Files
goldenChart/frontend/src/components/PaperTradingPage.tsx
T
2026-05-31 05:29:17 +09:00

394 lines
16 KiB
TypeScript

/**
* 모의투자 대시보드 — 가상매매와 동일 3열 레이아웃(좌·중앙·우 접기/리사이즈)
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTradesPaged,
loadPaperPendingOrders,
loadStrategies,
resetPaperAccount,
type PaperOrderDto,
type PaperPositionDto,
type PaperSummaryDto,
type PaperTradeDto,
type StrategyDto,
} from '../utils/backendApi';
import type { TickerData } from '../hooks/useMarketTicker';
import { coerceFiniteNumber } from '../utils/safeFormat';
import PaperInvestmentKpi from './paper/PaperInvestmentKpi';
import PaperAllocationTable from './paper/PaperAllocationTable';
import PaperPendingOrdersList from './paper/PaperPendingOrdersList';
import PaperLedgerTab from './paper/PaperLedgerTab';
import PaperAssetTrendChart from './paper/PaperAssetTrendChart';
import { computePaperMetrics } from '../utils/paperMetrics';
import { TradeSplitOrderPanel, TradeOrderbookPanelContent, TradeRightPanelBody } from './trade';
import type { Theme, TradeOrderFillRequest } from '../types';
import PaperAnalysisChart from './paper/PaperAnalysisChart';
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
import PaperTradeHistoryList from './paper/PaperTradeHistoryList';
import BuilderPageShell from './layout/BuilderPageShell';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
type HistoryTab = 'open' | 'recent' | 'ledger';
type CenterTab = 'invest' | 'chart';
type RightTab = 'trade' | 'orderbook' | 'history';
type LeftTab = 'strategy' | 'settings';
interface Props {
theme?: Theme;
tickers?: Map<string, TickerData>;
onGoChart?: (market: string) => void;
refreshKey?: number;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
/** 설정 → 모의투자 카테고리로 이동 */
onOpenSettings?: () => void;
}
function buildMarkPrices(
summary: PaperSummaryDto | null,
tickers?: Map<string, TickerData>,
): Record<string, number> {
const m: Record<string, number> = {};
summary?.positions?.forEach(p => {
const t = tickers?.get(p.symbol)?.tradePrice;
if (t != null && t > 0) m[p.symbol] = t;
});
return m;
}
function positionPriceKey(
positions: PaperPositionDto[] | undefined,
tickers?: Map<string, TickerData>,
): string {
if (!positions?.length) return '';
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
}
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
const PaperTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
refreshKey = 0,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
onOpenSettings,
}) => {
const [strategies, setStrategies] = useState<StrategyDto[]>([]);
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [pendingOrders, setPendingOrders] = useState<PaperOrderDto[]>([]);
const [dataRefreshKey, setDataRefreshKey] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [centerTab, setCenterTab] = useState<CenterTab>('invest');
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [leftTab, setLeftTab] = useState<LeftTab>('strategy');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const summaryRef = useRef(summary);
summaryRef.current = summary;
const loadData = useCallback(async (opts?: { focusHistory?: boolean }) => {
try {
const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
setSummary(full);
const tradePage = await loadPaperTradesPaged({ page: 0, size: 100 });
setTrades(tradePage.content);
setPendingOrders(await loadPaperPendingOrders());
setDataRefreshKey(k => k + 1);
} finally {
setInitialLoading(false);
}
if (opts?.focusHistory) setRightTab('history');
}, []);
useEffect(() => {
void Promise.all([
loadData(),
loadStrategies().then(setStrategies).catch(() => setStrategies([])),
]);
}, [loadData]);
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
const priceKey = positionPriceKey(summary?.positions, tickers);
const lastPriceKeyRef = useRef('');
useEffect(() => {
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
lastPriceKeyRef.current = priceKey;
let cancelled = false;
(async () => {
const marks = buildMarkPrices(summaryRef.current, tickersRef.current);
if (!Object.keys(marks).length) return;
try {
const full = await loadPaperSummary(marks);
if (!cancelled) setSummary(full);
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [priceKey, initialLoading]);
const strategyNames = useMemo(
() => Object.fromEntries(strategies.map(s => [s.id, s.name])),
[strategies],
);
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary;
const posQty = useMemo(() => {
const p = s?.positions?.find(x => x.symbol === selectedMarket);
return coerceFiniteNumber(p?.quantity) ?? 0;
}, [s?.positions, selectedMarket]);
const selectedAlloc = useMemo(
() => s?.allocations?.find(a => a.symbol === selectedMarket),
[s?.allocations, selectedMarket],
);
const orderableCash = s?.orderableCash ?? s?.cashBalance ?? 0;
const tradePrice = useMemo(
() => coerceFiniteNumber(tickers?.get(selectedMarket)?.tradePrice),
[tickers, selectedMarket],
);
const { orderbook, wsStatus: obWsStatus, spread } = useUpbitOrderbook(selectedMarket);
const { trades: recentTrades, strength: tradeStrength } = useUpbitRecentTrades(selectedMarket);
const selectedTicker = tickers?.get(selectedMarket);
const orderbookPrevClose = selectedTicker
? (selectedTicker.tradePrice ?? 0) - (selectedTicker.changePrice ?? 0)
: 0;
const orderbookTickerInfo = selectedTicker ? {
tradePrice: selectedTicker.tradePrice,
changeRate: selectedTicker.changeRate,
changePrice: selectedTicker.changePrice,
accTradePrice24: selectedTicker.accTradePrice24,
accTradeVolume24: selectedTicker.accTradeVolume24,
highPrice: selectedTicker.highPrice,
lowPrice: selectedTicker.lowPrice,
openingPrice: selectedTicker.openingPrice,
} : undefined;
const handleSelectMarket = useCallback((market: string) => {
setSelectedMarket(market);
const price = tickers?.get(market)?.tradePrice;
if (price != null && price > 0) {
const seq = Date.now();
setFillBuy({ market, price, side: 'buy', seq });
setFillSell({ market, price, side: 'sell', seq: seq + 1 });
}
setRightTab('trade');
}, [tickers]);
const handleOrderbookRowClick = useCallback((price: number, rowType: 'ask' | 'bid') => {
setRightTab('trade');
const side = rowType === 'bid' ? 'buy' : 'sell';
const req: TradeOrderFillRequest = { market: selectedMarket, price, side, seq: Date.now() };
if (side === 'buy') setFillBuy(req);
else setFillSell(req);
}, [selectedMarket]);
const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) {
setSummary(res);
setTrades([]);
setPendingOrders([]);
}
}, []);
const rightTabs = (
<div className="bps-right-tabs">
<button type="button" className={`bps-right-tab${rightTab === 'trade' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('trade')}>매매</button>
<button type="button" className={`bps-right-tab${rightTab === 'orderbook' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('orderbook')}>호가</button>
<button type="button" className={`bps-right-tab${rightTab === 'history' ? ' bps-right-tab--on' : ''}`} onClick={() => setRightTab('history')}>
거래{trades.length > 0 ? ` (${trades.length})` : ''}
</button>
</div>
);
const leftTabs = (
<div className="bps-left-tabs">
<button type="button" className={`bps-left-tab${leftTab === 'strategy' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('strategy')}>전략</button>
<button type="button" className={`bps-left-tab${leftTab === 'settings' ? ' bps-left-tab--on' : ''}`} onClick={() => setLeftTab('settings')}>설정</button>
</div>
);
return (
<BuilderPageShell
theme={theme}
title="모의투자"
subtitle="Paper Trading"
pageClassName="bps-page--vtd bps-page--ptd"
loading={initialLoading}
loadingText="모의투자 대시보드 로딩…"
collapsiblePanels
leftStorageKey="ptd-left-width"
leftDefaultWidth={380}
leftCollapsedStorageKey="ptd-left-open"
rightStorageKey="ptd-right-width"
rightDefaultWidth={440}
rightCollapsedStorageKey="ptd-right-open"
headerCenter={!paperTradingEnabled ? (
<span className="bps-header-status-text" role="status">
모의투자가 꺼져 있습니다. 설정에서 활성화하세요.
</span>
) : undefined}
headerCenterMode="between"
headerActions={(
<>
<button type="button" className="bps-btn bps-btn--ghost" onClick={() => void loadData()}>새로고침</button>
<button type="button" className="bps-btn bps-btn--danger" onClick={() => void handleReset()}>계좌 초기화</button>
</>
)}
leftTabs={leftTabs}
left={leftTab === 'strategy' ? (
<PaperLeftStrategyTab
market={selectedMarket}
summary={s}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onMarketSelect={handleSelectMarket}
/>
) : (
<PaperLeftSettingsTab
onOpenSettings={onOpenSettings}
onSettingsSaved={() => void loadData()}
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
/>
)}
centerHead={(
<div className="ptd-center-head">
<div className="ptd-tabs ptd-tabs--center">
<button type="button" className={`ptd-tab${centerTab === 'invest' ? ' active' : ''}`} onClick={() => setCenterTab('invest')}>투자금 관리</button>
<button type="button" className={`ptd-tab${centerTab === 'chart' ? ' active' : ''}`} onClick={() => setCenterTab('chart')}>차트</button>
</div>
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
</div>
)}
center={centerTab === 'invest' ? (
<div className="ptd-card ptd-invest-card ptd-center-fill">
<PaperInvestmentKpi summary={s} />
<PaperAllocationTable
allocations={s?.allocations ?? []}
selectedMarket={selectedMarket}
onSelectMarket={handleSelectMarket}
onChanged={() => void loadData()}
/>
<div className="ptd-invest-metrics">
<PaperAssetTrendChart refreshKey={dataRefreshKey} />
<div className="ptd-metrics-grid">
<MetricCard icon="📉" title="MDD" value={`${metrics.mddPct.toFixed(2)}%`} sub="최대 낙폭" tone="down" />
<MetricCard icon="📊" title="Sharpe Ratio" value={metrics.sharpeRatio.toFixed(2)} sub="위험 대비 수익" tone="up" />
<MetricCard icon="🏆" title="Win Rate" value={`${metrics.winRatePct.toFixed(0)}%`} sub="승률" tone="up" />
<MetricCard icon="🛡" title="Profit Factor" value={metrics.profitFactor.toFixed(2)} sub="총이익/총손실" tone="up" />
</div>
</div>
</div>
) : (
<div className="ptd-card ptd-chart-card ptd-center-fill">
<PaperAnalysisChart market={selectedMarket} theme={theme} />
</div>
)}
rightTabs={rightTabs}
right={(
<TradeRightPanelBody anchorRef={orderAnchorRef} embeddedInShell>
{rightTab === 'trade' ? (
<TradeSplitOrderPanel
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={orderableCash}
availableSymbolBuyKrw={selectedAlloc?.availableBuyKrw}
fillBuy={fillBuy}
fillSell={fillSell}
searchAnchorRef={orderAnchorRef}
onMarketSelect={handleSelectMarket}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => void loadData({ focusHistory: true })}
/>
) : rightTab === 'orderbook' ? (
<TradeOrderbookPanelContent
market={selectedMarket}
asks={orderbook.asks}
bids={orderbook.bids}
totalAskSize={orderbook.totalAskSize}
totalBidSize={orderbook.totalBidSize}
wsStatus={obWsStatus}
bestAsk={spread.bestAsk}
bestBid={spread.bestBid}
spread={spread.spread}
spreadPct={spread.pct}
prevClose={orderbookPrevClose}
tickerInfo={orderbookTickerInfo}
recentTrades={recentTrades}
tradeStrength={tradeStrength ?? undefined}
onRowClick={handleOrderbookRowClick}
/>
) : (
<div className="ptd-right-history">
<div className="ptd-tabs ptd-tabs--history">
<button type="button" className={`ptd-tab${historyTab === 'open' ? ' active' : ''}`} onClick={() => setHistoryTab('open')}>미체결</button>
<button type="button" className={`ptd-tab${historyTab === 'recent' ? ' active' : ''}`} onClick={() => setHistoryTab('recent')}>체결</button>
<button type="button" className={`ptd-tab${historyTab === 'ledger' ? ' active' : ''}`} onClick={() => setHistoryTab('ledger')}>원장</button>
</div>
<div className="ptd-right-history-body">
{historyTab === 'open' ? (
<PaperPendingOrdersList orders={pendingOrders} onChanged={() => void loadData()} />
) : historyTab === 'ledger' ? (
<PaperLedgerTab refreshKey={dataRefreshKey} />
) : (
<PaperTradeHistoryList
trades={trades}
strategyNames={strategyNames}
tickers={tickers}
onSelectMarket={handleSelectMarket}
emptyText="체결 내역이 없습니다."
className="ptd-trade-history--fill"
/>
)}
</div>
</div>
)}
</TradeRightPanelBody>
)}
/>
);
};
function MetricCard({ icon, title, value, sub, tone }: {
icon: string; title: string; value: string; sub: string; tone: 'up' | 'down';
}) {
return (
<div className={`ptd-metric ptd-metric--${tone}`}>
<span className="ptd-metric-icon">{icon}</span>
<div>
<div className="ptd-metric-title">{title}</div>
<div className="ptd-metric-value">{value}</div>
<div className="ptd-metric-sub">{sub}</div>
</div>
</div>
);
}
export default PaperTradingPage;