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

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

{summary.note}

+ {activeTab === TAB_AI && aib.summary.note ? ( +

{aib.summary.note}

) : null} @@ -1190,9 +234,9 @@ ${cashLines} > 💼 쟁승토리 계좌 - {portfolioHoldings.length > 0 && ( + {pf.portfolioHoldings.length > 0 && ( - {portfolioHoldings.length} + {pf.portfolioHoldings.length} )} @@ -1230,8 +274,8 @@ ${cashLines} ════════════════════════════════════════════════════════ */} {activeTab === TAB_PORTFOLIO && ( <> - {portfolioError ? ( -

{portfolioError}

+ {pf.portfolioError ? ( +

{pf.portfolioError}

) : null} {/* 포트폴리오 관리 헤더 + 추가 폼 */} @@ -1245,35 +289,35 @@ ${cashLines}

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