stock AI 어드바이저 추가 및 UX 개선
- Gemini Pro 기반 AI 어드바이저 탭 추가 (TAB_ADVISOR) - 보유 종목 현재가 + 뉴스 → 종목별 매도/매수/분할매도 지침 - 5분 캐시, 강제 새로고침 버튼 - 경량 마크다운 렌더러 (AdvisorMarkdown) - 실현손익 수수료 → 수수료 & 세금으로 레이블 변경 - 총 자산 추이 그래프: 0 데이터 제외 (장 미개장일 필터) - Todo 완료 패널 하단 이동 + 날짜 필터 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
156
CLAUDE.md
156
CLAUDE.md
@@ -17,8 +17,14 @@
|
|||||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||||
| `/travel` | `Travel` | 여행 사진 갤러리 |
|
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
|
||||||
| `/lab` | `EffectLab` | UI/UX 실험 |
|
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||||
|
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||||
|
| `/lab` | `EffectLab` | UI/UX 실험 허브 |
|
||||||
|
| `/lab/sword-stream` | `SwordStream` | Three.js 파티클 인터랙션 |
|
||||||
|
| `/lab/day-calc` | `DayCalc` | 날짜 계산기 |
|
||||||
|
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||||
|
| `/todo` | `Todo` | 태스크 보드 |
|
||||||
|
|
||||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||||
|
|
||||||
@@ -49,23 +55,28 @@ apiPost('/api/portfolio', { ... })
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
|
||||||
target: 'https://gahusb.synology.me',
|
'/media': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
|
||||||
changeOrigin: true,
|
// /ext/* — Yahoo Finance, CNN Fear&Greed 등 외부 API 프록시
|
||||||
secure: true,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
개발 중 `/api/*` 요청은 NAS 백엔드로 프록시됨. 개발 서버 포트: **3007**
|
- `/api/*` → NAS 백엔드
|
||||||
|
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
|
||||||
|
- 개발 서버 포트: **3007**
|
||||||
|
|
||||||
### API 엔드포인트 목록
|
### API 엔드포인트 목록
|
||||||
|
|
||||||
| 분류 | 메서드 | 경로 |
|
| 분류 | 메서드 | 경로 |
|
||||||
|------|--------|------|
|
|------|--------|------|
|
||||||
| 로또 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
|
| 로또 기본 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
|
||||||
| 로또 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
|
| 로또 기본 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
|
||||||
| 로또 | POST | `/api/admin/simulate` |
|
| 로또 기본 | POST | `/api/admin/simulate` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/stats/performance` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/report/latest`, `/api/lotto/report/:drw_no`, `/api/lotto/report/history?limit=N` |
|
||||||
|
| 로또 고도화 | GET | `/api/lotto/analysis/personal` |
|
||||||
|
| 로또 구매 | GET | `/api/lotto/purchase?draw_no=N&days=N`, `/api/lotto/purchase/stats` |
|
||||||
|
| 로또 구매 | POST/PUT/DELETE | `/api/lotto/purchase`, `/api/lotto/purchase/:id` |
|
||||||
| 히스토리 | GET | `/api/history` |
|
| 히스토리 | GET | `/api/history` |
|
||||||
| 히스토리 | DELETE | `/api/history/:id` |
|
| 히스토리 | DELETE | `/api/history/:id` |
|
||||||
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||||
@@ -73,6 +84,21 @@ proxy: {
|
|||||||
| 트레이딩 | POST | `/api/trade/order` |
|
| 트레이딩 | POST | `/api/trade/order` |
|
||||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||||
|
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||||
|
| 예수금 | DELETE | `/api/portfolio/cash/:broker` |
|
||||||
|
| 자산 스냅샷 | POST | `/api/portfolio/snapshot` — body: `{ total_assets }` 또는 body 없이 서버 계산 |
|
||||||
|
| 자산 스냅샷 | GET | `/api/portfolio/snapshot/history?days=N` — response: `{ history: [{date, total_assets}] }` |
|
||||||
|
| 실현손익 | GET | `/api/portfolio/sell-history?broker=X&days=N` — response: `{ records: [...] }` |
|
||||||
|
| 실현손익 | POST/PUT | `/api/portfolio/sell-history`, `/api/portfolio/sell-history/:id` |
|
||||||
|
| 실현손익 | DELETE | `/api/portfolio/sell-history/:id` |
|
||||||
|
| TODO | GET/POST | `/api/todos` |
|
||||||
|
| TODO | PUT/DELETE | `/api/todos/:id`, `/api/todos/done` |
|
||||||
|
| 블로그 | GET/POST | `/api/blog/posts` |
|
||||||
|
| 블로그 | PUT/DELETE | `/api/blog/posts/:id` |
|
||||||
|
| AI 음악 | POST | `/api/music/generate` — body: `{ title, genre, moods, instruments, duration_sec, bpm, key, scale, prompt }` → `{ task_id }` |
|
||||||
|
| AI 음악 | GET | `/api/music/status/:task_id` → `{ status, progress, message, audio_url?, error?, track? }` |
|
||||||
|
| AI 음악 라이브러리 | GET/POST | `/api/music/library` — response: `{ tracks: [...] }` |
|
||||||
|
| AI 음악 라이브러리 | DELETE | `/api/music/library/:id` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,3 +173,111 @@ npm run preview # 빌드 결과물 미리보기
|
|||||||
| `scripts/deploy-nas.cjs` | NAS 배포 스크립트 |
|
| `scripts/deploy-nas.cjs` | NAS 배포 스크립트 |
|
||||||
| `src/content/blog/` | 블로그 마크다운 파일 |
|
| `src/content/blog/` | 블로그 마크다운 파일 |
|
||||||
| `public/` | 정적 파일 (로고, API 스펙 등) |
|
| `public/` | 정적 파일 (로고, API 스펙 등) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sonic Forge — AI 음악 생성 스튜디오 (`/lab/music`)
|
||||||
|
|
||||||
|
### 파일 구조
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `src/pages/music/MusicStudio.jsx` | 메인 컴포넌트 |
|
||||||
|
| `src/pages/music/MusicStudio.css` | 스타일 (Bebas Neue · Syne · Courier Prime) |
|
||||||
|
|
||||||
|
### 주요 컴포넌트
|
||||||
|
|
||||||
|
- **SonicRadar** — 헤더 우측 비주얼. SVG 링·크로스헤어·스윕 라인 + 48개 CSS 방사형 바. `isGenerating` / `accentColor` prop으로 상태 전환
|
||||||
|
- **WaveformCanvas** — 스테이지 우측 캔버스 오실로스코프 (헤더와 별도)
|
||||||
|
- **AudioPlayer** — 실제 `<audio>` 엘리먼트 기반. `audio_url` 없으면 타이머 폴백
|
||||||
|
- **Library** — 저장된 트랙 카드 그리드 + 삭제/재생
|
||||||
|
- **GenerationProgress** — 진행률 바 + 단계 메시지
|
||||||
|
|
||||||
|
### 생성 플로우
|
||||||
|
|
||||||
|
```
|
||||||
|
handleGenerate()
|
||||||
|
→ POST /api/music/generate (payload에 title 포함)
|
||||||
|
→ task_id 반환 시: setInterval 3초 폴링 (getMusicStatus)
|
||||||
|
succeeded → setTrack(status.track 우선, 없으면 로컬 조립) + loadLibrary()
|
||||||
|
failed → genError 표시
|
||||||
|
→ API 실패 시: 6단계 시뮬레이션 폴백 (오프라인 모드)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 백엔드 연동 규칙
|
||||||
|
|
||||||
|
- `audio_url`은 반드시 **상대경로** `/media/music/{task_id}.mp3` 형식 (절대 URL 금지)
|
||||||
|
- `status` 응답 shape: `{ status, progress, message, audio_url?, error?, track? }`
|
||||||
|
- `track` 객체: `{ id, title, genre, moods[], instruments[], duration_sec, bpm, key, scale, audio_url, created_at }`
|
||||||
|
- 백엔드가 `succeeded` 시 library 자동 등록 → 프론트는 "Save" 버튼 없음, `loadLibrary()` 자동 호출
|
||||||
|
- generate payload에 `title` 포함 → 백엔드에서 payload title 우선 사용 권장
|
||||||
|
|
||||||
|
### CSS 설계 특이사항
|
||||||
|
|
||||||
|
- 설명 토글: `.ms-desc-wrap` + `grid-template-rows: 0fr → 1fr` 트랜지션으로 높이 애니메이션
|
||||||
|
- 완전히 닫힐 때 노출 방지: `.ms-desc-wrap { overflow: hidden }` + `.ms-desc-wrap > * { min-height: 0 }`
|
||||||
|
- 장르 선택 시 `--ms-accent` / `--radar-accent` / `--g-color` CSS 변수로 전체 컬러 테마 동기화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lotto 고도화 (`/lotto`)
|
||||||
|
|
||||||
|
`src/pages/lotto/Functions.jsx`에 4개 신규 섹션 추가:
|
||||||
|
|
||||||
|
| 섹션 | API | 설명 |
|
||||||
|
|------|-----|------|
|
||||||
|
| PerformanceBanner | `/api/lotto/stats/performance` | 수익률·당첨 통계 상단 띠 |
|
||||||
|
| ReportPanel | `/api/lotto/report/*` | 주간 리포트 + 전략 카드 + ConfidenceRing |
|
||||||
|
| PersonalAnalysisPanel | `/api/lotto/analysis/personal` | 개인 번호 성향 분석 |
|
||||||
|
| PurchasePanel | `/api/lotto/purchase/*` | 구매 내역 CRUD |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Travel 갤러리 (`/travel`)
|
||||||
|
|
||||||
|
- 테마: "Dark Room" (배경 `#0f0c09`, 서체 Cormorant Garamond + Space Mono)
|
||||||
|
- 사진 URL: `/media/travel/...` 형식 → `vite.config.js` `/media` 프록시로 처리
|
||||||
|
- 프로덕션 nginx에도 `location /media/` 프록시 블록 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||||
|
|
||||||
|
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||||
|
|
||||||
|
### 구성 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `server.py` | FastAPI 서버 (generate/status/audio 엔드포인트) |
|
||||||
|
| `model.py` | MusicGen 래퍼 + 프롬프트 빌더 (genre/mood/instruments→텍스트) |
|
||||||
|
| `.env` | MODEL_NAME, OUTPUT_DIR, SERVER_PORT 등 |
|
||||||
|
| `setup.bat` | venv 생성 + PyTorch CUDA 12.4 + audiocraft 설치 |
|
||||||
|
| `start.bat` | 서버 시작 |
|
||||||
|
|
||||||
|
### 엔드포인트
|
||||||
|
|
||||||
|
| 메서드 | 경로 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| GET | `/health` | 서버·GPU 상태 확인 |
|
||||||
|
| POST | `/generate` | 음악 생성 → `task_id` 즉시 반환 |
|
||||||
|
| GET | `/status/{task_id}` | 생성 진행 폴링 |
|
||||||
|
| GET | `/audio/{task_id}.mp3` | 완성 오디오 파일 |
|
||||||
|
|
||||||
|
### 모델
|
||||||
|
|
||||||
|
- 기본: `facebook/musicgen-stereo-large` (16GB VRAM, 스테레오 고품질)
|
||||||
|
- RTX 5070 Ti(16GB)로 실행 가능
|
||||||
|
|
||||||
|
### NAS 연동 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
web-ui → POST /api/music/generate (NAS music-lab)
|
||||||
|
→ music-lab이 Windows PC :8765/generate 호출
|
||||||
|
→ Windows PC가 MusicGen 추론 → WAV → MP3 변환
|
||||||
|
→ music-lab이 /status 폴링 → audio_url 다운로드
|
||||||
|
→ /media/music/{task_id}.mp3 저장 → DB 등록
|
||||||
|
→ 프론트 폴링 성공 → Library 자동 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||||
|
|||||||
@@ -354,3 +354,11 @@ export function updateBlogPost(id, data) {
|
|||||||
export function deleteBlogPost(id) {
|
export function deleteBlogPost(id) {
|
||||||
return apiDelete(`/api/blog/posts/${id}`);
|
return apiDelete(`/api/blog/posts/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── AI 포트폴리오 분석 (Gemini Pro) ──────────────────────────────────────────
|
||||||
|
// GET /api/stock/ai-analysis?force=true
|
||||||
|
// response: { analysis: string, generated_at: string, cached: bool, holdings_count: int }
|
||||||
|
|
||||||
|
export function getAiAnalysis(force = false) {
|
||||||
|
return apiGet(`/api/stock/ai-analysis${force ? '?force=true' : ''}`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -462,6 +462,10 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stock-profit.is-negative {
|
||||||
|
color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
.stock-result {
|
.stock-result {
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -2650,3 +2654,259 @@
|
|||||||
grid-column: span 1;
|
grid-column: span 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════════════════════════════════════════════════════════════════
|
||||||
|
AI Advisor Tab (TAB_ADVISOR)
|
||||||
|
═══════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ── Tab button ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.stock-main-tab--advisor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stock-main-tab--advisor::after {
|
||||||
|
content: 'AI';
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 4px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel layout ────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.advisor-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__title-block {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #818cf8;
|
||||||
|
background: rgba(129, 140, 248, 0.12);
|
||||||
|
border: 1px solid rgba(129, 140, 248, 0.3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__sub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__timestamp {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading state ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.advisor-panel__loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-loading-spinner {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid rgba(129, 140, 248, 0.15);
|
||||||
|
border-top-color: #818cf8;
|
||||||
|
animation: spin 0.9s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-loading-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-loading-text p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-bright);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-loading-sub {
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--text-muted) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Error state ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.advisor-panel__error {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(249, 182, 177, 0.08);
|
||||||
|
border: 1px solid rgba(249, 182, 177, 0.3);
|
||||||
|
color: #f9b6b1;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.advisor-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 60px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__empty-icon {
|
||||||
|
font-size: 40px;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Analysis body ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.advisor-panel__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__disclaimer {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.6;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Markdown renderer ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.adv-md {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-h2 {
|
||||||
|
margin: 20px 0 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-bright);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-h3 {
|
||||||
|
margin: 14px 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-p strong {
|
||||||
|
color: var(--text-bright);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-p em {
|
||||||
|
color: #fbbf24;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-p code {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-hr {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
margin: 12px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.adv-md-gap {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.advisor-panel__head {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advisor-panel__actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
addSellHistory,
|
addSellHistory,
|
||||||
updateSellHistory,
|
updateSellHistory,
|
||||||
deleteSellHistory,
|
deleteSellHistory,
|
||||||
|
getAiAnalysis,
|
||||||
} from '../../api';
|
} from '../../api';
|
||||||
import Loading from '../../components/Loading';
|
import Loading from '../../components/Loading';
|
||||||
import './Stock.css';
|
import './Stock.css';
|
||||||
@@ -91,6 +92,38 @@ const toNumeric = (value) => {
|
|||||||
return Number.isNaN(numeric) ? null : numeric;
|
return Number.isNaN(numeric) ? null : numeric;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ── AdvisorMarkdown — 경량 마크다운 렌더러 ──────────────────────── */
|
||||||
|
|
||||||
|
const AdvisorMarkdown = ({ text }) => {
|
||||||
|
if (!text) return null;
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const elements = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < lines.length) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.startsWith('### ')) {
|
||||||
|
elements.push(<h4 key={i} className="adv-md-h3">{line.slice(4)}</h4>);
|
||||||
|
} else if (line.startsWith('## ')) {
|
||||||
|
elements.push(<h3 key={i} className="adv-md-h2">{line.slice(3)}</h3>);
|
||||||
|
} else if (line.startsWith('---')) {
|
||||||
|
elements.push(<hr key={i} className="adv-md-hr" />);
|
||||||
|
} else if (line.trim() === '') {
|
||||||
|
elements.push(<div key={i} className="adv-md-gap" />);
|
||||||
|
} else {
|
||||||
|
// 인라인 마크다운: **bold**, *italic*, 🎯 등 이모지 보존
|
||||||
|
const rendered = line
|
||||||
|
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||||
|
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||||
|
.replace(/`(.+?)`/g, '<code>$1</code>');
|
||||||
|
elements.push(
|
||||||
|
<p key={i} className="adv-md-p" dangerouslySetInnerHTML={{ __html: rendered }} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return <div className="adv-md">{elements}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
/* ── Chart colors ──────────────────────────────────────────────── */
|
/* ── Chart colors ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
|
||||||
@@ -144,6 +177,7 @@ const emptySellForm = () => ({
|
|||||||
quantity: '',
|
quantity: '',
|
||||||
avg_price: '',
|
avg_price: '',
|
||||||
sell_price: '',
|
sell_price: '',
|
||||||
|
commission: '',
|
||||||
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
sold_at: toLocalDatetimeValue(new Date().toISOString()),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -152,6 +186,7 @@ const emptySellForm = () => ({
|
|||||||
const TAB_PORTFOLIO = 'portfolio';
|
const TAB_PORTFOLIO = 'portfolio';
|
||||||
const TAB_AI = 'ai';
|
const TAB_AI = 'ai';
|
||||||
const TAB_REPORT = 'report';
|
const TAB_REPORT = 'report';
|
||||||
|
const TAB_ADVISOR = 'advisor';
|
||||||
|
|
||||||
/* ── component ───────────────────────────────────────────────────── */
|
/* ── component ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -202,6 +237,11 @@ const StockTrade = () => {
|
|||||||
const [sellFormSaving, setSellFormSaving] = useState(false);
|
const [sellFormSaving, setSellFormSaving] = useState(false);
|
||||||
const [sellFormError, setSellFormError] = useState('');
|
const [sellFormError, setSellFormError] = useState('');
|
||||||
|
|
||||||
|
/* AI 전문가 분석 (Gemini Pro Advisor) */
|
||||||
|
const [advisorData, setAdvisorData] = useState(null); // { analysis, generated_at, cached, holdings_count }
|
||||||
|
const [advisorLoading, setAdvisorLoading] = useState(false);
|
||||||
|
const [advisorError, setAdvisorError] = useState('');
|
||||||
|
|
||||||
/* Cash (예수금) form */
|
/* Cash (예수금) form */
|
||||||
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
|
||||||
const [cashSaving, setCashSaving] = useState(false);
|
const [cashSaving, setCashSaving] = useState(false);
|
||||||
@@ -282,6 +322,20 @@ const StockTrade = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadAdvisorAnalysis = useCallback(async (force = false) => {
|
||||||
|
setAdvisorLoading(true);
|
||||||
|
setAdvisorError('');
|
||||||
|
try {
|
||||||
|
const data = await getAiAnalysis(force);
|
||||||
|
if (data?.error) throw new Error(data.error);
|
||||||
|
setAdvisorData(data);
|
||||||
|
} catch (err) {
|
||||||
|
setAdvisorError(err?.message ?? '분석 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setAdvisorLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadBalance = useCallback(async () => {
|
const loadBalance = useCallback(async () => {
|
||||||
setBalanceLoading(true);
|
setBalanceLoading(true);
|
||||||
setBalanceError('');
|
setBalanceError('');
|
||||||
@@ -322,10 +376,12 @@ const StockTrade = () => {
|
|||||||
const d = new Date(today);
|
const d = new Date(today);
|
||||||
d.setDate(today.getDate() - (days - 1 - i));
|
d.setDate(today.getDate() - (days - 1 - i));
|
||||||
const dateStr = toLocalDate(d);
|
const dateStr = toLocalDate(d);
|
||||||
return { date: dateStr, total_assets: byDate[dateStr] ?? 0 };
|
const val = byDate[dateStr];
|
||||||
});
|
return val > 0 ? { date: dateStr, total_assets: val } : null;
|
||||||
|
}).filter(Boolean);
|
||||||
} else {
|
} else {
|
||||||
filled = Object.entries(byDate)
|
filled = Object.entries(byDate)
|
||||||
|
.filter(([, total_assets]) => total_assets > 0)
|
||||||
.map(([date, total_assets]) => ({ date, total_assets }))
|
.map(([date, total_assets]) => ({ date, total_assets }))
|
||||||
.sort((a, b) => a.date.localeCompare(b.date));
|
.sort((a, b) => a.date.localeCompare(b.date));
|
||||||
}
|
}
|
||||||
@@ -358,8 +414,10 @@ const StockTrade = () => {
|
|||||||
loadBalance();
|
loadBalance();
|
||||||
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
|
||||||
loadPortfolio();
|
loadPortfolio();
|
||||||
|
} else if (activeTab === TAB_ADVISOR && !advisorData) {
|
||||||
|
loadAdvisorAnalysis();
|
||||||
}
|
}
|
||||||
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
|
}, [activeTab, portfolioLoaded, balanceLoaded, advisorData, loadPortfolio, loadBalance, loadSellHistory, loadAdvisorAnalysis]);
|
||||||
|
|
||||||
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
|
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -622,6 +680,7 @@ const StockTrade = () => {
|
|||||||
quantity: String(record.quantity ?? ''),
|
quantity: String(record.quantity ?? ''),
|
||||||
avg_price: String(record.avg_price ?? ''),
|
avg_price: String(record.avg_price ?? ''),
|
||||||
sell_price: String(record.sell_price ?? ''),
|
sell_price: String(record.sell_price ?? ''),
|
||||||
|
commission: String(record.commission ?? ''),
|
||||||
sold_at: toLocalDatetimeValue(record.sold_at),
|
sold_at: toLocalDatetimeValue(record.sold_at),
|
||||||
});
|
});
|
||||||
setSellFormError('');
|
setSellFormError('');
|
||||||
@@ -644,9 +703,10 @@ const StockTrade = () => {
|
|||||||
const qty = Number(sellForm.quantity);
|
const qty = Number(sellForm.quantity);
|
||||||
const avgPrice = Number(sellForm.avg_price);
|
const avgPrice = Number(sellForm.avg_price);
|
||||||
const sellPrice = Number(sellForm.sell_price);
|
const sellPrice = Number(sellForm.sell_price);
|
||||||
|
const commission = Number(sellForm.commission) || 0;
|
||||||
const buyAmount = avgPrice * qty;
|
const buyAmount = avgPrice * qty;
|
||||||
const sellAmount = sellPrice * qty;
|
const sellAmount = sellPrice * qty;
|
||||||
const realizedProfit = sellAmount - buyAmount;
|
const realizedProfit = sellAmount - buyAmount - commission;
|
||||||
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -656,6 +716,7 @@ const StockTrade = () => {
|
|||||||
quantity: qty,
|
quantity: qty,
|
||||||
avg_price: avgPrice,
|
avg_price: avgPrice,
|
||||||
sell_price: sellPrice,
|
sell_price: sellPrice,
|
||||||
|
commission,
|
||||||
buy_amount: buyAmount,
|
buy_amount: buyAmount,
|
||||||
sell_amount: sellAmount,
|
sell_amount: sellAmount,
|
||||||
realized_profit: realizedProfit,
|
realized_profit: realizedProfit,
|
||||||
@@ -994,8 +1055,9 @@ ${holdingsText}${marketText}
|
|||||||
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
|
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
|
||||||
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
|
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
|
||||||
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
|
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
|
||||||
|
const totalCommission = filteredSellHistory.reduce((s, r) => s + (r.commission ?? 0), 0);
|
||||||
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
|
||||||
return { totalProfit, totalSell, totalBuy, rate, count: filteredSellHistory.length };
|
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
|
||||||
}, [filteredSellHistory]);
|
}, [filteredSellHistory]);
|
||||||
|
|
||||||
/* ── render ───────────────────────────────────────────────────── */
|
/* ── render ───────────────────────────────────────────────────── */
|
||||||
@@ -1123,6 +1185,15 @@ ${holdingsText}${marketText}
|
|||||||
<span className="stock-main-tab__label">리포트</span>
|
<span className="stock-main-tab__label">리포트</span>
|
||||||
<span className="stock-main-tab__sub">분석·AI코치</span>
|
<span className="stock-main-tab__sub">분석·AI코치</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`stock-main-tab stock-main-tab--advisor ${activeTab === TAB_ADVISOR ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setActiveTab(TAB_ADVISOR)}
|
||||||
|
>
|
||||||
|
<span className="stock-main-tab__icon">🧠</span>
|
||||||
|
<span className="stock-main-tab__label">AI 어드바이저</span>
|
||||||
|
<span className="stock-main-tab__sub">Gemini Pro</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ════════════════════════════════════════════════════════
|
{/* ════════════════════════════════════════════════════════
|
||||||
@@ -1974,6 +2045,83 @@ ${holdingsText}${marketText}
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ════════════════════════════════════════════════════════
|
||||||
|
TAB 4: AI 어드바이저 (Gemini Pro)
|
||||||
|
════════════════════════════════════════════════════════ */}
|
||||||
|
{activeTab === TAB_ADVISOR && (
|
||||||
|
<section className="stock-panel stock-panel--wide advisor-panel">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="advisor-panel__head">
|
||||||
|
<div className="advisor-panel__title-block">
|
||||||
|
<span className="advisor-panel__badge">Gemini Pro</span>
|
||||||
|
<h3 className="advisor-panel__title">AI 전문가 분석</h3>
|
||||||
|
<p className="advisor-panel__sub">
|
||||||
|
보유 종목 현재가와 오늘의 뉴스를 기반으로 전문가 관점의 매매 조언을 제공합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="advisor-panel__actions">
|
||||||
|
{advisorData && (
|
||||||
|
<span className="advisor-panel__timestamp">
|
||||||
|
{advisorData.cached ? '🗂 캐시' : '✨ 신규'} ·{' '}
|
||||||
|
{new Date(advisorData.generated_at).toLocaleTimeString('ko-KR', {
|
||||||
|
hour: '2-digit', minute: '2-digit',
|
||||||
|
})} 기준
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="button ghost small"
|
||||||
|
onClick={() => loadAdvisorAnalysis(true)}
|
||||||
|
disabled={advisorLoading}
|
||||||
|
>
|
||||||
|
{advisorLoading ? '분석 중...' : '🔄 새로 분석'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 로딩 */}
|
||||||
|
{advisorLoading && (
|
||||||
|
<div className="advisor-panel__loading">
|
||||||
|
<div className="advisor-loading-spinner" />
|
||||||
|
<div className="advisor-loading-text">
|
||||||
|
<p>Gemini Pro가 포트폴리오를 분석 중입니다...</p>
|
||||||
|
<p className="advisor-loading-sub">현재가 조회 · 뉴스 분석 · 전략 수립</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 에러 */}
|
||||||
|
{!advisorLoading && advisorError && (
|
||||||
|
<div className="advisor-panel__error">
|
||||||
|
<span>⚠️ {advisorError}</span>
|
||||||
|
<button className="button ghost small" onClick={() => loadAdvisorAnalysis(true)}>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 분석 결과 */}
|
||||||
|
{!advisorLoading && !advisorError && advisorData && (
|
||||||
|
<div className="advisor-panel__body">
|
||||||
|
<AdvisorMarkdown text={advisorData.analysis} />
|
||||||
|
<p className="advisor-panel__disclaimer">
|
||||||
|
※ 이 분석은 AI가 생성한 참고 자료이며 투자 결정은 본인의 판단과 책임 하에 이루어져야 합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 초기 상태 */}
|
||||||
|
{!advisorLoading && !advisorError && !advisorData && (
|
||||||
|
<div className="advisor-panel__empty">
|
||||||
|
<span className="advisor-panel__empty-icon">🧠</span>
|
||||||
|
<p>아직 분석 결과가 없습니다.</p>
|
||||||
|
<button className="button primary" onClick={() => loadAdvisorAnalysis()}>
|
||||||
|
분석 시작
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ════════════════════════════════════════════════════════
|
{/* ════════════════════════════════════════════════════════
|
||||||
TAB 3: 리포트 + AI 코치
|
TAB 3: 리포트 + AI 코치
|
||||||
════════════════════════════════════════════════════════ */}
|
════════════════════════════════════════════════════════ */}
|
||||||
@@ -2528,6 +2676,17 @@ ${holdingsText}${marketText}
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
수수료 & 세금 (원)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={sellForm.commission}
|
||||||
|
onChange={(e) => setSellForm((p) => ({ ...p, commission: e.target.value }))}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label className="sh-form__datetime">
|
<label className="sh-form__datetime">
|
||||||
매도 일시
|
매도 일시
|
||||||
<input
|
<input
|
||||||
@@ -2542,11 +2701,15 @@ ${holdingsText}${marketText}
|
|||||||
const qty = Number(sellForm.quantity);
|
const qty = Number(sellForm.quantity);
|
||||||
const buy = Number(sellForm.avg_price) * qty;
|
const buy = Number(sellForm.avg_price) * qty;
|
||||||
const sell = Number(sellForm.sell_price) * qty;
|
const sell = Number(sellForm.sell_price) * qty;
|
||||||
const profit = sell - buy;
|
const commission = Number(sellForm.commission) || 0;
|
||||||
|
const profit = sell - buy - commission;
|
||||||
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
const rate = buy > 0 ? (profit / buy) * 100 : 0;
|
||||||
return (
|
return (
|
||||||
<div className="sh-form__preview">
|
<div className="sh-form__preview">
|
||||||
<span>매도금액 <strong>{formatNumber(Math.round(sell))}원</strong></span>
|
<span>매도금액 <strong>{formatNumber(Math.round(sell))}원</strong></span>
|
||||||
|
{commission > 0 && (
|
||||||
|
<span>수수료 & 세금 <strong className="stock-profit is-negative">-{formatNumber(Math.round(commission))}원</strong></span>
|
||||||
|
)}
|
||||||
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
<span>실현손익 <strong className={`stock-profit ${profitColorClass(profit)}`}>{formatNumber(Math.round(profit))}원</strong></span>
|
||||||
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
<span>수익률 <strong className={`stock-profit ${profitColorClass(rate)}`}>{formatPercent(rate)}</strong></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -2611,6 +2774,12 @@ ${holdingsText}${marketText}
|
|||||||
<span>총 매도금액</span>
|
<span>총 매도금액</span>
|
||||||
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
<strong>{formatNumber(sellHistorySummary.totalSell)}원</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sell-history__summary-card">
|
||||||
|
<span>총 수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(sellHistorySummary.totalCommission))}원
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
<div className="sell-history__summary-card">
|
<div className="sell-history__summary-card">
|
||||||
<span>실현손익 합계</span>
|
<span>실현손익 합계</span>
|
||||||
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
|
||||||
@@ -2684,6 +2853,14 @@ ${holdingsText}${marketText}
|
|||||||
<span>매도금액</span>
|
<span>매도금액</span>
|
||||||
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
{(r.commission > 0) && (
|
||||||
|
<div>
|
||||||
|
<span>수수료 & 세금</span>
|
||||||
|
<strong className="stock-profit is-negative">
|
||||||
|
-{formatNumber(Math.round(r.commission))}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<span>실현손익</span>
|
<span>실현손익</span>
|
||||||
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
<strong className={`stock-profit ${profitColorClass(profitN)}`}>
|
||||||
|
|||||||
Reference in New Issue
Block a user