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 (
거래 데스크
실제 계좌와 AI 모의투자를 한 곳에서 관리하세요.
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? ( /* Portfolio summary */{summary.note}
) : null}{portfolioError}
) : null} {/* 포트폴리오 관리 헤더 + 추가 폼 */}포트폴리오
증권사별 보유 종목을 수동 등록하면 현재가를 자동 조회합니다. (3분 캐시)
예수금 관리
증권사별 예수금을 입력하면 총 자산에 자동 반영됩니다.
등록된 예수금이 없습니다.
)}{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; })()}
{item.name ?? item.ticker ?? 'N/A'}
{item.ticker ?? ''}등록된 종목이 없습니다. 상단의 + 종목 추가 버튼으로 보유 종목을 등록하세요.
{balanceError}
: null} {/* AI Balance section */}AI 모의투자
AI가 운용 중인 모의투자 계좌의 잔고와 보유 종목을 확인합니다.
{item.name ?? item.code ?? 'N/A'}
{item.code ?? ''}보유 종목이 없습니다.
)}수동 주문
종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
{portfolioError}
} {/* ── 자산 배분 + 수익률 차트 ────────────────────── */} {portfolioHoldings.length > 0 && (포트폴리오 분석
증권사별 자산 배분
종목별 수익률 (%)
수익률 랭킹
헤더 클릭으로 정렬
| 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) : '-'} |
등록된 종목이 없습니다. 쟁승토리 계좌 탭에서 종목을 먼저 등록하세요.
AI 투자 코치
포트폴리오를 AI가 분석하여 성취도 등급과 내일을 위한 투자 조언을 드립니다.
{aiError}
} {aiResult && !aiLoading && ({aiResult.summary}
{aiResult.evaluation}
{aiResult.advice?.length > 0 && ({a.title}
{a.body}
{kisModal}