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 && (
-
)}
{/* Portfolio total summary */}
- {portfolioHoldings.length > 0 && (
+ {pf.portfolioHoldings.length > 0 && (
{[
- { label: '총 매입', value: portfolioSummary.total_buy },
- { label: '총 평가', value: portfolioSummary.total_eval },
- { label: '총 손익', value: portfolioSummary.total_profit, isProfit: true },
- { label: '수익률', value: portfolioSummary.total_profit_rate, isRate: true },
+ { 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}
@@ -1363,16 +407,16 @@ ${cashLines}
))}
- {totalCash != null && (
+ {pf.totalCash != null && (
예수금 합계
- {formatNumber(totalCash)}원
+ {formatNumber(pf.totalCash)}원
)}
- {totalAssets != null && (
+ {pf.totalAssets != null && (
총 자산
- {formatNumber(totalAssets)}원
+ {formatNumber(pf.totalAssets)}원
)}
@@ -1391,8 +435,8 @@ ${cashLines}
@@ -1401,22 +445,22 @@ ${cashLines}
type="button"
className="button ghost small"
onClick={handleSaveSnapshot}
- disabled={snapshotSaving || totalAssets == null}
+ disabled={asset.snapshotSaving || pf.totalAssets == null}
title="현재 총 자산을 오늘 날짜로 저장"
>
- {snapshotSaving ? '저장 중...' : '📸 스냅샷'}
+ {asset.snapshotSaving ? '저장 중...' : '📸 스냅샷'}
- {assetHistoryLoading ? (
+ {asset.assetHistoryLoading ? (
- ) : Array.isArray(assetHistory) && assetHistory.length >= 1 ? (
+ ) : Array.isArray(asset.assetHistory) && asset.assetHistory.length >= 1 ? (
@@ -1478,10 +522,10 @@ ${cashLines}
- {cashList.length > 0 && (
+ {pf.cashList.length > 0 && (
- {cashList.map((item) => {
- const isEditing = cashEditingBroker === item.broker;
+ {pf.cashList.map((item) => {
+ const isEditing = pf.cashEditingBroker === item.broker;
return (
{item.broker}
@@ -1491,11 +535,11 @@ ${cashLines}
type="number"
min={0}
step={1}
- value={cashEditingValue}
- onChange={(e) => setCashEditingValue(e.target.value)}
+ value={pf.cashEditingValue}
+ onChange={(e) => pf.setCashEditingValue(e.target.value)}
onKeyDown={(e) => {
- if (e.key === 'Enter') handleCashInlineSave(item.broker);
- if (e.key === 'Escape') handleCashInlineCancel();
+ if (e.key === 'Enter') pf.handleCashInlineSave(item.broker);
+ if (e.key === 'Escape') pf.handleCashInlineCancel();
}}
autoFocus
/>
@@ -1513,15 +557,15 @@ ${cashLines}
<>
@@ -1530,14 +574,14 @@ ${cashLines}
<>
)}
- {cashList.length === 0 && (
+ {pf.cashList.length === 0 && (
등록된 예수금이 없습니다.
)}
-
{/* Broker cards stacked */}
- {brokerGroups.map(([broker, items]) => {
- const bSummary = getBrokerSummary(items);
- const color = brokerColors[broker];
+ {pf.brokerGroups.map(([broker, items]) => {
+ const bSummary = pf.getBrokerSummary(items);
+ const color = pf.brokerColors[broker];
return (
{(() => {
- const bc = cashList.find(
+ const bc = pf.cashList.find(
(c) => c.broker === broker
);
return bc ? (
@@ -1645,9 +689,9 @@ ${cashLines}
const profitRate = item.profit_rate;
const profitAmtN = toNumeric(profitAmt);
const profitRateN = toNumeric(profitRate);
- const isEditing = editingId === item.id;
- const isDeleting = deleteConfirmId === item.id;
- const isSelling = sellConfirmId === item.id;
+ 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;
@@ -1664,9 +708,9 @@ ${cashLines}
- setEditForm((p) => ({
+ pf.setEditForm((p) => ({
...p,
quantity: Number(e.target.value),
}))
@@ -1678,9 +722,9 @@ ${cashLines}
- setEditForm((p) => ({
+ pf.setEditForm((p) => ({
...p,
avg_price: Number(e.target.value),
}))
@@ -1691,14 +735,14 @@ ${cashLines}
@@ -1764,7 +808,7 @@ ${cashLines}
{!isSelling && !isDeleting && (
- {balanceLoading ? (
+ {aib.balanceLoading ? (
조회 중
) : null}
새로고침
@@ -1890,8 +932,8 @@ ${cashLines}
{[
- { label: '총 평가', value: totalEval },
- { label: '예수금', value: deposit },
+ { label: '총 평가', value: aib.totalEval },
+ { label: '예수금', value: aib.deposit },
].map((item) => (
))}
- {holdings.length ? (
+ {aib.holdings.length ? (
- {holdings.map((item, idx) => {
+ {aib.holdings.map((item, idx) => {
const profitLoss = getProfitLoss(item);
const profitLossNumeric = toNumeric(profitLoss);
const profitClass = profitColorClass(profitLossNumeric);
@@ -1987,14 +1029,14 @@ ${cashLines}
-