모의투자, 백테스팅 레이아웃 수정

This commit is contained in:
Macbook
2026-05-24 20:13:21 +09:00
parent 958b813f3b
commit af230a4233
23 changed files with 2394 additions and 782 deletions
+164 -202
View File
@@ -13,20 +13,17 @@ import {
import { computePaperMetrics } from '../utils/paperMetrics';
import { fmtKrw } from './TradeOrderPanel';
import TradeOrderPanel from './TradeOrderPanel';
import type { TradeOrderFillRequest } from '../types';
import PaperMiniChart from './paper/PaperMiniChart';
import PaperIndicatorPanel from './paper/PaperIndicatorPanel';
import type { Theme, TradeOrderFillRequest } from '../types';
import PaperAnalysisChart from './paper/PaperAnalysisChart';
import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab';
import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab';
import PaperCompactOrderbook from './paper/PaperCompactOrderbook';
import type { Theme } from '../types';
const COIN_COLORS: Record<string, string> = {
BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff',
DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142',
};
const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b'];
import PaperSplitPanel from './paper/PaperSplitPanel';
import BuilderPageShell from './layout/BuilderPageShell';
type HistoryTab = 'open' | 'recent';
type RightTab = 'trade' | 'orderbook';
type RightTab = 'trade' | 'orderbook' | 'history';
type LeftTab = 'strategy' | 'settings';
interface Props {
theme?: Theme;
@@ -63,10 +60,6 @@ function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
function coinIcon(code: string): string {
return code.slice(0, 1);
}
const PaperTradingPage: React.FC<Props> = ({
theme = 'dark',
tickers,
@@ -82,6 +75,7 @@ const PaperTradingPage: React.FC<Props> = ({
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
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);
@@ -126,7 +120,6 @@ const PaperTradingPage: React.FC<Props> = ({
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary;
const retUp = (s?.totalReturnPct ?? 0) >= 0;
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const posQty = useMemo(() => {
@@ -134,29 +127,6 @@ const PaperTradingPage: React.FC<Props> = ({
return p?.quantity ?? 0;
}, [s?.positions, selectedMarket]);
const allocation = useMemo(() => {
if (!s) return [] as { label: string; value: number; pct: number }[];
const items: { label: string; value: number }[] = [];
if (s.cashBalance > 0) items.push({ label: 'KRW', value: s.cashBalance });
s.positions.forEach(p => {
const v = p.evalAmount ?? p.quantity * (p.markPrice ?? p.avgPrice);
if (v > 0) items.push({ label: coinCode(p.symbol), value: v });
});
const total = items.reduce((a, x) => a + x.value, 0) || 1;
return items.map(x => ({ ...x, pct: (x.value / total) * 100 }));
}, [s]);
const donutStyle = useMemo(() => {
if (!allocation.length) return { background: '#1f2335' };
let acc = 0;
const stops = allocation.map((a, i) => {
const start = acc;
acc += a.pct;
return `${DONUT_COLORS[i % DONUT_COLORS.length]} ${start}% ${acc}%`;
});
return { background: `conic-gradient(${stops.join(', ')})` };
}, [allocation]);
const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => {
const side = rowType === 'ask' ? 'buy' : 'sell';
fillSeqRef.current += 1;
@@ -176,178 +146,170 @@ const PaperTradingPage: React.FC<Props> = ({
if (res) { setSummary(res); setTrades([]); }
}, []);
if (initialLoading) {
return <div className="ptd-page ptd-page--loading"> </div>;
}
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')}></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 (
<div className="ptd-page">
<header className="ptd-topbar">
<h1 className="ptd-title"></h1>
<div className="ptd-topbar-actions">
<button type="button" className="ptd-btn" onClick={() => void loadData()}></button>
<button type="button" className="ptd-btn ptd-btn--danger" onClick={handleReset}> </button>
</div>
</header>
{!s?.enabled && (
<div className="ptd-banner ptd-banner--warn"> . .</div>
<BuilderPageShell
theme={theme}
title="모의투자"
subtitle="Paper Trading"
loading={initialLoading}
loadingText="모의투자 대시보드 로딩…"
leftStorageKey="ptd-left-width"
footerStorageKey="ptd-footer-height"
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>
</>
)}
<section className="ptd-metrics">
banner={!s?.enabled ? (
<div className="bps-banner bps-banner--warn"> . .</div>
) : undefined}
leftTabs={leftTabs}
left={leftTab === 'strategy' ? (
<PaperLeftStrategyTab
market={selectedMarket}
summary={s}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onMarketSelect={setSelectedMarket}
/>
) : (
<PaperLeftSettingsTab
onAccountReset={() => { setSummary(null); setTrades([]); void loadData(); }}
/>
)}
centerHead={(
<span className="bps-center-head-title">{coinCode(selectedMarket)} / KRW</span>
)}
center={(
<div className="ptd-card ptd-chart-card ptd-center-fill">
<PaperAnalysisChart market={selectedMarket} theme={theme} />
</div>
)}
rightTabs={rightTabs}
right={(
<div className="ptd-right-body" ref={orderAnchorRef}>
{rightTab === 'trade' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매수"
bottomTitle="매도"
top={(
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={s?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
bottom={(
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
)}
/>
) : rightTab === 'orderbook' ? (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="매도 호가"
bottomTitle="매수 호가"
top={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="asks"
fillHeight
hideHeader
depth={10}
/>
)}
bottom={(
<PaperCompactOrderbook
market={selectedMarket}
onPick={handleObPick}
section="bids"
fillHeight
hideHeader
depth={10}
/>
)}
/>
) : (
<PaperSplitPanel
className="ptd-split-panel--right"
topTitle="체결 구분"
bottomTitle={historyTab === 'open' ? '미체결' : '최근체결'}
top={(
<div className="ptd-tabs ptd-tabs--in-card">
<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>
</div>
)}
bottom={historyTab === 'open' ? (
<p className="ptd-muted"> ( )</p>
) : (
<table className="ptd-table ptd-table--compact">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{trades.slice(0, 20).map(t => (
<tr key={t.id}>
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(t.price)}</td>
<td><span className="ptd-status ptd-status--done"></span></td>
</tr>
))}
{!trades.length && <tr><td colSpan={5} className="ptd-muted"> </td></tr>}
</tbody>
</table>
)}
/>
)}
</div>
)}
footerLabel="성과 지표"
footer={(
<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>
</section>
<div className="ptd-main">
<aside className="ptd-col ptd-col--left">
<div className="ptd-card ptd-asset-card">
<div className="ptd-card-label"> </div>
<div className="ptd-asset-total">{s ? fmtKrw(s.totalAsset) : '—'} <span>KRW</span></div>
<div className={`ptd-asset-ret ${retUp ? 'up' : 'down'}`}>
{s ? `${retUp ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
</div>
<div className="ptd-asset-sub"> {s ? fmtKrw(s.initialCapital) : '—'} KRW</div>
</div>
<div className="ptd-card">
<div className="ptd-card-head"> </div>
<table className="ptd-table">
<thead>
<tr><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{(s?.positions ?? []).length === 0 ? (
<tr><td colSpan={4} className="ptd-muted"> </td></tr>
) : s!.positions.map(p => {
const code = coinCode(p.symbol);
const up = (p.profitLoss ?? 0) >= 0;
return (
<tr key={p.symbol} className={selectedMarket === p.symbol ? 'ptd-row--sel' : ''} onClick={() => setSelectedMarket(p.symbol)}>
<td>
<span className="ptd-coin" style={{ background: COIN_COLORS[code] ?? '#64748b' }}>{coinIcon(code)}</span>
{code}
</td>
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
<td>{p.quantity.toFixed(4)}</td>
<td className={up ? 'up' : 'down'}>
{p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'}
{p.profitLossPct != null && <small> ({p.profitLossPct.toFixed(1)}%)</small>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="ptd-card ptd-donut-card">
<div className="ptd-card-head"> </div>
<div className="ptd-donut-wrap">
<div className="ptd-donut" style={donutStyle}>
<div className="ptd-donut-hole">
<span>{s ? `${(s.totalAsset / 1_000_000).toFixed(1)}M` : '—'}</span>
</div>
</div>
<ul className="ptd-donut-legend">
{allocation.map((a, i) => (
<li key={a.label}>
<span className="ptd-dot" style={{ background: DONUT_COLORS[i % DONUT_COLORS.length] }} />
{a.label} {a.pct.toFixed(0)}%
</li>
))}
</ul>
</div>
</div>
</aside>
<main className="ptd-col ptd-col--center">
<div className="ptd-card ptd-chart-card">
<PaperMiniChart market={selectedMarket} theme={theme} />
<PaperIndicatorPanel market={selectedMarket} />
</div>
</main>
<aside className="ptd-col ptd-col--right" ref={orderAnchorRef}>
<div className="ptd-right-panel">
<div className="ptd-tabs ptd-tabs--main">
<button type="button" className={`ptd-tab${rightTab === 'trade' ? ' active' : ''}`} onClick={() => setRightTab('trade')}></button>
<button type="button" className={`ptd-tab${rightTab === 'orderbook' ? ' active' : ''}`} onClick={() => setRightTab('orderbook')}></button>
</div>
<div className="ptd-right-body">
{rightTab === 'trade' ? (
<>
<div className="ptd-card ptd-order-card">
<div className="ptd-card-head"></div>
<div className="ptd-order-stack">
<TradeOrderPanel
side="buy"
market={selectedMarket}
tradePrice={tradePrice}
availableKrw={s?.cashBalance ?? 0}
fillRequest={fillBuy}
searchAnchorRef={orderAnchorRef}
onMarketSelect={setSelectedMarket}
showSymbolField
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
<TradeOrderPanel
side="sell"
market={selectedMarket}
tradePrice={tradePrice}
availableCoinQty={posQty}
fillRequest={fillSell}
onMarketSelect={setSelectedMarket}
showSymbolField={false}
paperTradingEnabled={paperTradingEnabled}
paperAutoTradeEnabled={paperAutoTradeEnabled}
onPaperOrderFilled={() => { onPaperOrderFilled?.(); void loadData(); }}
/>
</div>
</div>
<div className="ptd-card ptd-history-card">
<div className="ptd-tabs">
<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>
</div>
{historyTab === 'open' ? (
<p className="ptd-muted"> ( )</p>
) : (
<table className="ptd-table ptd-table--compact">
<thead>
<tr><th></th><th></th><th></th><th></th><th></th></tr>
</thead>
<tbody>
{trades.slice(0, 12).map(t => (
<tr key={t.id}>
<td className="ptd-time">{t.createdAt?.slice(11, 19) ?? '—'}</td>
<td>{coinCode(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side === 'BUY' ? '매수' : '매도'}</td>
<td>{fmtKrw(t.price)}</td>
<td><span className="ptd-status ptd-status--done"></span></td>
</tr>
))}
{!trades.length && <tr><td colSpan={5} className="ptd-muted"> </td></tr>}
</tbody>
</table>
)}
</div>
</>
) : (
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
)}
</div>
</div>
</aside>
</div>
</div>
)}
/>
);
};