238 lines
13 KiB
JavaScript
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;
|