import React, { useEffect, useMemo, useRef, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { createTradeOrder, getTradeBalance, getPortfolio, addPortfolio, updatePortfolio, deletePortfolio, upsertCash, deleteCash, } from '../../api'; import Loading from '../../components/Loading'; import './Stock.css'; import { PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip as ChartTooltip, Legend, ResponsiveContainer, } 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 ''; }; /* ── empty portfolio form ────────────────────────────────────────── */ const emptyPortfolioForm = { broker: '', ticker: '', name: '', quantity: '', avg_price: '', }; /* ── TAB IDs ─────────────────────────────────────────────────────── */ const TAB_PORTFOLIO = 'portfolio'; const TAB_AI = 'ai'; const TAB_REPORT = 'report'; /* ── 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); /* Cash (예수금) form */ const [cashForm, setCashForm] = useState({ broker: '', cash: '' }); const [cashSaving, setCashSaving] = useState(false); const [cashError, setCashError] = useState(''); /* ────────────────────────────────────────────────────────────── */ /* 리포트 탭 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(''); /* ────────────────────────────────────────────────────────────── */ /* 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 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); } }, []); /* Lazy load: 탭 전환 시 해당 API만 호출 */ useEffect(() => { if (activeTab === TAB_PORTFOLIO && !portfolioLoaded) { loadPortfolio(); } else if (activeTab === TAB_AI && !balanceLoaded) { loadBalance(); } else if (activeTab === TAB_REPORT && !portfolioLoaded) { loadPortfolio(); } }, [activeTab, portfolioLoaded, balanceLoaded, loadPortfolio, loadBalance]); /* 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 */ } } }, []); /* 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))); } }; /* ── 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 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} 반드시 아래 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] ); 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]); /* ── 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)}원
)}
)}
{/* 예수금 패널 */}

예수금 관리

증권사별 예수금

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

{cashList.length > 0 && (
{cashList.map((item) => (
{item.broker} {formatNumber(item.cash)}원 {item.updated_at ? new Date(item.updated_at).toLocaleDateString('ko-KR') : ''}
))}
)} {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; 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) : '-'}
{isDeleting ? ( <> ) : ( )}
)}
); })}
); })} {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (

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

)} )} {/* ════════════════════════════════════════════════════════ 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 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 && (

수익률 랭킹

종목별 상세 현황

헤더 클릭으로 정렬

{[ { 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; 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) : '-'}
)} {portfolioLoaded && portfolioHoldings.length === 0 && !portfolioError && (

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

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

AI 투자 코치

오늘의 투자 평가

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

{/* 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}
); }; export default StockTrade;