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 && (
-
- )}
-
- {/* 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 && (
-
- 등록된 예수금이 없습니다.
-
- )}
-
-
-
-
- {/* 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 */}
-
-
-
-
수동 주문
-
직접 매수/매도
-
- 종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
-
-
-
-
-
- >
- )}
-
- {/* ════════════════════════════════════════════════════════
- 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.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.handleReportSort(key)}>
- {label}{' '}
-
- {report.reportSortField === key
- ? report.reportSortDir === 'asc' ? '↑' : '↓'
- : '↕'}
-
- |
- ))}
- 비중 |
-
-
-
- {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 (
-
- |
- {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) => (
-
- ))}
-
- )}
-
-
- )}
-
- >
- )}
-
- {/* 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 */}
+
+
+
+
수동 주문
+
직접 매수/매도
+
+ 종목명 또는 종목코드를 입력하고 매수/매도 주문을 요청합니다.
+
+
+
+
+
+
+ {/* 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 && (
+
+ )}
+
+ {/* 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 && (
+
+ 등록된 예수금이 없습니다.
+
+ )}
+
+
+
+
+ {/* 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.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.handleReportSort(key)}>
+ {label}{' '}
+
+ {report.reportSortField === key
+ ? report.reportSortDir === 'asc' ? '↑' : '↓'
+ : '↕'}
+
+ |
+ ))}
+ 비중 |
+
+
+
+ {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 (
+
+ |
+ {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) => (
+
+ ))}
+
+ )}
+
+
+ )}
+
+ >
+);
+
+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;