전략분봉 저장 에러 문제 수정

This commit is contained in:
Macbook
2026-05-23 21:08:46 +09:00
parent eaf067db1c
commit 70ac67afe7
18 changed files with 2445 additions and 498 deletions
+275 -226
View File
@@ -1,7 +1,7 @@
/**
* 모의투자 화면 — 계좌 요약, 보유종목, 체결내역
* 모의투자 대시보드 — 첨부 UI (3열 + 핵심 성과 지표)
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
loadPaperSummary,
loadPaperTrades,
@@ -10,16 +10,31 @@ import {
type PaperSummaryDto,
type PaperTradeDto,
} from '../utils/backendApi';
import { getKoreanName } from '../utils/marketNameCache';
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 PaperCompactOrderbook from './paper/PaperCompactOrderbook';
type TabId = 'overview' | 'positions' | 'trades';
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'];
type HistoryTab = 'open' | 'recent';
type RightTab = 'trade' | 'orderbook';
interface Props {
tickers?: Map<string, { tradePrice: number | null }>;
onGoChart?: (market: string) => void;
/** 외부 체결(알림 모달 등) 후 목록 갱신 트리거 */
refreshKey?: number;
defaultMarket?: string;
paperTradingEnabled?: boolean;
paperAutoTradeEnabled?: boolean;
onPaperOrderFilled?: () => void;
}
function buildMarkPrices(
@@ -27,12 +42,10 @@ function buildMarkPrices(
tickers?: Map<string, { tradePrice: number | null }>,
): Record<string, number> {
const m: Record<string, number> = {};
if (summary?.positions) {
for (const p of summary.positions) {
const t = tickers?.get(p.symbol)?.tradePrice;
if (t != null && t > 0) m[p.symbol] = t;
}
}
summary?.positions?.forEach(p => {
const t = tickers?.get(p.symbol)?.tradePrice;
if (t != null && t > 0) m[p.symbol] = t;
});
return m;
}
@@ -41,277 +54,313 @@ function positionPriceKey(
tickers?: Map<string, { tradePrice: number | null }>,
): string {
if (!positions?.length) return '';
return positions
.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`)
.join('|');
return positions.map(p => `${p.symbol}:${tickers?.get(p.symbol)?.tradePrice ?? ''}`).join('|');
}
const PaperTradingPage: React.FC<Props> = ({ tickers, onGoChart, refreshKey = 0 }) => {
const [tab, setTab] = useState<TabId>('overview');
function coinCode(symbol: string): string {
return symbol.replace(/^KRW-/, '');
}
function coinIcon(code: string): string {
return code.slice(0, 1);
}
const PaperTradingPage: React.FC<Props> = ({
tickers,
refreshKey = 0,
defaultMarket = 'KRW-BTC',
paperTradingEnabled = true,
paperAutoTradeEnabled = false,
onPaperOrderFilled,
}) => {
const [summary, setSummary] = useState<PaperSummaryDto | null>(null);
const [trades, setTrades] = useState<PaperTradeDto[]>([]);
const [initialLoading, setInitialLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [historyTab, setHistoryTab] = useState<HistoryTab>('recent');
const [rightTab, setRightTab] = useState<RightTab>('trade');
const [fillBuy, setFillBuy] = useState<TradeOrderFillRequest | null>(null);
const [fillSell, setFillSell] = useState<TradeOrderFillRequest | null>(null);
const orderAnchorRef = useRef<HTMLDivElement>(null);
const fillSeqRef = useRef(0);
const tickersRef = useRef(tickers);
tickersRef.current = tickers;
const summaryRef = useRef(summary);
summaryRef.current = summary;
const loadData = useCallback(async (opts?: { showSpinner?: boolean }) => {
const showSpinner = opts?.showSpinner ?? false;
if (showSpinner) setRefreshing(true);
setError(null);
const loadData = useCallback(async () => {
try {
const base = await loadPaperSummary();
const marks = buildMarkPrices(base, tickersRef.current);
const full = Object.keys(marks).length > 0
? await loadPaperSummary(marks)
: base;
const full = Object.keys(marks).length > 0 ? await loadPaperSummary(marks) : base;
setSummary(full);
setTrades(await loadPaperTrades());
lastPriceKeyRef.current = positionPriceKey(full?.positions, tickersRef.current);
} catch {
if (showSpinner) setError('모의투자 데이터를 불러오지 못했습니다.');
} finally {
if (showSpinner) setRefreshing(false);
setInitialLoading(false);
}
}, []);
/** 최초 진입·수동 새로고침 */
const refresh = useCallback(() => {
void loadData({ showSpinner: true });
}, [loadData]);
useEffect(() => { void loadData(); }, [loadData]);
useEffect(() => { if (refreshKey > 0) void loadData(); }, [refreshKey, loadData]);
useEffect(() => {
void loadData({ showSpinner: true });
}, [loadData]);
useEffect(() => {
if (refreshKey <= 0) return;
void loadData();
}, [refreshKey, loadData]);
/** 보유 종목 시세만 조용히 갱신 (tickers Map 참조 변경마다 전체 로딩 방지) */
const priceKey = positionPriceKey(summary?.positions, tickers);
const lastPriceKeyRef = useRef('');
useEffect(() => {
if (!priceKey || initialLoading) return;
if (priceKey === lastPriceKeyRef.current) return;
if (!priceKey || initialLoading || priceKey === lastPriceKeyRef.current) return;
lastPriceKeyRef.current = priceKey;
let cancelled = false;
(async () => {
const base = summaryRef.current;
if (!base?.positions?.length) return;
const marks = buildMarkPrices(base, tickersRef.current);
if (Object.keys(marks).length === 0) return;
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 handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 보유 종목과 체결 이력이 모두 삭제됩니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) {
setSummary(res);
setTrades([]);
lastPriceKeyRef.current = '';
alert('모의투자 계좌가 초기화되었습니다.');
}
}, []);
const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]);
const s = summary;
const retUp = (s?.totalReturnPct ?? 0) >= 0;
const showPanelLoading = initialLoading;
const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null;
const posQty = useMemo(() => {
const p = s?.positions?.find(x => x.symbol === selectedMarket);
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;
const req: TradeOrderFillRequest = {
market: selectedMarket,
price,
side,
seq: fillSeqRef.current,
};
if (side === 'buy') setFillBuy(req); else setFillSell(req);
setRightTab('trade');
}, [selectedMarket]);
const handleReset = useCallback(async () => {
if (!window.confirm('모의투자 계좌를 초기화합니다. 계속할까요?')) return;
const res = await resetPaperAccount();
if (res) { setSummary(res); setTrades([]); }
}, []);
if (initialLoading) {
return <div className="ptd-page ptd-page--loading"> </div>;
}
return (
<div className="paper-page">
<header className="paper-header">
<div>
<h1 className="paper-title"></h1>
<p className="paper-subtitle">
· . .
</p>
</div>
<div className="paper-header-actions">
<button type="button" className="paper-btn" onClick={refresh} disabled={refreshing}>
{refreshing ? '새로고침 중…' : '새로고침'}
</button>
<button type="button" className="paper-btn paper-btn--danger" onClick={handleReset}>
</button>
<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 && !showPanelLoading && (
<div className="paper-banner paper-banner--warn">
. .
</div>
{!s?.enabled && (
<div className="ptd-banner ptd-banner--warn"> . .</div>
)}
{s?.enabled && s.autoTradeEnabled && !showPanelLoading && (
<div className="paper-banner paper-banner--info">
ON (BUY/SELL) .
(매수: 가용현금 {s.autoTradeBudgetPct}% · 매도: 보유 )
<section className="ptd-metrics">
<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>
{s?.enabled && !s.autoTradeEnabled && !showPanelLoading && (
<div className="paper-banner paper-banner--info">
OFF , · .
</div>
)}
<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>
{error && <div className="paper-banner paper-banner--error">{error}</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="paper-summary-grid">
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.totalAsset) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.cashBalance) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className="paper-card-value">{s ? fmtKrw(s.stockEvalAmount) : '—'} KRW</span>
</div>
<div className="paper-card">
<span className="paper-card-label"> </span>
<span className={`paper-card-value ${retUp ? 'up' : 'down'}`}>
{s ? `${s.totalReturnPct >= 0 ? '+' : ''}${s.totalReturnPct.toFixed(2)}%` : '—'}
</span>
</div>
<div className="paper-card">
<span className="paper-card-label"></span>
<span className={`paper-card-value ${(s?.unrealizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
{s ? fmtKrw(s.unrealizedPnl) : '—'} KRW
</span>
</div>
<div className="paper-card">
<span className="paper-card-label"></span>
<span className={`paper-card-value ${(s?.realizedPnl ?? 0) >= 0 ? 'up' : 'down'}`}>
{s ? fmtKrw(s.realizedPnl) : '—'} KRW
</span>
</div>
</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>
<nav className="paper-tabs">
{(['overview', 'positions', 'trades'] as TabId[]).map(id => (
<button
key={id}
type="button"
className={`paper-tab${tab === id ? ' active' : ''}`}
onClick={() => setTab(id)}
>
{id === 'overview' ? '계좌 개요' : id === 'positions' ? '보유종목' : '체결내역'}
</button>
))}
</nav>
<main className="ptd-col ptd-col--center">
<div className="ptd-card ptd-chart-card">
<PaperMiniChart market={selectedMarket} />
<PaperIndicatorPanel market={selectedMarket} />
</div>
</main>
<div className="paper-panel">
{showPanelLoading && <p className="paper-muted"> ...</p>}
<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>
{!showPanelLoading && tab === 'overview' && s && (
<dl className="paper-dl">
<dt> </dt><dd>{fmtKrw(s.initialCapital)} KRW</dd>
<dt></dt><dd>{s.feeRatePct}%</dd>
<dt></dt><dd>{s.slippagePct}%</dd>
<dt> </dt><dd>{fmtKrw(s.minOrderKrw)} KRW</dd>
<dt></dt>
<dd>{s.autoTradeEnabled ? `ON (매수 시 가용현금 ${s.autoTradeBudgetPct}%)` : 'OFF (수동 매매)'}</dd>
<dt> </dt><dd>{s.positions.length}</dd>
</dl>
)}
{!showPanelLoading && tab === 'positions' && (
<table className="paper-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{(s?.positions ?? []).length === 0 ? (
<tr><td colSpan={7} className="paper-muted"> .</td></tr>
) : s!.positions.map(p => (
<tr key={p.symbol}>
<td>
<strong>{getKoreanName(p.symbol)}</strong>
<span className="paper-sym">{p.symbol}</span>
</td>
<td>{p.quantity.toFixed(8).replace(/\.?0+$/, '')}</td>
<td>{fmtKrw(p.avgPrice)}</td>
<td>{p.markPrice != null ? fmtKrw(p.markPrice) : '—'}</td>
<td>{p.evalAmount != null ? fmtKrw(p.evalAmount) : '—'}</td>
<td className={(p.profitLoss ?? 0) >= 0 ? 'up' : 'down'}>
{p.profitLoss != null ? `${fmtKrw(p.profitLoss)} (${(p.profitLossPct ?? 0).toFixed(2)}%)` : '—'}
</td>
<td>
{onGoChart && (
<button type="button" className="paper-link" onClick={() => onGoChart(p.symbol)}>
</button>
<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>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{!showPanelLoading && tab === 'trades' && (
<table className="paper-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> </th>
</tr>
</thead>
<tbody>
{trades.length === 0 ? (
<tr><td colSpan={8} className="paper-muted"> .</td></tr>
) : trades.map(t => (
<tr key={t.id}>
<td className="paper-time">{t.createdAt?.replace('T', ' ').slice(0, 19) ?? '—'}</td>
<td>{getKoreanName(t.symbol)}</td>
<td className={t.side === 'BUY' ? 'up' : 'down'}>{t.side}</td>
<td>{t.source === 'STRATEGY' ? '전략' : '수동'}</td>
<td>{fmtKrw(t.price)}</td>
<td>{t.quantity.toFixed(6).replace(/\.?0+$/, '')}</td>
<td>{fmtKrw(t.feeAmount)}</td>
<td>{fmtKrw(t.cashAfter)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
) : (
<PaperCompactOrderbook market={selectedMarket} onPick={handleObPick} fillHeight hideHeader />
)}
</div>
</div>
</aside>
</div>
</div>
);
};
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;