Files
web-page/src/pages/stock/StockTrade.jsx

238 lines
13 KiB
JavaScript

import React, { useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom';
import './Stock.css';
import { useIsMobile } from '../../hooks/useIsMobile';
import SwipeableView from '../../components/SwipeableView';
import {
formatNumber, formatPercent,
toNumeric, profitColorClass,
TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR,
} from './stockUtils';
/* ── 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';
/* ── 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 = () => {
const [activeTab, setActiveTab] = React.useState(TAB_REPORT);
const isMobile = useIsMobile();
const TAB_ORDER = [TAB_PORTFOLIO, TAB_AI, TAB_REPORT, TAB_ADVISOR];
const tabLabels = ['포트폴리오', 'AI 트레이드', '리포트', '어드바이저'];
const tabIndex = TAB_ORDER.indexOf(activeTab);
const handleTabChange = useCallback((idx) => setActiveTab(TAB_ORDER[idx]), []); // eslint-disable-line react-hooks/exhaustive-deps
/* ── hooks ────────────────────────────────────────────────────── */
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 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(sell.sellHistory.map((r) => r.broker).filter(Boolean));
return ['ALL', ...Array.from(set).sort()];
}, [sell.sellHistory]);
const filteredSellHistory = useMemo(() => {
const now = new Date();
const periodMs = {
'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;
return (now - new Date(r.sold_at)) <= periodMs;
});
}, [sell.sellHistory, sell.sellHistoryBroker, sell.sellHistoryPeriod]);
const sellHistorySummary = useMemo(() => {
const totalProfit = filteredSellHistory.reduce((s, r) => s + (r.realized_profit ?? 0), 0);
const totalSell = filteredSellHistory.reduce((s, r) => s + (r.sell_amount ?? 0), 0);
const totalBuy = filteredSellHistory.reduce((s, r) => s + (r.buy_amount ?? 0), 0);
const totalCommission = filteredSellHistory.reduce((s, r) => s + (r.commission ?? 0), 0);
const rate = totalBuy > 0 ? (totalProfit / totalBuy) * 100 : 0;
return { totalProfit, totalSell, totalBuy, totalCommission, rate, count: filteredSellHistory.length };
}, [filteredSellHistory]);
/* ── 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 || 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
useEffect(() => {
if (activeTab !== TAB_PORTFOLIO) return;
const timer = window.setInterval(pf.loadPortfolio, 180000);
return () => window.clearInterval(timer);
}, [activeTab, pf.loadPortfolio]);
/* ── cross-hook wrappers ─────────────────────────────────────── */
const handleSell = (item) =>
pf.handleSell(item, { cashList: pf.cashList, loadSellHistoryAfter: sell.addSellRecord });
const handleSaveSnapshot = () =>
asset.handleSaveSnapshot(pf.totalAssets, asset.assetHistoryDays);
/* ── render ───────────────────────────────────────────────────── */
return (
<div className="stock">
{/* Header */}
<header className="stock-header">
<div>
<p className="stock-kicker">거래 데스크</p>
<h1>거래 데스크</h1>
<p className="stock-sub">실제 계좌와 AI 모의투자를 곳에서 관리하세요.</p>
<div className="stock-actions">
<Link className="button ghost" to="/stock">주식 랩으로 돌아가기</Link>
</div>
</div>
<div className="stock-card">
<p className="stock-card__title">
{activeTab === TAB_AI ? 'AI 투자 요약' : '쟁승토리 계좌 요약'}
</p>
{activeTab === TAB_PORTFOLIO || activeTab === TAB_REPORT ? (
<div className="stock-status">
<div><span> 매입</span><strong>{formatNumber(pf.portfolioSummary.total_buy)}</strong></div>
<div><span> 평가</span><strong>{formatNumber(pf.portfolioSummary.total_eval)}</strong></div>
<div>
<span> 손익</span>
<strong className={`stock-profit ${profitColorClass(toNumeric(pf.portfolioSummary.total_profit))}`}>
{formatNumber(pf.portfolioSummary.total_profit)}
{pf.portfolioSummary.total_profit_rate != null && (
<small style={{ marginLeft: 4, fontSize: 11 }}>
({formatPercent(pf.portfolioSummary.total_profit_rate)})
</small>
)}
</strong>
</div>
<div><span>보유 종목</span><strong>{pf.portfolioHoldings.length}</strong></div>
{pf.totalCash != null && (
<div><span>예수금 합계</span><strong style={{ color: '#93c5fd' }}>{formatNumber(pf.totalCash)}</strong></div>
)}
{pf.totalAssets != null && (
<div><span> 자산</span><strong style={{ fontWeight: 700 }}>{formatNumber(pf.totalAssets)}</strong></div>
)}
</div>
) : (
<div className="stock-status">
<div><span> 평가금액</span><strong>{formatNumber(aib.totalEval)}</strong></div>
<div><span>예수금</span><strong>{formatNumber(aib.deposit)}</strong></div>
<div><span>보유 종목</span><strong>{aib.holdings.length}</strong></div>
</div>
)}
{activeTab === TAB_AI && aib.summary.note ? (
<p className="stock-status__note">{aib.summary.note}</p>
) : null}
</div>
</header>
{/* Tab bar + Tab content */}
{isMobile ? (
<SwipeableView
tabs={TAB_ORDER.map((tabId, i) => ({
key: tabId,
label: tabLabels[i],
content: tabId === TAB_PORTFOLIO
? <PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
: tabId === TAB_AI
? <AiTradeTab aib={aib} />
: tabId === TAB_REPORT
? <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />
: <AdvisorTab pf={pf} advisor={advisor} />,
}))}
activeIndex={tabIndex}
onTabChange={handleTabChange}
/>
) : (
<>
<div className="stock-main-tabs">
{[
{ 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 }) => (
<button
key={id}
type="button"
className={`stock-main-tab ${cls ?? ''} ${activeTab === id ? 'is-active' : ''}`}
onClick={() => setActiveTab(id)}
>
<span className="stock-main-tab__icon">{icon}</span>
<span className="stock-main-tab__label">{label}</span>
{sub && <span className="stock-main-tab__sub">{sub}</span>}
{badge > 0 && <span className="stock-main-tab__badge">{badge}</span>}
</button>
))}
</div>
{activeTab === TAB_PORTFOLIO && (
<PortfolioTab pf={pf} asset={asset} handleSell={handleSell} handleSaveSnapshot={handleSaveSnapshot} />
)}
{activeTab === TAB_AI && <AiTradeTab aib={aib} />}
{activeTab === TAB_REPORT && <ReportTab pf={pf} report={report} ai={ai} marketCtx={marketCtx} />}
{activeTab === TAB_ADVISOR && <AdvisorTab pf={pf} advisor={advisor} />}
</>
)}
{/* Sell history drawer (always mounted) */}
<SellHistoryDrawer
sell={sell}
sellHistoryBrokers={sellHistoryBrokers}
filteredSellHistory={filteredSellHistory}
sellHistorySummary={sellHistorySummary}
/>
</div>
);
};
export default StockTrade;