import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { createTradeOrder, getTradeBalance, getPortfolio, addPortfolio, updatePortfolio, deletePortfolio, upsertCash, deleteCash, getFearAndGreed, getVix, getTreasury10Y, getWTI, getAssetHistory, saveAssetSnapshot, getSellHistory, addSellHistory, updateSellHistory, deleteSellHistory, } from '../../api'; import Loading from '../../components/Loading'; import './Stock.css'; import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, Legend, ResponsiveContainer, AreaChart, Area, } from 'recharts'; /* ── helpers ─────────────────────────────────────────────────────── */ const formatNumber = (value) => { if (value === null || value === undefined || value === '') return '-'; const numeric = Number(value); if (Number.isNaN(numeric)) return value; return new Intl.NumberFormat('ko-KR').format(numeric); }; const formatPercent = (value) => { if (value === null || value === undefined || value === '') return '-'; if (typeof value === 'string' && value.includes('%')) return value; const numeric = Number(value); if (Number.isNaN(numeric)) return value; return `${numeric >= 0 ? '+' : ''}${numeric.toFixed(2)}%`; }; const pickFirst = (...values) => values.find((value) => value !== undefined && value !== null && value !== ''); const getQty = (item) => pickFirst(item?.qty, item?.quantity, item?.holding, item?.hold_qty); const getBuyPrice = (item) => pickFirst( item?.buy_price, item?.avg_price, item?.avg, item?.purchase_price, item?.buyPrice, item?.price ); const getCurrentPrice = (item) => pickFirst( item?.current_price, item?.current, item?.cur_price, item?.now_price, item?.market_price ); const getProfitRate = (item) => pickFirst( item?.profit_rate, item?.profitRate, item?.profit_pct, item?.profitPercent, item?.pnl_rate, item?.return_rate, item?.yield ); const getProfitLoss = (item) => pickFirst(item?.profit_loss, item?.pnl, item?.profitLoss); const toNumeric = (value) => { if (value === null || value === undefined || value === '') return null; const numeric = Number(String(value).replace(/[^0-9.-]/g, '')); return Number.isNaN(numeric) ? null : numeric; }; /* ── Chart colors ──────────────────────────────────────────────── */ const CHART_COLORS = ['#818cf8', '#fbbf24', '#34d399', '#f472b6', '#fb923c', '#a78bfa', '#38bdf8', '#4ade80']; const profitColorClass = (numericValue) => { if (numericValue > 0) return 'is-up'; if (numericValue < 0) return 'is-down'; if (numericValue === 0) return 'is-flat'; return ''; }; const getVixLabel = (vix) => { if (vix < 12) return '극히 낮음 (안일 주의)'; if (vix < 20) return '정상 (안정적)'; if (vix < 30) return '주의 (불확실성 증가)'; if (vix < 40) return '높음 (극도의 공포)'; return '극단 (패닉)'; }; const getFgLabel = (score) => { if (score <= 25) return '극단적 공포'; if (score <= 45) return '공포'; if (score <= 55) return '중립'; if (score <= 75) return '탐욕'; return '극단적 탐욕'; }; /* ── empty portfolio form ────────────────────────────────────────── */ const emptyPortfolioForm = { broker: '', ticker: '', name: '', quantity: '', avg_price: '', }; /* ── empty sell-history form ─────────────────────────────────────── */ const toLocalDatetimeValue = (isoStr) => { if (!isoStr) return ''; const d = new Date(isoStr); const pad = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; }; const emptySellForm = () => ({ broker: '', ticker: '', name: '', quantity: '', avg_price: '', sell_price: '', commission: '', sold_at: toLocalDatetimeValue(new Date().toISOString()), }); /* ── TAB IDs ─────────────────────────────────────────────────────── */ const TAB_PORTFOLIO = 'portfolio'; const TAB_AI = 'ai'; const TAB_REPORT = 'report'; const TAB_ADVISOR = 'advisor'; /* ── component ───────────────────────────────────────────────────── */ const StockTrade = () => { /* Active tab */ const [activeTab, setActiveTab] = useState(TAB_REPORT); /* ────────────────────────────────────────────────────────────── */ /* 쟁승토리 계좌 (Portfolio) state */ /* ────────────────────────────────────────────────────────────── */ const [portfolio, setPortfolio] = useState(null); const [portfolioLoading, setPortfolioLoading] = useState(false); const [portfolioError, setPortfolioError] = useState(''); const [portfolioLoaded, setPortfolioLoaded] = useState(false); /* Portfolio add form */ const [addForm, setAddForm] = useState({ ...emptyPortfolioForm }); const [addFormOpen, setAddFormOpen] = useState(false); const [addLoading, setAddLoading] = useState(false); const [addError, setAddError] = useState(''); /* Portfolio edit */ const [editingId, setEditingId] = useState(null); const [editForm, setEditForm] = useState({}); const [editLoading, setEditLoading] = useState(false); const editOrigRef = useRef({}); /* Portfolio delete */ const [deleteConfirmId, setDeleteConfirmId] = useState(null); /* Portfolio sell */ const [sellConfirmId, setSellConfirmId] = useState(null); const [sellLoading, setSellLoading] = useState(false); /* 실현손익 내역 */ const [sellHistory, setSellHistory] = useState([]); const [sellHistoryLoading, setSellHistoryLoading] = useState(false); const [sellHistoryBroker, setSellHistoryBroker] = useState('ALL'); const [sellHistoryPeriod, setSellHistoryPeriod] = useState('3M'); /* 실현손익 드로어 */ const [sellDrawerOpen, setSellDrawerOpen] = useState(false); /* 실현손익 수동 추가/수정 폼 */ const [sellFormOpen, setSellFormOpen] = useState(false); const [sellEditId, setSellEditId] = useState(null); // null = 추가, number = 수정 중 id const [sellForm, setSellForm] = useState(emptySellForm()); const [sellFormSaving, setSellFormSaving] = useState(false); const [sellFormError, setSellFormError] = useState(''); /* AI 어드바이저 — 프롬프트 복사 */ const [advisorCopied, setAdvisorCopied] = useState(false); /* Cash (예수금) form */ const [cashForm, setCashForm] = useState({ broker: '', cash: '' }); const [cashSaving, setCashSaving] = useState(false); const [cashError, setCashError] = useState(''); /* Cash inline edit */ const [cashEditingBroker, setCashEditingBroker] = useState(null); const [cashEditingValue, setCashEditingValue] = useState(''); const [cashEditSaving, setCashEditSaving] = useState(false); /* ────────────────────────────────────────────────────────────── */ /* 자산 추이 state */ /* ────────────────────────────────────────────────────────────── */ const [assetHistory, setAssetHistory] = useState(null); const [assetHistoryLoading, setAssetHistoryLoading] = useState(false); const [assetHistoryDays, setAssetHistoryDays] = useState(30); const [snapshotSaving, setSnapshotSaving] = useState(false); /* ────────────────────────────────────────────────────────────── */ /* 리포트 탭 state */ /* ────────────────────────────────────────────────────────────── */ const [reportSortField, setReportSortField] = useState('profit_rate'); const [reportSortDir, setReportSortDir] = useState('desc'); /* AI Coach */ const [aiApiKey, setAiApiKey] = useState(''); const [aiModel, setAiModel] = useState('claude-haiku-4-5-20251001'); const [aiResult, setAiResult] = useState(null); const [aiLoading, setAiLoading] = useState(false); const [aiError, setAiError] = useState(''); const [marketCtx, setMarketCtx] = useState(null); /* ────────────────────────────────────────────────────────────── */ /* AI 투자 (Balance) state */ /* ────────────────────────────────────────────────────────────── */ const [balance, setBalance] = useState(null); const [balanceLoading, setBalanceLoading] = useState(false); const [balanceError, setBalanceError] = useState(''); const [balanceLoaded, setBalanceLoaded] = useState(false); /* Manual order state */ const [manualForm, setManualForm] = useState({ code: '', qty: 1, price: 0, type: 'buy', }); const [manualLoading, setManualLoading] = useState(false); const [manualError, setManualError] = useState(''); const [manualResult, setManualResult] = useState(null); const [kisModal, setKisModal] = useState(''); /* ── loaders ─────────────────────────────────────────────────── */ const loadPortfolio = useCallback(async () => { setPortfolioLoading(true); setPortfolioError(''); try { const data = await getPortfolio(); setPortfolio(data); setPortfolioLoaded(true); } catch (err) { setPortfolioError(err?.message ?? String(err)); } finally { setPortfolioLoading(false); } }, []); const loadSellHistory = useCallback(async () => { setSellHistoryLoading(true); try { const data = await getSellHistory(); setSellHistory(data?.records ?? (Array.isArray(data) ? data : [])); } catch { /* 백엔드 미구현 시 빈 배열 유지 */ } finally { setSellHistoryLoading(false); } }, []); /* AI 어드바이저: 포트폴리오 기반 프롬프트 생성 */ const buildAdvisorPrompt = useCallback(() => { const today = new Date().toLocaleDateString('ko-KR', { year: 'numeric', month: 'long', day: 'numeric' }); const holdingsLines = portfolioHoldings.map((h) => { const cp = h.current_price != null ? `${formatNumber(h.current_price)}원` : '시세 미조회'; const rate = h.profit_rate != null ? formatPercent(h.profit_rate) : '미조회'; const profit = h.profit_amount != null ? `(${h.profit_amount >= 0 ? '+' : ''}${formatNumber(h.profit_amount)}원)` : ''; return `- **${h.name ?? h.ticker}** (${h.ticker ?? ''}) | 계좌: ${h.broker ?? '-'} 수량 ${h.quantity}주 | 평균매입가 ${formatNumber(h.avg_price)}원 | 현재가 ${cp} | 손익 ${rate} ${profit}`; }).join('\n'); const cashLines = cashList.map((c) => `- ${c.broker}: ${formatNumber(c.cash)}원`).join('\n') || '- 없음'; const marketLines = marketCtx ? [ `VIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}`, `공포탐욕지수: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}`, `미 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}`, `WTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}`, ].join('\n') : '시장 데이터 미로드'; return `당신은 15년 이상 경력의 한국 주식시장 전문 애널리스트입니다. 오늘은 ${today}입니다. 아래 포트폴리오 정보와 시장 환경을 바탕으로 전문가 분석을 제공해주세요. --- ## 📊 현재 시장 환경 ${marketLines} --- ## 💼 보유 포트폴리오 ### 보유 종목 (${portfolioHoldings.length}개) ${holdingsLines || '보유 종목 없음'} ### 포트폴리오 요약 - 총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원 - 총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원 - 총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)}) - 예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'} - 총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'} ### 예수금 현황 ${cashLines} --- ## 🔍 분석 요청 다음 형식으로 명확하게 작성해주세요: ### 📈 오늘의 시장 환경 시장 환경 데이터를 바탕으로 오늘 한국 주식시장의 전반적인 분위기와 주요 이슈를 2-3문장으로 요약하세요. ### 🔍 종목별 분석 및 행동 지침 각 보유 종목에 대해 아래 형식으로 작성하세요: **[종목명 (티커)]** - 현황: 현재 손익 상태와 포지션 평가 - 분석: 업황·섹터 동향, 주요 리스크/기회 - 🎯 행동 지침: **[매도 / 보유 / 추가매수 / 분할매도]** — 구체적 이유와 목표 참고 가격대 ### 💼 포트폴리오 종합 의견 전체 포트폴리오의 섹터 편중, 리밸런싱 필요 여부, 현금 비중 조언을 작성하세요. ### ⚠️ 오늘 주의해야 할 리스크 매크로·섹터·개별 종목 측면에서 오늘 특히 주의할 리스크를 2-3가지 나열하세요. --- 분석은 반드시 한국어로, 구체적인 수치와 근거를 들어 전문적으로 작성해주세요. 투자 결정은 최종적으로 투자자 본인이 판단함을 명시하세요.`; }, [portfolioHoldings, portfolioSummary, cashList, totalCash, totalAssets, marketCtx]); const loadBalance = useCallback(async () => { setBalanceLoading(true); setBalanceError(''); try { const data = await getTradeBalance(); setBalance(data); setBalanceLoaded(true); } catch (err) { setBalanceError(err?.message ?? String(err)); } finally { setBalanceLoading(false); } }, []); const loadAssetHistory = useCallback(async (days) => { setAssetHistoryLoading(true); try { const data = await getAssetHistory(days); // 백엔드 응답 키: snapshots 또는 history 모두 허용 const raw = data?.snapshots ?? data?.history ?? (Array.isArray(data) ? data : []); // 날짜 → total_assets 맵 const byDate = {}; for (const item of raw) { byDate[item.date] = item.total_assets ?? 0; } // days > 0: 오늘 기준으로 days일치 전체 날짜 생성 후 없는 날은 0 채움 // days = 0(전체): 받은 데이터만 날짜순 정렬 const toLocalDate = (d) => { const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, '0'); const day = String(d.getDate()).padStart(2, '0'); return `${y}-${m}-${day}`; }; let filled; if (days > 0) { const today = new Date(); filled = Array.from({ length: days }, (_, i) => { const d = new Date(today); d.setDate(today.getDate() - (days - 1 - i)); const dateStr = toLocalDate(d); 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)); } setAssetHistory(filled); } catch { setAssetHistory([]); } finally { setAssetHistoryLoading(false); } }, []); const handleSaveSnapshot = async () => { setSnapshotSaving(true); try { await saveAssetSnapshot(totalAssets != null ? Number(totalAssets) : undefined); await loadAssetHistory(assetHistoryDays); } catch (err) { alert('스냅샷 저장 실패: ' + (err?.message ?? String(err))); } finally { setSnapshotSaving(false); } }; /* Lazy load: 탭 전환 시 해당 API만 호출 */ useEffect(() => { if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) { loadPortfolio(); loadSellHistory(); } else if (activeTab === TAB_AI && !balanceLoaded) { loadBalance(); } else if (activeTab === TAB_REPORT && !portfolioLoaded) { loadPortfolio(); } else if (activeTab === TAB_ADVISOR && !portfolioLoaded) { loadPortfolio(); } }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance, loadSellHistory]); /* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */ useEffect(() => { if (activeTab === TAB_PORTFOLIO) { loadAssetHistory(assetHistoryDays); } }, [activeTab, assetHistoryDays, loadAssetHistory]); /* AI Coach: 마운트 시 localStorage에서 API Key + 오늘 캐시 복원 */ useEffect(() => { const savedKey = localStorage.getItem('ai_coach_key') ?? ''; const savedModel = localStorage.getItem('ai_coach_model') ?? 'claude-haiku-4-5-20251001'; setAiApiKey(savedKey); setAiModel(savedModel); const today = new Date().toISOString().slice(0, 10); const cached = localStorage.getItem(`ai_coach_${today}`); if (cached) { try { setAiResult({ ...JSON.parse(cached), cached: true }); } catch { /* ignore */ } } }, []); /* 리포트 탭 진입 시 시장 컨텍스트(VIX, F&G, 국채, WTI) 한 번 로드 */ useEffect(() => { if (activeTab !== TAB_REPORT || marketCtx !== null) return; Promise.allSettled([getFearAndGreed(), getVix(), getTreasury10Y(), getWTI()]) .then(([fg, vix, t, w]) => { const fgRaw = fg.status === 'fulfilled' ? fg.value : null; const fgScore = fgRaw?.fear_and_greed?.score ?? fgRaw?.score; setMarketCtx({ fg: fgScore != null ? Math.round(Number(fgScore)) : null, vix: vix.status === 'fulfilled' ? (vix.value?.value ?? null) : null, treasury: t.status === 'fulfilled' ? (t.value?.value ?? null) : null, wti: w.status === 'fulfilled' ? (w.value?.value ?? null) : null, }); }); }, [activeTab, marketCtx]); /* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */ useEffect(() => { if (activeTab !== TAB_PORTFOLIO) return; const timer = window.setInterval(loadPortfolio, 180000); return () => window.clearInterval(timer); }, [activeTab, loadPortfolio]); /* ── portfolio actions ───────────────────────────────────────── */ const handleAddSubmit = async (e) => { e.preventDefault(); setAddLoading(true); setAddError(''); try { await addPortfolio({ broker: addForm.broker.trim(), ticker: addForm.ticker.trim(), name: addForm.name.trim(), quantity: Number(addForm.quantity), avg_price: Number(addForm.avg_price), }); setAddForm({ ...emptyPortfolioForm }); setAddFormOpen(false); await loadPortfolio(); } catch (err) { setAddError(err?.message ?? String(err)); } finally { setAddLoading(false); } }; const handleEditStart = (item) => { setEditingId(item.id); setEditForm({ quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name, }); editOrigRef.current = { quantity: item.quantity, avg_price: item.avg_price, broker: item.broker, name: item.name, }; }; const handleEditSave = async (id) => { setEditLoading(true); try { const orig = editOrigRef.current ?? {}; const diff = {}; for (const key of Object.keys(editForm)) { if (editForm[key] !== orig[key]) { diff[key] = editForm[key]; } } if (Object.keys(diff).length === 0) { setEditingId(null); return; } await updatePortfolio(id, diff); setEditingId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); await loadPortfolio(); } else { alert('수정 실패: ' + msg); } } finally { setEditLoading(false); } }; const handleDelete = async (id) => { try { await deletePortfolio(id); setDeleteConfirmId(null); await loadPortfolio(); } catch (err) { const msg = err?.message ?? String(err); if (msg.includes('404') || msg.includes('not found')) { alert('해당 종목을 찾을 수 없습니다. 이미 삭제되었을 수 있습니다.'); setDeleteConfirmId(null); await loadPortfolio(); } else { alert('삭제 실패: ' + msg); } } }; /* ── cash (예수금) actions ──────────────────────────────────── */ const handleCashSave = async (e) => { e.preventDefault(); if (!cashForm.broker.trim() || cashForm.cash === '') return; setCashSaving(true); setCashError(''); try { await upsertCash(cashForm.broker.trim(), Number(cashForm.cash)); setCashForm({ broker: '', cash: '' }); await loadPortfolio(); } catch (err) { setCashError(err?.message ?? String(err)); } finally { setCashSaving(false); } }; const handleCashDelete = async (broker) => { try { await deleteCash(broker); await loadPortfolio(); } catch (err) { alert('예수금 삭제 실패: ' + (err?.message ?? String(err))); } }; const handleCashInlineEdit = (item) => { setCashEditingBroker(item.broker); setCashEditingValue(String(item.cash ?? '')); }; const handleCashInlineSave = async (broker) => { if (cashEditingValue === '') return; setCashEditSaving(true); try { await upsertCash(broker, Number(cashEditingValue)); setCashEditingBroker(null); setCashEditingValue(''); await loadPortfolio(); } catch (err) { alert('예수금 수정 실패: ' + (err?.message ?? String(err))); } finally { setCashEditSaving(false); } }; const handleCashInlineCancel = () => { setCashEditingBroker(null); setCashEditingValue(''); }; /* ── sell (현재가 매도) ───────────────────────────────────────── */ const handleSell = async (item) => { const sellPrice = item.current_price ?? item.avg_price; const avgPrice = item.avg_price ?? 0; const qty = item.quantity ?? 0; const saleAmount = sellPrice * qty; const buyAmount = avgPrice * qty; const realizedProfit = saleAmount - buyAmount; const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0; const broker = item.broker ?? ''; setSellLoading(true); try { // 기존 예수금에 판매금액 합산 const existing = cashList.find((c) => c.broker === broker); const newCash = (existing?.cash ?? 0) + saleAmount; await upsertCash(broker, newCash); await deletePortfolio(item.id); // 실현손익 기록 저장 (백엔드) const record = { broker, ticker: item.ticker ?? '', name: item.name ?? item.ticker ?? 'N/A', quantity: qty, avg_price: avgPrice, sell_price: sellPrice, buy_amount: buyAmount, sell_amount: saleAmount, realized_profit: realizedProfit, realized_rate: realizedRate, sold_at: new Date().toISOString(), }; try { const saved = await addSellHistory(record); setSellHistory((prev) => [saved ?? record, ...prev]); } catch { /* 백엔드 미구현 시 낙관적 UI 유지 */ setSellHistory((prev) => [{ ...record, id: Date.now() }, ...prev]); } setSellConfirmId(null); await loadPortfolio(); } catch (err) { alert('매도 처리 실패: ' + (err?.message ?? String(err))); } finally { setSellLoading(false); } }; const handleDeleteSellRecord = async (id) => { setSellHistory((prev) => prev.filter((r) => r.id !== id)); try { await deleteSellHistory(id); } catch { /* 실패 시 목록 재로드로 복구 */ loadSellHistory(); } }; /* 수동 추가 폼 열기 */ const handleSellFormOpen = () => { setSellEditId(null); setSellForm(emptySellForm()); setSellFormError(''); setSellFormOpen(true); }; /* 수정 폼 열기 */ const handleSellEditStart = (record) => { setSellEditId(record.id); setSellForm({ broker: record.broker ?? '', ticker: record.ticker ?? '', name: record.name ?? '', 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(''); setSellFormOpen(true); }; /* 폼 닫기 */ const handleSellFormClose = () => { setSellFormOpen(false); setSellEditId(null); setSellFormError(''); }; /* 폼 제출 (추가 or 수정) */ const handleSellFormSubmit = async (e) => { e.preventDefault(); setSellFormSaving(true); setSellFormError(''); 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 - commission; const realizedRate = buyAmount > 0 ? (realizedProfit / buyAmount) * 100 : 0; const payload = { broker: sellForm.broker.trim(), ticker: sellForm.ticker.trim(), name: sellForm.name.trim(), quantity: qty, avg_price: avgPrice, sell_price: sellPrice, commission, buy_amount: buyAmount, sell_amount: sellAmount, realized_profit: realizedProfit, realized_rate: realizedRate, sold_at: sellForm.sold_at ? new Date(sellForm.sold_at).toISOString() : new Date().toISOString(), }; try { if (sellEditId != null) { const updated = await updateSellHistory(sellEditId, payload); setSellHistory((prev) => prev.map((r) => (r.id === sellEditId ? (updated ?? { ...payload, id: sellEditId }) : r)) ); } else { const saved = await addSellHistory(payload); setSellHistory((prev) => [saved ?? { ...payload, id: Date.now() }, ...prev]); } handleSellFormClose(); } catch (err) { setSellFormError(err?.message ?? String(err)); } finally { setSellFormSaving(false); } }; /* ── report sort ─────────────────────────────────────────────── */ const handleReportSort = (field) => { if (reportSortField === field) { setReportSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); } else { setReportSortField(field); setReportSortDir('desc'); } }; /* ── AI coach ────────────────────────────────────────────────── */ const handleAiCoach = async () => { if (!aiApiKey.trim() || portfolioHoldings.length === 0) return; const today = new Date().toISOString().slice(0, 10); const cacheKey = `ai_coach_${today}`; const cached = localStorage.getItem(cacheKey); if (cached) { try { setAiResult({ ...JSON.parse(cached), cached: true }); return; } catch { /* invalid cache */ } } setAiLoading(true); setAiError(''); const holdingsText = portfolioHoldings .map((item) => `- ${item.name ?? item.ticker}(${item.ticker ?? ''}): ${item.quantity}주, 매입가 ${formatNumber(item.avg_price)}원, 현재가 ${item.current_price != null ? formatNumber(item.current_price) + '원' : '미조회'}, 수익률 ${item.profit_rate != null ? formatPercent(item.profit_rate) : '미조회'}` ) .join('\n'); const marketText = marketCtx ? `\n[현재 시장 환경]\nVIX: ${marketCtx.vix != null ? `${marketCtx.vix} (${getVixLabel(marketCtx.vix)})` : '데이터 없음'}\nFear & Greed: ${marketCtx.fg != null ? `${marketCtx.fg}점 (${getFgLabel(marketCtx.fg)})` : '데이터 없음'}\n미국 10년물 국채: ${marketCtx.treasury != null ? `${marketCtx.treasury}%` : '데이터 없음'}\nWTI 유가: ${marketCtx.wti != null ? `$${marketCtx.wti}` : '데이터 없음'}` : ''; const prompt = `당신은 한국 주식 전문 투자 코치입니다. 아래 포트폴리오와 시장 환경을 종합 분석하여 JSON으로만 답하세요. 분석 일자: ${today} 총 매입금액: ${formatNumber(portfolioSummary.total_buy)}원 총 평가금액: ${formatNumber(portfolioSummary.total_eval)}원 총 손익: ${formatNumber(portfolioSummary.total_profit)}원 (수익률: ${formatPercent(portfolioSummary.total_profit_rate)}) 예수금 합계: ${totalCash != null ? formatNumber(totalCash) + '원' : '미입력'} 총 자산: ${totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'} 보유 종목 수: ${portfolioHoldings.length}개 보유 종목: ${holdingsText}${marketText} 반드시 아래 JSON 형식으로만 응답하세요 (코드블록 없이, 모든 텍스트는 한국어로): { "score": 85, "grade": "A", "summary": "30자 이내 한줄 평가", "evaluation": "200자 이내 상세 평가", "advice": [ { "title": "조언 제목", "body": "50자 이내 조언 내용" }, { "title": "조언 제목", "body": "50자 이내 조언 내용" }, { "title": "조언 제목", "body": "50자 이내 조언 내용" } ] }`; try { const res = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': aiApiKey.trim(), 'anthropic-version': '2023-06-01', 'anthropic-dangerous-direct-browser-access': 'true', }, body: JSON.stringify({ model: aiModel, max_tokens: 1024, messages: [{ role: 'user', content: prompt }], }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Claude API 오류 (${res.status}): ${text.slice(0, 200)}`); } const data = await res.json(); const text = data.content?.[0]?.text ?? ''; const jsonMatch = text.match(/\{[\s\S]*\}/); if (!jsonMatch) throw new Error('AI 응답에서 JSON을 파싱할 수 없습니다.'); const result = JSON.parse(jsonMatch[0]); const final = { ...result, generated_at: new Date().toISOString(), cached: false }; localStorage.setItem(cacheKey, JSON.stringify(final)); setAiResult(final); } catch (err) { setAiError(err?.message ?? String(err)); } finally { setAiLoading(false); } }; /* ── manual order ────────────────────────────────────────────── */ const submitManualOrder = async (event) => { event.preventDefault(); setManualLoading(true); setManualError(''); setManualResult(null); try { const payload = { ticker: manualForm.code.trim(), action: manualForm.type === 'sell' ? 'SELL' : 'BUY', quantity: Number(manualForm.qty), price: Number(manualForm.price), }; const result = await createTradeOrder(payload); setManualResult(result ?? { ok: true }); if (result?.kis_result !== undefined) { const message = typeof result.kis_result === 'string' ? result.kis_result : JSON.stringify(result.kis_result, null, 2); setKisModal(message); } await loadBalance(); } catch (err) { setManualError(err?.message ?? String(err)); } finally { setManualLoading(false); } }; /* ── derived: AI balance ──────────────────────────────────────── */ const holdings = useMemo(() => { if (!balance) return []; if (Array.isArray(balance.holdings)) return balance.holdings; if (Array.isArray(balance.positions)) return balance.positions; if (Array.isArray(balance.items)) return balance.items; return []; }, [balance]); const summary = balance?.summary ?? {}; const totalEval = summary.total_eval ?? balance?.total_eval ?? balance?.total_value; const deposit = summary.deposit ?? balance?.deposit ?? balance?.available_cash; /* ── derived: Portfolio ───────────────────────────────────────── */ const portfolioHoldings = portfolio?.holdings ?? []; const portfolioSummary = portfolio?.summary ?? {}; const cashList = portfolio?.cash ?? []; const totalCash = portfolioSummary.total_cash ?? null; const totalAssets = portfolioSummary.total_assets ?? null; const brokerGroups = useMemo(() => { const map = {}; for (const item of portfolioHoldings) { const broker = item.broker || '기타'; if (!map[broker]) map[broker] = []; map[broker].push(item); } return Object.entries(map).sort(([a], [b]) => a.localeCompare(b)); }, [portfolioHoldings]); const getBrokerSummary = (items) => { let totalBuy = 0; let totalEvalAmt = 0; let hasNullPrice = false; for (const item of items) { totalBuy += (item.avg_price ?? 0) * (item.quantity ?? 0); if (item.eval_amount != null) { totalEvalAmt += item.eval_amount; } else { hasNullPrice = true; } } const totalProfit = totalEvalAmt - totalBuy; const totalProfitRate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0; return { totalBuy, totalEval: totalEvalAmt, totalProfit, totalProfitRate, hasNullPrice }; }; const brokerColors = useMemo(() => { const palette = [ { border: 'rgba(129,140,248,0.5)', bg: 'rgba(129,140,248,0.06)' }, { border: 'rgba(251,191,36,0.5)', bg: 'rgba(251,191,36,0.06)' }, { border: 'rgba(52,211,153,0.5)', bg: 'rgba(52,211,153,0.06)' }, { border: 'rgba(244,114,182,0.5)', bg: 'rgba(244,114,182,0.06)' }, { border: 'rgba(251,146,60,0.5)', bg: 'rgba(251,146,60,0.06)' }, { border: 'rgba(139,92,246,0.5)', bg: 'rgba(139,92,246,0.06)' }, ]; const map = {}; brokerGroups.forEach(([broker], i) => { map[broker] = palette[i % palette.length]; }); return map; }, [brokerGroups]); /* ── derived: Report ──────────────────────────────────────────── */ const brokerPieData = useMemo(() => brokerGroups .map(([broker, items]) => ({ name: broker, value: getBrokerSummary(items).totalEval })) .filter((d) => d.value > 0), [brokerGroups] ); const profitBarData = useMemo(() => portfolioHoldings .filter((item) => item.profit_rate != null) .map((item) => ({ name: item.ticker ?? (item.name ?? 'N/A').slice(0, 5), fullName: item.name ?? item.ticker ?? 'N/A', rate: toNumeric(item.profit_rate) ?? 0, })) .sort((a, b) => b.rate - a.rate), [portfolioHoldings] ); const maxAbsRate = useMemo(() => Math.max(1, ...portfolioHoldings.map((h) => Math.abs(toNumeric(h.profit_rate) ?? 0))), [portfolioHoldings] ); /* ── derived: 리스크 분산 분석 ────────────────────────────────── */ const brokerConcentration = useMemo(() => { const totalEval = toNumeric(portfolioSummary.total_eval); if (!totalEval || totalEval === 0) return []; return brokerGroups .map(([broker, items]) => { const { totalEval: brokerEval } = getBrokerSummary(items); const ratio = Math.round((brokerEval / totalEval) * 1000) / 10; return { broker, eval: brokerEval, ratio }; }) .sort((a, b) => b.ratio - a.ratio); }, [brokerGroups, portfolioSummary.total_eval]); // eslint-disable-line react-hooks/exhaustive-deps const stockConcentration = useMemo(() => { const totalEval = toNumeric(portfolioSummary.total_eval); if (!totalEval || totalEval === 0) return []; return portfolioHoldings .map((item) => { const evalAmt = item.eval_amount != null ? toNumeric(item.eval_amount) : (item.current_price != null && item.quantity != null) ? toNumeric(item.current_price) * toNumeric(item.quantity) : null; if (!evalAmt) return null; return { name: item.name ?? item.ticker ?? 'N/A', ticker: item.ticker ?? '', eval: evalAmt, ratio: Math.round((evalAmt / totalEval) * 1000) / 10, }; }) .filter(Boolean) .sort((a, b) => b.ratio - a.ratio) .slice(0, 5); }, [portfolioHoldings, portfolioSummary.total_eval]); const sortedHoldings = useMemo(() => { const getVal = (item) => { switch (reportSortField) { case 'profit_rate': return toNumeric(item.profit_rate) ?? -Infinity; case 'profit_amount': return toNumeric(item.profit_amount) ?? -Infinity; case 'eval_amount': { const ea = toNumeric(item.eval_amount); if (ea != null) return ea; const cp = toNumeric(item.current_price); const qty = toNumeric(item.quantity); return cp != null && qty != null ? cp * qty : -Infinity; } default: return 0; } }; return [...portfolioHoldings].sort((a, b) => { if (reportSortField === 'name') return reportSortDir === 'asc' ? (a.name ?? '').localeCompare(b.name ?? '') : (b.name ?? '').localeCompare(a.name ?? ''); if (reportSortField === 'broker') return reportSortDir === 'asc' ? (a.broker ?? '').localeCompare(b.broker ?? '') : (b.broker ?? '').localeCompare(a.broker ?? ''); const av = getVal(a); const bv = getVal(b); return reportSortDir === 'asc' ? av - bv : bv - av; }); }, [portfolioHoldings, reportSortField, reportSortDir]); /* ── derived: 실현손익 필터 ────────────────────────────────────── */ const sellHistoryBrokers = useMemo(() => { const set = new Set(sellHistory.map((r) => r.broker).filter(Boolean)); return ['ALL', ...Array.from(set).sort()]; }, [sellHistory]); const filteredSellHistory = useMemo(() => { const now = new Date(); const periodMs = { '1M': 30 * 86400000, '3M': 90 * 86400000, '6M': 180 * 86400000, '1Y': 365 * 86400000, 'ALL': Infinity, }[sellHistoryPeriod] ?? Infinity; return sellHistory.filter((r) => { if (sellHistoryBroker !== 'ALL' && r.broker !== sellHistoryBroker) return false; const diff = now - new Date(r.sold_at); return diff <= periodMs; }); }, [sellHistory, sellHistoryBroker, sellHistoryPeriod]); const sellHistorySummary = useMemo(() => { 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, totalCommission, rate, count: filteredSellHistory.length }; }, [filteredSellHistory]); /* ── render ───────────────────────────────────────────────────── */ return (
{/* ── Header ──────────────────────────────────────────── */}

거래 데스크

거래 데스크

실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.

주식 랩으로 돌아가기

{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}

{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? ( /* Portfolio summary */
총 매입 {formatNumber(portfolioSummary.total_buy)}
총 평가 {formatNumber(portfolioSummary.total_eval)}
총 손익 {formatNumber(portfolioSummary.total_profit)} {portfolioSummary.total_profit_rate != null && ( ({formatPercent(portfolioSummary.total_profit_rate)}) )}
보유 종목 {portfolioHoldings.length}
{totalCash != null && (
예수금 합계 {formatNumber(totalCash)}원
)} {totalAssets != null && (
총 자산 {formatNumber(totalAssets)}원
)}
) : ( /* AI balance summary */
총 평가금액 {formatNumber(totalEval)}
예수금 {formatNumber(deposit)}
보유 종목 {holdings.length}
)} {activeTab === TAB_AI && summary.note ? (

{summary.note}

) : null}
{/* ── Main Tabs ───────────────────────────────────────── */}
{/* ════════════════════════════════════════════════════════ TAB 1: 쟁승토리 계좌 ════════════════════════════════════════════════════════ */} {activeTab === TAB_PORTFOLIO && ( <> {portfolioError ? (

{portfolioError}

) : null} {/* 포트폴리오 관리 헤더 + 추가 폼 */}

포트폴리오

수동 입력 종목 관리

증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)

{portfolioLoading ? ( ) : null}
{/* Add form */} {addFormOpen && (
{addError &&

{addError}

}
)} {/* Portfolio total summary */} {portfolioHoldings.length > 0 && (
{[ { label: '총 매입', value: portfolioSummary.total_buy }, { label: '총 평가', value: portfolioSummary.total_eval }, { label: '총 손익', value: portfolioSummary.total_profit, isProfit: true }, { label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true }, ].map((s) => (
{s.label} {s.isRate ? formatPercent(s.value) : formatNumber(s.value)}
))} {totalCash != null && (
예수금 합계 {formatNumber(totalCash)}원
)} {totalAssets != null && (
총 자산 {formatNumber(totalAssets)}원
)}
)} {/* 자산 추이 차트 */}

총 자산 추이

{[ { label: '7일', value: 7 }, { label: '30일', value: 30 }, { label: '90일', value: 90 }, { label: '전체', value: 0 }, ].map(({ label, value }) => ( ))}
{assetHistoryLoading ? (
) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? ( v?.slice(5)} tickLine={false} axisLine={false} interval="preserveStartEnd" /> [`${new Intl.NumberFormat('ko-KR').format(v)}원`, '총 자산']} /> ) : (
저장된 자산 추이 데이터가 없습니다. 📸 스냅샷 버튼으로 오늘 자산을 기록하세요.
)}
{/* 예수금 패널 */}

예수금 관리

증권사별 예수금

증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.

{cashList.length > 0 && (
{cashList.map((item) => { const isEditing = cashEditingBroker === item.broker; return (
{item.broker} {isEditing ? ( setCashEditingValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') handleCashInlineSave(item.broker); if (e.key === 'Escape') handleCashInlineCancel(); }} autoFocus /> ) : ( {formatNumber(item.cash)}원 )} {item.updated_at ? new Date(item.updated_at).toLocaleDateString('ko-KR') : ''} {isEditing ? ( <> ) : ( <> )}
); })}
)} {cashList.length === 0 && (

등록된 예수금이 없습니다.

)}
{cashError &&

{cashError}

}
{/* Broker cards stacked */} {brokerGroups.map(([broker, items]) => { const bSummary = getBrokerSummary(items); const color = brokerColors[broker]; return (

{broker}

{broker} 보유 현황

{items.length}종목 · 평가{' '} {formatNumber(bSummary.totalEval)} · 손익{' '} {formatNumber(bSummary.totalProfit)} ( {formatPercent(bSummary.totalProfitRate)}) {(() => { const bc = cashList.find( (c) => c.broker === broker ); return bc ? ( 예수금 {formatNumber(bc.cash)}원 ) : null; })()}

{items.map((item) => { const profitAmt = item.profit_amount; const profitRate = item.profit_rate; const profitAmtN = toNumeric(profitAmt); const profitRateN = toNumeric(profitRate); const isEditing = editingId === item.id; const isDeleting = deleteConfirmId === item.id; const isSelling = sellConfirmId === item.id; const sellPrice = item.current_price ?? item.avg_price; const saleAmount = sellPrice != null ? sellPrice * (item.quantity ?? 0) : null; return (
{isEditing ? (
) : ( <>

{item.name ?? item.ticker ?? 'N/A'}

{item.ticker ?? ''}
수량 {formatNumber(item.quantity)}
매입가 {formatNumber(item.avg_price)}
현재가 {item.current_price != null ? formatNumber(item.current_price) : '조회 실패'}
평가금액 {item.current_price != null && item.quantity != null ? formatNumber(item.current_price * item.quantity) : '-'}
수익률 {profitRate != null ? formatPercent(profitRate) : '-'}
평가손익 {profitAmt != null ? formatNumber(profitAmt) : '-'}
{!isSelling && !isDeleting && ( )} {isSelling ? (
{item.current_price == null && ( 현재가 미조회 — 매입가 기준 )} {saleAmount != null ? `${formatNumber(saleAmount)}원 매도 후 예수금 반영` : '매도 처리'}
) : isDeleting ? ( <> ) : ( <> )}
)}
); })}
); })} {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (

등록된 종목이 없습니다. 상단의 + 종목 추가 버튼으로 보유 종목을 등록하세요.

)} {/* sell history → 드로어로 이동됨 */} )} {/* ════════════════════════════════════════════════════════ TAB 2: AI 투자 (모의투자) ════════════════════════════════════════════════════════ */} {activeTab === TAB_AI && ( <> {balanceError ?

{balanceError}

: null} {/* AI Balance section */}

AI 모의투자

보유 현황

AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.

{balanceLoading ? ( 조회 중 ) : null}
{[ { label: '총 평가', value: totalEval }, { label: '예수금', value: deposit }, ].map((item) => (
{item.label} {formatNumber(item.value)}
))}
{holdings.length ? (
{holdings.map((item, idx) => { const profitLoss = getProfitLoss(item); const profitLossNumeric = toNumeric(profitLoss); const profitClass = profitColorClass(profitLossNumeric); const profitRate = getProfitRate(item); const profitRateNumeric = toNumeric(profitRate); const profitRateClass = profitColorClass(profitRateNumeric); return (

{item.name ?? item.code ?? 'N/A'}

{item.code ?? ''}
수량 {formatNumber(getQty(item))}
매입가 {formatNumber(getBuyPrice(item))}
현재가 {formatNumber(getCurrentPrice(item))}
평가금액 {getCurrentPrice(item) != null && getQty(item) != null ? formatNumber(toNumeric(getCurrentPrice(item)) * toNumeric(getQty(item))) : '-'}
수익률 {formatPercent(profitRate)}
평가손익 {formatNumber(profitLoss)}
); })}
) : (

보유 종목이 없습니다.

)}
{/* Manual order section */}

수동 주문

직접 매수/매도

종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.

{manualError ? (

{manualError}

) : null} {manualResult ? (

요청 결과

                                        {typeof manualResult === 'string'
                                            ? manualResult
                                            : JSON.stringify(manualResult, null, 2)}
                                    
) : null}
)} {/* ════════════════════════════════════════════════════════ TAB 4: AI 어드바이저 — 프롬프트 생성/복사 ════════════════════════════════════════════════════════ */} {activeTab === TAB_ADVISOR && (
{/* 헤더 */}
AI 어드바이저

포트폴리오 분석 프롬프트

보유 종목 정보를 담은 전문가용 프롬프트를 생성합니다. 복사 후 Gemini, ChatGPT 등에 붙여넣어 분석을 받아보세요.

Gemini 열기 ↗ ChatGPT 열기 ↗
{portfolioLoading && (
)} {!portfolioLoading && portfolioHoldings.length === 0 && (
📋

포트폴리오 탭에서 보유 종목을 먼저 등록해주세요.

)} {!portfolioLoading && portfolioHoldings.length > 0 && (
{/* 복사 버튼 */}
종목 {portfolioHoldings.length}개 · 총 자산 {totalAssets != null ? formatNumber(totalAssets) + '원' : '미집계'}
{/* 프롬프트 미리보기 */}
{buildAdvisorPrompt()}

※ 이 프롬프트를 AI에 붙여넣으면 전문가 관점의 매매 조언을 받을 수 있습니다. 투자 결정은 최종적으로 본인의 판단과 책임 하에 이루어져야 합니다.

)}
)} {/* ════════════════════════════════════════════════════════ TAB 3: 리포트 + AI 코치 ════════════════════════════════════════════════════════ */} {activeTab === TAB_REPORT && ( <> {portfolioLoading && (
)} {portfolioError &&

{portfolioError}

} {/* ── 자산 배분 + 수익률 차트 ────────────────────── */} {portfolioHoldings.length > 0 && (

포트폴리오 분석

자산 배분 현황

증권사별 자산 배분

{brokerPieData.map((_, i) => ( ))} [formatNumber(v) + '원', '평가금액']} contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }} /> {v}} />

종목별 수익률 (%)

`${v}%`} /> [`${v.toFixed(2)}%`, props.payload.fullName]} contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8, fontSize: 12 }} /> {profitBarData.map((entry, i) => ( = 0 ? '#34d399' : '#f87171'} /> ))}
)} {/* ── 리스크 분산 분석 ─────────────────────────────── */} {portfolioHoldings.length > 0 && portfolioSummary.total_eval != null && (

리스크 관리

분산 분석

증권사·종목 집중도를 확인합니다. 단일 비중 40% 초과 시 주의.

{/* 증권사별 집중도 */}

증권사별 집중도

{brokerConcentration.length === 0 ? (

평가금액 데이터 없음

) : ( <> {brokerConcentration.some((b) => b.ratio > 40) && (
⚠️ 단일 증권사 집중도가 40%를 초과합니다
)} {brokerConcentration.map(({ broker, eval: evalAmt, ratio }) => { const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok'; return (
{broker} {ratio.toFixed(1)}%
{formatNumber(evalAmt)}원
); })} )}
{/* 종목별 집중도 */}

상위 5 종목 집중도

{stockConcentration.length === 0 ? (

현재가 데이터 없음

) : ( <> {stockConcentration.some((s) => s.ratio > 40) && (
⚠️ 단일 종목 집중도가 40%를 초과합니다
)} {stockConcentration.map(({ name, ticker, eval: evalAmt, ratio }) => { const level = ratio >= 60 ? 'is-danger' : ratio >= 40 ? 'is-warn' : 'is-ok'; return (
{name} {ratio.toFixed(1)}%
{ticker && {ticker}} {formatNumber(evalAmt)}원
); })} )}
)} {/* ── 수익률 랭킹 테이블 ─────────────────────────── */} {portfolioHoldings.length > 0 && (

수익률 랭킹

종목별 상세 현황

헤더 클릭으로 정렬 · 비중은 총 평가금액 대비

{[ { key: 'name', label: '종목명' }, { key: 'broker', label: '증권사' }, { key: 'profit_rate', label: '수익률' }, { key: 'profit_amount', label: '평가손익' }, { key: 'eval_amount', label: '평가금액' }, ].map(({ key, label }) => ( ))} {sortedHoldings.map((item) => { const rateN = toNumeric(item.profit_rate); const pnlN = toNumeric(item.profit_amount); const evalAmt = item.eval_amount != null ? item.eval_amount : item.current_price != null ? item.current_price * item.quantity : null; const totalEval = toNumeric(portfolioSummary.total_eval); const weight = evalAmt != null && totalEval ? Math.round((evalAmt / totalEval) * 1000) / 10 : null; return ( ); })}
handleReportSort(key)}> {label}{' '} {reportSortField === key ? reportSortDir === 'asc' ? '↑' : '↓' : '↕'} 비중

{item.name ?? item.ticker ?? 'N/A'}

{item.ticker ?? ''}
{item.broker ?? '-'}
{item.profit_rate != null ? formatPercent(item.profit_rate) : '-'} {rateN != null && (
= 0 ? 'is-up' : 'is-down'}`} style={{ width: `${maxAbsRate > 0 ? Math.abs(rateN) / maxAbsRate * 100 : 0}%` }} />
)}
{item.profit_amount != null ? formatNumber(item.profit_amount) : '-'} {evalAmt != null ? formatNumber(evalAmt) : '-'} {weight != null ? `${weight.toFixed(1)}%` : '-'}
)} {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (

등록된 종목이 없습니다. 쟁승토리 계좌 탭에서 종목을 먼저 등록하세요.

)} {/* ── AI 투자 코치 ───────────────────────────────── */}

AI 투자 코치

오늘의 투자 평가

포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.

{/* 시장 컨텍스트 미니 패널 */} {marketCtx && (
시장 환경
{marketCtx.vix != null && ( VIX {marketCtx.vix} {getVixLabel(marketCtx.vix)} )} {marketCtx.fg != null && ( F&G {marketCtx.fg} {getFgLabel(marketCtx.fg)} )} {marketCtx.treasury != null && ( 10년물 {marketCtx.treasury}% )} {marketCtx.wti != null && ( WTI ${marketCtx.wti} )}
)} {/* API Key 설정 */}
{portfolioHoldings.length === 0 && ( 종목 등록 후 이용 가능합니다. )} {aiResult?.generated_at && ( {aiResult.cached ? '오늘 캐시 결과 · ' : ''} {new Date(aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성 )}
{aiError &&

{aiError}

} {aiResult && !aiLoading && (
{aiResult.grade ?? '?'}
{aiResult.score ?? 0} / 100

{aiResult.summary}

{aiResult.evaluation}

{aiResult.advice?.length > 0 && (
{aiResult.advice.map((a, i) => (

{a.title}

{a.body}

))}
)}
)}
)} {/* KIS modal */} {kisModal ? (
setKisModal('')} />

주문 결과

{kisModal}
) : null} {/* ── 실현손익 floating 토글 버튼 (마우스 추적) ────────── */} {!sellDrawerOpen && ( )} {/* ════════════════════════════════════════════════════════ 실현손익 드로어 ════════════════════════════════════════════════════════ */} {sellDrawerOpen && (
{ setSellDrawerOpen(false); handleSellFormClose(); }} /> )}
); }; export default StockTrade;