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:
2026-03-25 03:54:50 +09:00
parent 2c4b1e2e3a
commit d1ecf13400
4 changed files with 596 additions and 17 deletions

156
CLAUDE.md
View File

@@ -17,8 +17,14 @@
| `/lotto` | `Lotto` | 로또 추천/통계 |
| `/stock` | `Stock` | 주식 뉴스/지수 |
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
| `/travel` | `Travel` | 여행 사진 갤러리 |
| `/lab` | `EffectLab` | UI/UX 실험 |
| `/realestate` | `Subscription` | 청약 자격·일정 관리 |
| `/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`
@@ -49,23 +55,28 @@ apiPost('/api/portfolio', { ... })
```js
proxy: {
'/api': {
target: 'https://gahusb.synology.me',
changeOrigin: true,
secure: true,
},
'/api': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
'/media': { target: 'https://gahusb.synology.me', changeOrigin: true, secure: true },
// /ext/* — Yahoo Finance, CNN Fear&Greed 등 외부 API 프록시
}
```
개발 중 `/api/*` 요청은 NAS 백엔드로 프록시됨. 개발 서버 포트: **3007**
- `/api/*` NAS 백엔드
- `/media/*` → NAS 미디어 파일 (여행 사진 `/media/travel/`, 음악 `/media/music/`)
- 개발 서버 포트: **3007**
### API 엔드포인트 목록
| 분류 | 메서드 | 경로 |
|------|--------|------|
| 로또 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
| 로또 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
| 로또 | POST | `/api/admin/simulate` |
| 로또 기본 | GET | `/api/lotto/latest`, `/api/lotto/stats`, `/api/lotto/recommend` |
| 로또 기본 | GET | `/api/lotto/best`, `/api/lotto/analysis` |
| 로또 기본 | 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` |
| 히스토리 | DELETE | `/api/history/:id` |
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
@@ -73,6 +84,21 @@ proxy: {
| 트레이딩 | POST | `/api/trade/order` |
| 포트폴리오 | GET/POST | `/api/portfolio` |
| 포트폴리오 | 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 배포 스크립트 |
| `src/content/blog/` | 블로그 마크다운 파일 |
| `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 인바운드 허용 필요.

View File

@@ -354,3 +354,11 @@ export function updateBlogPost(id, data) {
export function deleteBlogPost(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' : ''}`);
}

View File

@@ -462,6 +462,10 @@
color: var(--muted);
}
.stock-profit.is-negative {
color: #3b82f6;
}
.stock-result {
border: 1px solid var(--line);
border-radius: 14px;
@@ -2650,3 +2654,259 @@
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;
}
}

View File

@@ -19,6 +19,7 @@ import {
addSellHistory,
updateSellHistory,
deleteSellHistory,
getAiAnalysis,
} from '../../api';
import Loading from '../../components/Loading';
import './Stock.css';
@@ -91,6 +92,38 @@ const toNumeric = (value) => {
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 ──────────────────────────────────────────────── */
const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80'];
@@ -144,6 +177,7 @@ const emptySellForm = () => ({
quantity: '',
avg_price: '',
sell_price: '',
commission: '',
sold_at: toLocalDatetimeValue(new Date().toISOString()),
});
@@ -152,6 +186,7 @@ const emptySellForm = () => ({
const TAB_PORTFOLIO = 'portfolio';
const TAB_AI = 'ai';
const TAB_REPORT = 'report';
const TAB_ADVISOR = 'advisor';
/* ── component ───────────────────────────────────────────────────── */
@@ -202,6 +237,11 @@ const StockTrade = () => {
const [sellFormSaving, setSellFormSaving] = useState(false);
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 */
const [cashForm, setCashForm] = useState({ broker: '', cash: '' });
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 () => {
setBalanceLoading(true);
setBalanceError('');
@@ -322,10 +376,12 @@ const StockTrade = () => {
const d = new Date(today);
d.setDate(today.getDate() - (days - 1 - i));
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 {
filled = Object.entries(byDate)
.filter(([, total_assets]) => total_assets > 0)
.map(([date, total_assets]) => ({ date, total_assets }))
.sort((a, b) => a.date.localeCompare(b.date));
}
@@ -358,8 +414,10 @@ const StockTrade = () => {
loadBalance();
} else if (activeTab === TAB_REPORT && !portfolioLoaded) {
loadPortfolio();
} else if (activeTab === TAB_ADVISOR && !advisorData) {
loadAdvisorAnalysis();
}
}, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]);
}, [activeTab, portfolioLoaded, balanceLoaded, advisorData, loadPortfolio, loadBalance, loadSellHistory, loadAdvisorAnalysis]);
/* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */
useEffect(() => {
@@ -622,6 +680,7 @@ const StockTrade = () => {
quantity: String(record.quantity ?? ''),
avg_price: String(record.avg_price ?? ''),
sell_price: String(record.sell_price ?? ''),
commission: String(record.commission ?? ''),
sold_at: toLocalDatetimeValue(record.sold_at),
});
setSellFormError('');
@@ -644,9 +703,10 @@ const StockTrade = () => {
const qty = Number(sellForm.quantity);
const avgPrice = Number(sellForm.avg_price);
const sellPrice = Number(sellForm.sell_price);
const commission = Number(sellForm.commission) || 0;
const buyAmount = avgPrice * qty;
const sellAmount = sellPrice * qty;
const realizedProfit = sellAmount - buyAmount;
const realizedProfit = sellAmount - buyAmount - commission;
const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0;
const payload = {
@@ -656,6 +716,7 @@ const StockTrade = () => {
quantity: qty,
avg_price: avgPrice,
sell_price: sellPrice,
commission,
buy_amount: buyAmount,
sell_amount: sellAmount,
realized_profit: realizedProfit,
@@ -994,8 +1055,9 @@ ${holdingsText}${marketText}
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 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;
return { totalProfit, totalSell, totalBuy, rate, count: filteredSellHistory.length };
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
}, [filteredSellHistory]);
/* ── render ───────────────────────────────────────────────────── */
@@ -1123,6 +1185,15 @@ ${holdingsText}${marketText}
<span className="stock-main-tab__label">리포트</span>
<span className="stock-main-tab__sub">분석·AI코치</span>
</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>
{/* ════════════════════════════════════════════════════════
@@ -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 코치
════════════════════════════════════════════════════════ */}
@@ -2528,6 +2676,17 @@ ${holdingsText}${marketText}
required
/>
</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">
매도 일시
<input
@@ -2542,11 +2701,15 @@ ${holdingsText}${marketText}
const qty = Number(sellForm.quantity);
const buy = Number(sellForm.avg_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;
return (
<div className="sh-form__preview">
<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(rate)}`}>{formatPercent(rate)}</strong></span>
</div>
@@ -2611,6 +2774,12 @@ ${holdingsText}${marketText}
<span> 매도금액</span>
<strong>{formatNumber(sellHistorySummary.totalSell)}</strong>
</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">
<span>실현손익 합계</span>
<strong className={`stock-profit ${profitColorClass(sellHistorySummary.totalProfit)}`}>
@@ -2684,6 +2853,14 @@ ${holdingsText}${marketText}
<span>매도금액</span>
<strong>{formatNumber(Math.round(r.sell_amount))}</strong>
</div>
{(r.commission > 0) && (
<div>
<span>수수료 & 세금</span>
<strong className="stock-profit is-negative">
-{formatNumber(Math.round(r.commission))}
</strong>
</div>
)}
<div>
<span>실현손익</span>
<strong className={`stock-profit ${profitColorClass(profitN)}`}>