diff --git a/src/pages/stock/StockTrade.jsx b/src/pages/stock/StockTrade.jsx index fc6eeed..da805d3 100644 --- a/src/pages/stock/StockTrade.jsx +++ b/src/pages/stock/StockTrade.jsx @@ -1,18 +1,9 @@ import React, { useEffect, useMemo } from 'react'; import { Link } from 'react-router-dom'; -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'; import { formatNumber, formatPercent, - getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss, - toNumeric, CHART_COLORS, profitColorClass, - getVixLabel, getFgLabel, + toNumeric, profitColorClass, TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR, } from './stockUtils'; @@ -26,14 +17,19 @@ import useAiBalance from './hooks/useAiBalance'; import useReportData from './hooks/useReportData'; import useAdvisor from './hooks/useAdvisor'; +/* ── tab components ─────────────────────────────────────────────── */ +import PortfolioTab from './components/PortfolioTab'; +import AiTradeTab from './components/AiTradeTab'; +import ReportTab from './components/ReportTab'; +import AdvisorTab from './components/AdvisorTab'; +import SellHistoryDrawer from './components/SellHistoryDrawer'; + /* ── component ───────────────────────────────────────────────────── */ const StockTrade = () => { - /* Active tab */ const [activeTab, setActiveTab] = React.useState(TAB_REPORT); /* ── hooks ────────────────────────────────────────────────────── */ - const pf = usePortfolio(); const sell = useSellHistory(); const asset = useAssetHistory(); @@ -62,7 +58,6 @@ const StockTrade = () => { }); /* ── sell history filter derived ─────────────────────────────── */ - const sellHistoryBrokers = useMemo(() => { const set = new Set(sell.sellHistory.map((r) => r.broker).filter(Boolean)); return ['ALL', ...Array.from(set).sort()]; @@ -71,16 +66,12 @@ const StockTrade = () => { const filteredSellHistory = useMemo(() => { const now = new Date(); const periodMs = { - '1M': 30 * 86400000, - '3M': 90 * 86400000, - '6M': 180 * 86400000, - '1Y': 365 * 86400000, - 'ALL': Infinity, + '1M': 30 * 86400000, '3M': 90 * 86400000, + '6M': 180 * 86400000, '1Y': 365 * 86400000, 'ALL': Infinity, }[sell.sellHistoryPeriod] ?? Infinity; return sell.sellHistory.filter((r) => { if (sell.sellHistoryBroker !== 'ALL' && r.broker !== sell.sellHistoryBroker) return false; - const diff = now - new Date(r.sold_at); - return diff <= periodMs; + return (now - new Date(r.sold_at)) <= periodMs; }); }, [sell.sellHistory, sell.sellHistoryBroker, sell.sellHistoryPeriod]); @@ -93,64 +84,46 @@ const StockTrade = () => { return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length }; }, [filteredSellHistory]); - /* ── lazy load: 탭 전환 시 해당 API만 호출 ──────────────────── */ - + /* ── lazy load ───────────────────────────────────────────────── */ useEffect(() => { if (activeTab === TAB_PORTFOLIO && !pf.portfolioLoaded) { pf.loadPortfolio(); sell.loadSellHistory(); } else if (activeTab === TAB_AI && !aib.balanceLoaded) { aib.loadBalance(); - } else if (activeTab === TAB_REPORT && !pf.portfolioLoaded) { - pf.loadPortfolio(); - } else if (activeTab === TAB_ADVISOR && !pf.portfolioLoaded) { + } else if ((activeTab === TAB_REPORT || activeTab === TAB_ADVISOR) && !pf.portfolioLoaded) { pf.loadPortfolio(); } }, [activeTab, pf.portfolioLoaded, aib.balanceLoaded]); // eslint-disable-line react-hooks/exhaustive-deps - /* 자산 추이: 포트폴리오 탭 첫 진입 또는 기간 변경 시 로드 */ useEffect(() => { - if (activeTab === TAB_PORTFOLIO) { - asset.loadAssetHistory(asset.assetHistoryDays); - } + if (activeTab === TAB_PORTFOLIO) asset.loadAssetHistory(asset.assetHistoryDays); }, [activeTab, asset.assetHistoryDays]); // eslint-disable-line react-hooks/exhaustive-deps - /* Auto-refresh portfolio every 3 min (포트폴리오 탭 활성 시) */ useEffect(() => { if (activeTab !== TAB_PORTFOLIO) return; const timer = window.setInterval(pf.loadPortfolio, 180000); return () => window.clearInterval(timer); }, [activeTab, pf.loadPortfolio]); - /* ── sell handler wrapper (cross-hook dependency) ────────────── */ - + /* ── cross-hook wrappers ─────────────────────────────────────── */ const handleSell = (item) => - pf.handleSell(item, { - cashList: pf.cashList, - loadSellHistoryAfter: sell.addSellRecord, - }); - - /* ── snapshot handler wrapper ────────────────────────────────── */ + pf.handleSell(item, { cashList: pf.cashList, loadSellHistoryAfter: sell.addSellRecord }); const handleSaveSnapshot = () => asset.handleSaveSnapshot(pf.totalAssets, asset.assetHistoryDays); /* ── render ───────────────────────────────────────────────────── */ - return (
- {/* ── Header ──────────────────────────────────────────── */} + {/* Header */}

거래 데스크

거래 데스크

-

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

+

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

- - 주식 랩으로 돌아가기 - + 주식 랩으로 돌아가기
@@ -159,21 +132,11 @@ const StockTrade = () => {

{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
-
- 총 매입 - {formatNumber(pf.portfolioSummary.total_buy)} -
-
- 총 평가 - {formatNumber(pf.portfolioSummary.total_eval)} -
+
총 매입{formatNumber(pf.portfolioSummary.total_buy)}
+
총 평가{formatNumber(pf.portfolioSummary.total_eval)}
총 손익 - + {formatNumber(pf.portfolioSummary.total_profit)} {pf.portfolioSummary.total_profit_rate != null && ( @@ -182,41 +145,19 @@ const StockTrade = () => { )}
-
- 보유 종목 - {pf.portfolioHoldings.length} -
+
보유 종목{pf.portfolioHoldings.length}
{pf.totalCash != null && ( -
- 예수금 합계 - - {formatNumber(pf.totalCash)}원 - -
+
예수금 합계{formatNumber(pf.totalCash)}원
)} {pf.totalAssets != null && ( -
- 총 자산 - - {formatNumber(pf.totalAssets)}원 - -
+
총 자산{formatNumber(pf.totalAssets)}원
)}
) : (
-
- 총 평가금액 - {formatNumber(aib.totalEval)} -
-
- 예수금 - {formatNumber(aib.deposit)} -
-
- 보유 종목 - {aib.holdings.length} -
+
총 평가금액{formatNumber(aib.totalEval)}
+
예수금{formatNumber(aib.deposit)}
+
보유 종목{aib.holdings.length}
)} {activeTab === TAB_AI && aib.summary.note ? ( @@ -225,1706 +166,43 @@ const StockTrade = () => {
- {/* ── Main Tabs ───────────────────────────────────────── */} + {/* Tab bar */}
- - - - + {[ + { id: TAB_PORTFOLIO, icon: '💼', label: '쟁승토리 계좌', badge: pf.portfolioHoldings.length || null }, + { id: TAB_AI, icon: '🤖', label: 'AI 투자', sub: '모의투자' }, + { id: TAB_REPORT, icon: '📊', label: '리포트', sub: '분석·AI코치' }, + { id: TAB_ADVISOR, icon: '🧠', label: 'AI 어드바이저', sub: 'Gemini Pro', className: 'stock-main-tab--advisor' }, + ].map(({ id, icon, label, sub, badge, className: cls }) => ( + + ))}
- {/* ════════════════════════════════════════════════════════ - TAB 1: 쟁승토리 계좌 - ════════════════════════════════════════════════════════ */} + {/* Tab content */} {activeTab === TAB_PORTFOLIO && ( - <> - {pf.portfolioError ? ( -

{pf.portfolioError}

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

포트폴리오

-

수동 입력 종목 관리

-

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

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

{pf.addError}

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

총 자산 추이

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

예수금 관리

-

증권사별 예수금

-

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

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

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

- )} - -
- - - - {pf.cashError &&

{pf.cashError}

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

- {broker} -

-

{broker} 보유 현황

-

- {items.length}종목 · 평가{' '} - {formatNumber(bSummary.totalEval)} · 손익{' '} - - {formatNumber(bSummary.totalProfit)} ( - {formatPercent(bSummary.totalProfitRate)}) - - {(() => { - const bc = pf.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 = pf.editingId === item.id; - const isDeleting = pf.deleteConfirmId === item.id; - const isSelling = pf.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 ? ( - <> - - - - ) : ( - <> - - - - )} -
- - )} -
- ); - })} -
-
- ); - })} - - {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && ( -
-

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

-
- )} - + )} + {activeTab === TAB_AI && } + {activeTab === TAB_REPORT && } + {activeTab === TAB_ADVISOR && } - {/* ════════════════════════════════════════════════════════ - TAB 2: AI 투자 (모의투자) - ════════════════════════════════════════════════════════ */} - {activeTab === TAB_AI && ( - <> - {aib.balanceError ?

{aib.balanceError}

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

AI 모의투자

-

보유 현황

-

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

-
-
- {aib.balanceLoading ? ( - 조회 중 - ) : null} - -
-
-
-
- {[ - { label: '총 평가', value: aib.totalEval }, - { label: '예수금', value: aib.deposit }, - ].map((item) => ( -
- {item.label} - {formatNumber(item.value)} -
- ))} -
- {aib.holdings.length ? ( -
- {aib.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 */} -
-
-
-

수동 주문

-

직접 매수/매도

-

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

-
-
-
- - - - - - {aib.manualError ? ( -

{aib.manualError}

- ) : null} - {aib.manualResult ? ( -
-

요청 결과

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

포트폴리오 분석 프롬프트

-

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

-
- -
- - {pf.portfolioLoading && ( -
- -
- )} - - {!pf.portfolioLoading && pf.portfolioHoldings.length === 0 && ( -
- 📋 -

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

-
- )} - - {!pf.portfolioLoading && pf.portfolioHoldings.length > 0 && ( -
-
- - 종목 {pf.portfolioHoldings.length}개 · 총 자산 {pf.totalAssets != null ? formatNumber(pf.totalAssets) + '원' : '미집계'} - - -
-
{advisor.buildAdvisorPrompt()}
-

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

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

{pf.portfolioError}

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

포트폴리오 분석

-

자산 배분 현황

-
-
-
-
-

증권사별 자산 배분

- - - - {report.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 }} - /> - - {report.profitBarData.map((entry, i) => ( - = 0 ? '#34d399' : '#f87171'} /> - ))} - - - -
-
-
- )} - - {/* ── 리스크 분산 분석 ─────────────────────────────── */} - {pf.portfolioHoldings.length > 0 && pf.portfolioSummary.total_eval != null && ( -
-
-
-

리스크 관리

-

분산 분석

-

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

-
-
-
-
-

증권사별 집중도

- {report.brokerConcentration.length === 0 ? ( -

평가금액 데이터 없음

- ) : ( - <> - {report.brokerConcentration.some((b) => b.ratio > 40) && ( -
- ⚠️ 단일 증권사 집중도가 40%를 초과합니다 -
- )} - {report.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 종목 집중도

- {report.stockConcentration.length === 0 ? ( -

현재가 데이터 없음

- ) : ( - <> - {report.stockConcentration.some((s) => s.ratio > 40) && ( -
- ⚠️ 단일 종목 집중도가 40%를 초과합니다 -
- )} - {report.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)}원 - -
- ); - })} - - )} -
-
-
- )} - - {/* ── 수익률 랭킹 테이블 ─────────────────────────── */} - {pf.portfolioHoldings.length > 0 && ( -
-
-
-

수익률 랭킹

-

종목별 상세 현황

-

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

-
-
-
- - - - {[ - { key: 'name', label: '종목명' }, - { key: 'broker', label: '증권사' }, - { key: 'profit_rate', label: '수익률' }, - { key: 'profit_amount', label: '평가손익' }, - { key: 'eval_amount', label: '평가금액' }, - ].map(({ key, label }) => ( - - ))} - - - - - {report.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 totalEvalVal = toNumeric(pf.portfolioSummary.total_eval); - const weight = evalAmt != null && totalEvalVal - ? Math.round((evalAmt / totalEvalVal) * 1000) / 10 - : null; - return ( - - - - - - - - - ); - })} - -
report.handleReportSort(key)}> - {label}{' '} - - {report.reportSortField === key - ? report.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: `${report.maxAbsRate > 0 ? Math.abs(rateN) / report.maxAbsRate * 100 : 0}%` }} - /> -
- )} -
-
- {item.profit_amount != null ? formatNumber(item.profit_amount) : '-'} - - {evalAmt != null ? formatNumber(evalAmt) : '-'} - - {weight != null ? `${weight.toFixed(1)}%` : '-'} -
-
-
- )} - - {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.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} - - )} -
-
- )} - - {/* 모델 선택 */} -
- -
- -
- - {pf.portfolioHoldings.length === 0 && ( - 종목 등록 후 이용 가능합니다. - )} - {ai.aiResult?.generated_at && ( - - {ai.aiResult.cached ? '오늘 캐시 결과 · ' : ''} - {new Date(ai.aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성 - - )} -
- - {ai.aiError &&

{ai.aiError}

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

{ai.aiResult.summary}

-
-

{ai.aiResult.evaluation}

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

{a.title}

-

{a.body}

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

주문 결과

- -
-
{aib.kisModal}
-
-
- ) : null} - - {/* ── 실현손익 floating 토글 버튼 ────────────────────── */} - {!sell.sellDrawerOpen && ( - - )} - - {/* ════════════════════════════════════════════════════════ - 실현손익 드로어 - ════════════════════════════════════════════════════════ */} - {sell.sellDrawerOpen && ( -
{ sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }} - /> - )} - + {/* Sell history drawer (always mounted) */} +
); }; diff --git a/src/pages/stock/components/AdvisorTab.jsx b/src/pages/stock/components/AdvisorTab.jsx new file mode 100644 index 0000000..0e9b1f6 --- /dev/null +++ b/src/pages/stock/components/AdvisorTab.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import Loading from '../../../components/Loading'; +import { formatNumber } from '../stockUtils'; + +const AdvisorTab = ({ pf, advisor }) => ( +
+
+
+ AI 어드바이저 +

포트폴리오 분석 프롬프트

+

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

+
+ +
+ + {pf.portfolioLoading && ( +
+ +
+ )} + + {!pf.portfolioLoading && pf.portfolioHoldings.length === 0 && ( +
+ 📋 +

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

+
+ )} + + {!pf.portfolioLoading && pf.portfolioHoldings.length > 0 && ( +
+
+ + 종목 {pf.portfolioHoldings.length}개 · 총 자산 {pf.totalAssets != null ? formatNumber(pf.totalAssets) + '원' : '미집계'} + + +
+
{advisor.buildAdvisorPrompt()}
+

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

+
+ )} +
+); + +export default AdvisorTab; diff --git a/src/pages/stock/components/AiTradeTab.jsx b/src/pages/stock/components/AiTradeTab.jsx new file mode 100644 index 0000000..4697468 --- /dev/null +++ b/src/pages/stock/components/AiTradeTab.jsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { + formatNumber, formatPercent, + getQty, getBuyPrice, getCurrentPrice, getProfitRate, getProfitLoss, + toNumeric, profitColorClass, +} from '../stockUtils'; + +const AiTradeTab = ({ aib }) => ( + <> + {aib.balanceError ?

{aib.balanceError}

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

AI 모의투자

+

보유 현황

+

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

+
+
+ {aib.balanceLoading ? ( + 조회 중 + ) : null} + +
+
+
+
+ {[ + { label: '총 평가', value: aib.totalEval }, + { label: '예수금', value: aib.deposit }, + ].map((item) => ( +
+ {item.label} + {formatNumber(item.value)} +
+ ))} +
+ {aib.holdings.length ? ( +
+ {aib.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 */} +
+
+
+

수동 주문

+

직접 매수/매도

+

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

+
+
+
+ + + + + + {aib.manualError ? ( +

{aib.manualError}

+ ) : null} + {aib.manualResult ? ( +
+

요청 결과

+
+                            {typeof aib.manualResult === 'string'
+                                ? aib.manualResult
+                                : JSON.stringify(aib.manualResult, null, 2)}
+                        
+
+ ) : null} +
+
+ + {/* KIS modal */} + {aib.kisModal ? ( +
+
aib.setKisModal('')} + /> +
+
+

주문 결과

+ +
+
{aib.kisModal}
+
+
+ ) : null} + +); + +export default AiTradeTab; diff --git a/src/pages/stock/components/PortfolioTab.jsx b/src/pages/stock/components/PortfolioTab.jsx new file mode 100644 index 0000000..2073c8a --- /dev/null +++ b/src/pages/stock/components/PortfolioTab.jsx @@ -0,0 +1,609 @@ +import React from 'react'; +import Loading from '../../../components/Loading'; +import { + ResponsiveContainer, AreaChart, Area, XAxis, YAxis, + Tooltip as ChartTooltip, +} from 'recharts'; +import { formatNumber, formatPercent, toNumeric, profitColorClass } from '../stockUtils'; + +const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => ( + <> + {pf.portfolioError ? ( +

{pf.portfolioError}

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

포트폴리오

+

수동 입력 종목 관리

+

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

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

{pf.addError}

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

총 자산 추이

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

예수금 관리

+

증권사별 예수금

+

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

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

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

+ )} + +
+ + + + {pf.cashError &&

{pf.cashError}

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

+ {broker} +

+

{broker} 보유 현황

+

+ {items.length}종목 · 평가{' '} + {formatNumber(bSummary.totalEval)} · 손익{' '} + + {formatNumber(bSummary.totalProfit)} ( + {formatPercent(bSummary.totalProfitRate)}) + + {(() => { + const bc = pf.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 = pf.editingId === item.id; + const isDeleting = pf.deleteConfirmId === item.id; + const isSelling = pf.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 ? ( + <> + + + + ) : ( + <> + + + + )} +
+ + )} +
+ ); + })} +
+
+ ); + })} + + {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.portfolioError && ( +
+

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

+
+ )} + +); + +export default PortfolioTab; diff --git a/src/pages/stock/components/ReportTab.jsx b/src/pages/stock/components/ReportTab.jsx new file mode 100644 index 0000000..29e4648 --- /dev/null +++ b/src/pages/stock/components/ReportTab.jsx @@ -0,0 +1,384 @@ +import React from 'react'; +import Loading from '../../../components/Loading'; +import { + PieChart, Pie, Cell, + BarChart, Bar, XAxis, YAxis, CartesianGrid, + Tooltip as ChartTooltip, Legend, ResponsiveContainer, +} from 'recharts'; +import { + formatNumber, formatPercent, toNumeric, + CHART_COLORS, profitColorClass, getVixLabel, getFgLabel, +} from '../stockUtils'; + +const ReportTab = ({ pf, report, ai, marketCtx }) => ( + <> + {pf.portfolioLoading && ( +
+ +
+ )} + {pf.portfolioError &&

{pf.portfolioError}

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

포트폴리오 분석

+

자산 배분 현황

+
+
+
+
+

증권사별 자산 배분

+ + + + {report.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 }} + /> + + {report.profitBarData.map((entry, i) => ( + = 0 ? '#34d399' : '#f87171'} /> + ))} + + + +
+
+
+ )} + + {/* 리스크 분산 분석 */} + {pf.portfolioHoldings.length > 0 && pf.portfolioSummary.total_eval != null && ( +
+
+
+

리스크 관리

+

분산 분석

+

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

+
+
+
+
+

증권사별 집중도

+ {report.brokerConcentration.length === 0 ? ( +

평가금액 데이터 없음

+ ) : ( + <> + {report.brokerConcentration.some((b) => b.ratio > 40) && ( +
+ ⚠️ 단일 증권사 집중도가 40%를 초과합니다 +
+ )} + {report.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 종목 집중도

+ {report.stockConcentration.length === 0 ? ( +

현재가 데이터 없음

+ ) : ( + <> + {report.stockConcentration.some((s) => s.ratio > 40) && ( +
+ ⚠️ 단일 종목 집중도가 40%를 초과합니다 +
+ )} + {report.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)}원 + +
+ ); + })} + + )} +
+
+
+ )} + + {/* 수익률 랭킹 테이블 */} + {pf.portfolioHoldings.length > 0 && ( +
+
+
+

수익률 랭킹

+

종목별 상세 현황

+

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

+
+
+
+ + + + {[ + { key: 'name', label: '종목명' }, + { key: 'broker', label: '증권사' }, + { key: 'profit_rate', label: '수익률' }, + { key: 'profit_amount', label: '평가손익' }, + { key: 'eval_amount', label: '평가금액' }, + ].map(({ key, label }) => ( + + ))} + + + + + {report.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 totalEvalVal = toNumeric(pf.portfolioSummary.total_eval); + const weight = evalAmt != null && totalEvalVal + ? Math.round((evalAmt / totalEvalVal) * 1000) / 10 + : null; + return ( + + + + + + + + + ); + })} + +
report.handleReportSort(key)}> + {label}{' '} + + {report.reportSortField === key + ? report.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: `${report.maxAbsRate > 0 ? Math.abs(rateN) / report.maxAbsRate * 100 : 0}%` }} + /> +
+ )} +
+
+ {item.profit_amount != null ? formatNumber(item.profit_amount) : '-'} + + {evalAmt != null ? formatNumber(evalAmt) : '-'} + + {weight != null ? `${weight.toFixed(1)}%` : '-'} +
+
+
+ )} + + {pf.portfolioLoaded && pf.portfolioHoldings.length === 0 && !pf.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} + + )} +
+
+ )} + + {/* 모델 선택 */} +
+ +
+ +
+ + {pf.portfolioHoldings.length === 0 && ( + 종목 등록 후 이용 가능합니다. + )} + {ai.aiResult?.generated_at && ( + + {ai.aiResult.cached ? '오늘 캐시 결과 · ' : ''} + {new Date(ai.aiResult.generated_at).toLocaleTimeString('ko-KR')} 생성 + + )} +
+ + {ai.aiError &&

{ai.aiError}

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

{ai.aiResult.summary}

+
+

{ai.aiResult.evaluation}

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

{a.title}

+

{a.body}

+
+ ))} +
+ )} + +
+ )} +
+ +); + +export default ReportTab; diff --git a/src/pages/stock/components/SellHistoryDrawer.jsx b/src/pages/stock/components/SellHistoryDrawer.jsx new file mode 100644 index 0000000..15ae2cd --- /dev/null +++ b/src/pages/stock/components/SellHistoryDrawer.jsx @@ -0,0 +1,354 @@ +import React from 'react'; +import Loading from '../../../components/Loading'; +import { formatNumber, formatPercent, profitColorClass } from '../stockUtils'; + +const SellHistoryDrawer = ({ + sell, sellHistoryBrokers, filteredSellHistory, sellHistorySummary, +}) => ( + <> + {/* Floating 토글 버튼 */} + {!sell.sellDrawerOpen && ( + + )} + + {/* Backdrop */} + {sell.sellDrawerOpen && ( +
{ sell.setSellDrawerOpen(false); sell.handleSellFormClose(); }} + /> + )} + + {/* Drawer */} + + +); + +export default SellHistoryDrawer;