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

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
+76 -217
View File
@@ -1,10 +1,7 @@
/**
* BacktestHistoryPage
* ─────────────────────────────────────────────────────
* [좌측] 체크박스 선택 + 삭제 가능한 이력 목록
* [우측] 카드 기반 대시보드 결과 뷰
* BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드)
*/
import React, { useEffect, useState, useCallback } from 'react';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import {
loadBacktestResults,
deleteBacktestResult,
@@ -12,147 +9,75 @@ import {
type BacktestAnalysis,
type BacktestSignal,
} from '../utils/backendApi';
import { BacktestDashboard } from './BacktestResultModal';
// ── 유틸 ──────────────────────────────────────────────────────────────────
import { buildEquityFromSignals } from '../utils/backtestEquity';
import { BacktestResultDashboard } from './backtest/BacktestResultDashboard';
import BacktestSparkline from './backtest/BacktestSparkline';
const pct = (v: number) =>
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '';
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%';
const fmtDateTime = (iso: string) => {
const fmtDate = (iso: string) => {
const d = new Date(iso);
return {
date: `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`,
time: `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`,
};
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
// ── 아이콘 ────────────────────────────────────────────────────────────────
const IcTrash = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<polyline points="1,3 13,3"/><line x1="5" y1="3" x2="5" y2="1"/><line x1="9" y1="3" x2="9" y2="1"/>
<line x1="6.5" y1="1" x2="7.5" y2="1"/>
<path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
<line x1="5.5" y1="6" x2="5.5" y2="10"/><line x1="7" y1="6" x2="7" y2="10"/><line x1="8.5" y1="6" x2="8.5" y2="10"/>
</svg>
);
const IcTrashAll = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<polyline points="1,3 13,3"/><path d="M2.5 3 L3 13 L11 13 L11.5 3"/>
<line x1="5.5" y1="6" x2="5.5" y2="10"/>
<line x1="8.5" y1="6" x2="8.5" y2="10"/>
<line x1="4" y1="1.5" x2="10" y2="1.5" strokeDasharray="1.5 1"/>
</svg>
);
const IcRefresh = () => (
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round">
<path d="M12 7 A5 5 0 1 1 9.5 2.5"/>
<polyline points="10,1 10,4 13,4"/>
</svg>
);
// ── 목록 아이템 ────────────────────────────────────────────────────────────
interface ListItemProps {
r: BacktestResultRecord;
checked: boolean;
active: boolean;
onToggle: () => void;
onClick: () => void;
function parseDetail(r: BacktestResultRecord) {
let analysis: BacktestAnalysis | null = null;
let signals: BacktestSignal[] = [];
try {
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
} catch { /* ignore */ }
return { analysis, signals };
}
function ListItem({ r, checked, active, onToggle, onClick }: ListItemProps) {
const ret = r.totalReturn ?? 0;
const dt = fmtDateTime(r.createdAt);
return (
<div
className={`bh-item ${active ? 'bh-item--active' : ''} ${checked ? 'bh-item--checked' : ''}`}
onClick={onClick}
>
<div className="bh-item-cb-wrap" onClick={e => { e.stopPropagation(); onToggle(); }}>
<div className={`bh-cb ${checked ? 'bh-cb--on' : ''}`}>
{checked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
</div>
</div>
interface TimelineItemProps {
r: BacktestResultRecord;
active: boolean;
onClick: () => void;
}
<div className="bh-item-body">
<div className="bh-item-row1">
<span className="bh-item-name">{r.strategyName || '전략 없음'}</span>
<span className={`bh-item-ret ${ret > 0 ? 'bh-pos' : ret < 0 ? 'bh-neg' : ''}`}>{pct(ret)}</span>
</div>
<div className="bh-item-row2">
<span className="bh-item-symbol">{r.symbol}</span>
<span className="bh-item-sep">·</span>
<span className="bh-item-tf">{r.timeframe}</span>
<span className="bh-item-sep">·</span>
<span className="bh-item-trades">{r.totalTrades}</span>
</div>
<div className="bh-item-row3">
<span className="bh-item-date">{dt.date}</span>
<span className="bh-item-time">{dt.time}</span>
{(r.winRate ?? 0) > 0 && (
<span className="bh-item-wr"> {((r.winRate ?? 0) * 100).toFixed(0)}%</span>
)}
function TimelineItem({ r, active, onClick }: TimelineItemProps) {
const ret = r.totalReturn ?? 0;
const positive = ret >= 0;
const sparkCurve = useMemo(() => {
const { analysis, signals } = parseDetail(r);
const cap = analysis?.initialCapital ?? 10_000_000;
return buildEquityFromSignals(signals, cap, r.symbol.replace(/^KRW-/, '')).curve;
}, [r]);
return (
<button type="button" className={`btd-timeline-item${active ? ' btd-timeline-item--active' : ''}`} onClick={onClick}>
<span className="btd-timeline-node" />
<div className="btd-timeline-card">
<div className="btd-timeline-top">
<span className="btd-timeline-name">{r.strategyName || '전략 없음'}</span>
<BacktestSparkline curve={sparkCurve} positive={positive} />
</div>
<span className="btd-timeline-date">{fmtDate(r.createdAt)}</span>
<span className={`btd-timeline-roi${positive ? ' up' : ' down'}`}>
ROI {pct(ret)}
</span>
</div>
</div>
</button>
);
}
// ── 메인 페이지 ───────────────────────────────────────────────────────────
export function BacktestHistoryPage() {
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
const [checked, setChecked] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<BacktestResultRecord[]>([]);
const [selected, setSelected] = useState<BacktestResultRecord | null>(null);
const [loading, setLoading] = useState(true);
// ── 로드 ────────────────────────────────────────────────────────────────
const fetchList = useCallback(async () => {
setLoading(true);
const list = await loadBacktestResults();
setRecords(list);
if (list.length > 0) setSelected(prev => prev ?? list[0]);
setSelected(prev => (prev && list.some(x => x.id === prev.id) ? prev : list[0] ?? null));
setLoading(false);
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
// ── 체크박스 ──────────────────────────────────────────────────────────
const toggleCheck = useCallback((id: number) => {
setChecked(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleAll = useCallback(() => {
setChecked(prev =>
prev.size === records.length
? new Set()
: new Set(records.map(r => r.id))
);
}, [records]);
const allChecked = records.length > 0 && checked.size === records.length;
const someChecked = checked.size > 0 && !allChecked;
// ── 삭제 ─────────────────────────────────────────────────────────────
const deleteSelected = useCallback(async () => {
if (checked.size === 0) return;
if (!window.confirm(`선택한 ${checked.size}개 백테스팅 결과를 삭제하시겠습니까?`)) return;
await Promise.all([...checked].map(id => deleteBacktestResult(id)));
setRecords(prev => {
const next = prev.filter(r => !checked.has(r.id));
if (selected && checked.has(selected.id)) setSelected(next[0] ?? null);
return next;
});
setChecked(new Set());
}, [checked, selected]);
useEffect(() => { void fetchList(); }, [fetchList]);
const deleteAll = useCallback(async () => {
if (records.length === 0) return;
@@ -160,95 +85,37 @@ export function BacktestHistoryPage() {
await Promise.all(records.map(r => deleteBacktestResult(r.id)));
setRecords([]);
setSelected(null);
setChecked(new Set());
}, [records]);
// ── 상세 데이터 파싱 ──────────────────────────────────────────────────
const getDetail = (r: BacktestResultRecord) => {
let analysis: BacktestAnalysis | null = null;
let signals: BacktestSignal[] = [];
try {
if (r.analysisJson) analysis = JSON.parse(r.analysisJson);
if (r.signalsJson) signals = JSON.parse(r.signalsJson);
} catch (_) {}
return { analysis, signals };
};
const selectedDetail = selected ? parseDetail(selected) : null;
// ── 렌더 ─────────────────────────────────────────────────────────────
return (
<div className="bh-page">
{/* ── 좌측 목록 ── */}
<aside className="bh-sidebar">
{/* 사이드바 헤더 */}
<div className="bh-sidebar-header">
<div className="bh-sidebar-title-row">
<span className="bh-sidebar-title"> </span>
<span className="bh-sidebar-count">{records.length}</span>
</div>
<div className="bh-sidebar-actions">
<button
className="bh-act-btn"
title="선택 항목 삭제"
disabled={checked.size === 0}
onClick={deleteSelected}
>
<IcTrash />
{checked.size > 0 && <span className="bh-act-badge">{checked.size}</span>}
</button>
<button
className="bh-act-btn bh-act-btn--danger"
title="전체 삭제"
disabled={records.length === 0}
onClick={deleteAll}
>
<IcTrashAll />
</button>
<button
className="bh-act-btn"
title="새로고침"
onClick={fetchList}
>
<IcRefresh />
</button>
<div className="btd-page">
<aside className="btd-sidebar">
<div className="btd-sidebar-head">
<span className="btd-sidebar-bar" />
<h2 className="btd-sidebar-title"> </h2>
<div className="btd-sidebar-actions">
<button type="button" className="btd-icon-btn" title="새로고침" onClick={() => void fetchList()}></button>
<button type="button" className="btd-icon-btn btd-icon-btn--danger" title="전체 삭제" disabled={records.length === 0} onClick={() => void deleteAll()}>🗑</button>
</div>
</div>
{/* 전체 선택 바 */}
{records.length > 0 && (
<div className="bh-select-all-bar" onClick={toggleAll}>
<div className={`bh-cb ${allChecked ? 'bh-cb--on' : someChecked ? 'bh-cb--indeterminate' : ''}`}>
{allChecked && <svg width="8" height="6" viewBox="0 0 8 6" fill="none" stroke="currentColor" strokeWidth="1.8"><polyline points="1,3 3,5 7,1"/></svg>}
{someChecked && <div className="bh-cb-dash"/>}
</div>
<span className="bh-select-all-label">
{someChecked || allChecked ? `${checked.size}개 선택됨` : '전체 선택'}
</span>
</div>
)}
{/* 목록 */}
{loading ? (
<div className="bh-loading">
<div className="bh-loading-spinner"/>
<span> </span>
</div>
<div className="btd-sidebar-empty"> </div>
) : records.length === 0 ? (
<div className="bh-empty-list">
<div className="bh-empty-icon">📊</div>
<div className="btd-sidebar-empty">
<p> .</p>
<p className="bh-empty-sub"> <br/> .</p>
<p className="btd-sidebar-hint"> <br /> .</p>
</div>
) : (
<div className="bh-list">
<div className="btd-timeline">
<span className="btd-timeline-line" />
{records.map(r => (
<ListItem
<TimelineItem
key={r.id}
r={r}
checked={checked.has(r.id)}
active={selected?.id === r.id}
onToggle={() => toggleCheck(r.id)}
onClick={() => setSelected(r)}
/>
))}
@@ -256,28 +123,20 @@ export function BacktestHistoryPage() {
)}
</aside>
{/* ── 우측 대시보드 ── */}
<main className="bh-content">
{selected ? (
(() => {
const { analysis, signals } = getDetail(selected);
return (
<div className="bh-detail">
<BacktestDashboard
analysis={analysis}
signals={signals}
strategyName={selected.strategyName}
symbol={selected.symbol}
timeframe={selected.timeframe}
barCount={selected.barCount}
createdAt={selected.createdAt}
/>
</div>
);
})()
<main className="btd-main">
{selected && selectedDetail ? (
<BacktestResultDashboard
analysis={selectedDetail.analysis}
signals={selectedDetail.signals}
strategyName={selected.strategyName}
symbol={selected.symbol}
timeframe={selected.timeframe}
barCount={selected.barCount}
createdAt={selected.createdAt}
/>
) : (
<div className="bh-no-selection">
<div className="bh-no-sel-icon">📈</div>
<div className="btd-empty">
<span>📈</span>
<p> .</p>
</div>
)}
+32 -6
View File
@@ -12,6 +12,9 @@ import {
saveLiveStrategySettings,
type LiveStrategySettingsDto,
} from '../utils/backendApi';
import { getKoreanName } from '../utils/marketNameCache';
const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const;
interface Strategy {
id: number;
@@ -91,6 +94,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const monCount = monitoredMarkets.length;
const selectedStrategy = strategies.find(s => s.id === stratId);
const candleType = settings.candleType ?? '1m';
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
? '보유 자산 기준'
@@ -156,16 +160,24 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
<span className="lsp-info-label"></span>
<span className="lsp-info-val lsp-monitor-chips">
{monitoredMarkets.slice(0, 8).map(m => (
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
<span key={m} className="lsp-monitor-chip" title={m}>
{getKoreanName(m)}
</span>
))}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
</span>
</div>
{isOn && selectedStrategy && (
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{selectedStrategy.name}</span>
</div>
<>
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{selectedStrategy.name}</span>
</div>
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{candleType}</span>
</div>
</>
)}
</div>
</div>
@@ -199,6 +211,20 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
</select>
</div>
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
<span className="lsp-field-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
value={candleType}
onChange={e => persist({ candleType: e.target.value })}
>
{CANDLE_TYPES.map(ct => (
<option key={ct} value={ct}>{ct}</option>
))}
</select>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
<div className="lsp-option-group">
@@ -276,7 +302,7 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
<span className="lsp-status-text">
{stratId
? `${selectedStrategy?.name ?? '전략'} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
? `${selectedStrategy?.name ?? '전략'} · ${candleType} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
: '전략을 선택하세요'}
</span>
</div>
+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;
+24 -17
View File
@@ -147,6 +147,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevBarsKey = useRef<string>('');
const prevIndKey = useRef<string>('');
const prevSortedPKRef = useRef<string>(''); // 순서 무관 paramKey (reorder 감지용)
const prevMarket = useRef<string>(market);
const prevMarketTf = useRef<Timeframe>(timeframe);
const prevChartType = useRef<ChartType>(chartType);
const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale);
@@ -252,6 +254,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
}, [candleOnlyMode, chartMgr, applyPaneLayout]);
// ── 전체 재로드 (데이터 + 인디케이터) ─────────────────────────────────────
const reloadSafetyTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const reloadAll = useCallback(async (
mgr: ChartManager,
newBars: OHLCVBar[],
@@ -262,11 +266,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
) => {
if (newBars.length === 0) return;
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
barsRef.current = newBars;
prevChartType.current = ct;
prevTheme.current = th;
prevLogScale.current = ls;
prevBarsKey.current = barsKey(newBars);
prevBarsKey.current = barsKey(newBars, market);
prevIndKey.current = indKey(inds);
prevSortedPKRef.current = sortedParamKey(inds);
@@ -275,15 +282,12 @@ const TradingChart: React.FC<TradingChartProps> = ({
mgr.setLogScale(ls);
mgr.setDisplayTimezone(displayTimezone, timeframe);
// 인디케이터를 순차적으로 등록 (async/await 보장)
for (const ind of inds) {
await mgr.addIndicator(ind);
}
// 인디케이터 추가 완료 후 RAF × 2 대기
// → LWC 가 모든 pane 을 DOM 에 확정한 뒤 높이·X축 재설정
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => {
applyPaneLayout(mgr); // ① pane 높이 재배분 (wrapper 높이 기준, 0이면 자동 재시도)
applyPaneLayout(mgr);
requestAnimationFrame(() => {
const futureBars = mgr.hasIchimoku() ? 28 : 0;
mgr.setInitialVisibleRange(DISPLAY_COUNT, futureBars);
@@ -291,12 +295,9 @@ const TradingChart: React.FC<TradingChartProps> = ({
});
})));
// 데이터 로드 완료 알림: 멀티차트 sync range 재적용 등 외부 콜백 처리용
onDataLoaded?.();
// ── 안전망: 멀티레이아웃에서 CSS Grid 높이 확정이 늦어진 경우를 위한 지연 재적용
// applyPaneLayout 이 이미 재시도 중이지만, setInitialVisibleRange 도 재실행 필요할 수 있음
const safetyTimers = [300, 700, 1400].map(delay =>
reloadSafetyTimers.current = [300, 700, 1400].map(delay =>
setTimeout(() => {
const m = managerRef.current;
if (!m || !m.hasMainSeries()) return;
@@ -307,10 +308,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
m.setInitialVisibleRange(DISPLAY_COUNT, fb);
}, delay)
);
// cleanup 시 타이머 해제 (컴포넌트 언마운트 대응)
// 반환값이 없으므로 managerRef 체크로 충분
void safetyTimers; // 타이머는 managerRef null 체크로 자동 무효화됨
}, [applyPaneLayout, onDataLoaded]);
}, [applyPaneLayout, onDataLoaded, displayTimezone, timeframe, market]);
// ── 차트 초기화 (마운트 시 1회) ──────────────────────────────────────────
useEffect(() => {
@@ -592,6 +590,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
clearTimeout(drawingSingleTimerRef.current);
drawingSingleTimerRef.current = null;
}
reloadSafetyTimers.current.forEach(clearTimeout);
reloadSafetyTimers.current = [];
ro.disconnect();
managerRef.current?.destroy();
managerRef.current = null;
@@ -604,6 +604,14 @@ const TradingChart: React.FC<TradingChartProps> = ({
managerRef.current?.setDisplayTimezone(displayTimezone, timeframe);
}, [displayTimezone, timeframe]);
// 종목·타임프레임 변경 시 barsKey 캐시만 초기화 (reloadAll 은 bars effect 에서 1회만)
useEffect(() => {
if (prevMarket.current === market && prevMarketTf.current === timeframe) return;
prevMarket.current = market;
prevMarketTf.current = timeframe;
prevBarsKey.current = '';
}, [market, timeframe]);
// ── 데이터/인디케이터 동기화 ─────────────────────────────────────────────
useEffect(() => {
const mgr = managerRef.current;
@@ -615,7 +623,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
return;
}
const bk = barsKey(bars);
const bk = barsKey(bars, market);
const ik = indKey(indicators);
const barsChanged = bk !== prevBarsKey.current;
const indChanged = ik !== prevIndKey.current;
@@ -905,11 +913,10 @@ const PaneLegendPortal: React.FC<
};
// ── 변경 감지용 키 생성 헬퍼 ────────────────────────────────────────────────
function barsKey(bars: OHLCVBar[]): string {
function barsKey(bars: OHLCVBar[], market = ''): string {
if (bars.length === 0) return '';
const last = bars[bars.length - 1];
// 종목이 달라도 시간 범위·개수가 같을 수 있으므로 마지막 close 가격도 포함
return `${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
return `${market}:${bars.length}:${bars[0].time}:${last.time}:${last.close}`;
}
/**
@@ -0,0 +1,165 @@
import React, { memo, useMemo } from 'react';
import type { EquityPoint, TradeMarker } from '../../utils/backtestEquity';
interface Props {
curve: EquityPoint[];
markers: TradeMarker[];
}
function fmtDate(ts: number): string {
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtMonth(ts: number): string {
const d = new Date(ts * 1000);
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return `${months[d.getMonth()]} ${d.getFullYear()}`;
}
interface LineSegment {
d: string;
up: boolean;
}
const BacktestAssetChart: React.FC<Props> = memo(({ curve, markers }) => {
const W = 800;
const H = 280;
const pad = { t: 22, r: 14, b: 26, l: 48 };
const innerW = W - pad.l - pad.r;
const innerH = H - pad.t - pad.b;
const chart = useMemo(() => {
if (curve.length < 2) {
const y = pad.t + innerH * 0.5;
return {
areaPath: `M${pad.l},${y} L${pad.l + innerW},${y} L${pad.l + innerW},${pad.t + innerH} L${pad.l},${pad.t + innerH} Z`,
segments: [] as LineSegment[],
yTicks: [0, 1],
xLabels: [] as { x: number; label: string }[],
markerPts: [] as Array<{ x: number; y: number; m: TradeMarker }>,
toY: (_e: number) => y,
};
}
const times = curve.map(p => p.time);
const equities = curve.map(p => p.equity);
const tMin = Math.min(...times);
const tMax = Math.max(...times);
const eMin = Math.min(...equities) * 0.985;
const eMax = Math.max(...equities) * 1.015;
const tRange = tMax - tMin || 1;
const eRange = eMax - eMin || 1;
const toX = (t: number) => pad.l + ((t - tMin) / tRange) * innerW;
const toY = (e: number) => pad.t + innerH - ((e - eMin) / eRange) * innerH;
const linePath = curve.map((p, i) => `${i === 0 ? 'M' : 'L'}${toX(p.time).toFixed(1)},${toY(p.equity).toFixed(1)}`).join(' ');
const areaPath = `${linePath} L${toX(curve[curve.length - 1].time).toFixed(1)},${pad.t + innerH} L${toX(curve[0].time).toFixed(1)},${pad.t + innerH} Z`;
const segments: LineSegment[] = [];
for (let i = 1; i < curve.length; i++) {
segments.push({
d: `M${toX(curve[i - 1].time).toFixed(1)},${toY(curve[i - 1].equity).toFixed(1)} L${toX(curve[i].time).toFixed(1)},${toY(curve[i].equity).toFixed(1)}`,
up: curve[i].equity >= curve[i - 1].equity,
});
}
const yTicks = [0, 0.25, 0.5, 0.75, 1].map(r => eMin + eRange * r);
const xLabels: { x: number; label: string }[] = [];
const step = Math.max(1, Math.floor(curve.length / 5));
for (let i = 0; i < curve.length; i += step) {
xLabels.push({ x: toX(curve[i].time), label: i === 0 ? fmtMonth(curve[i].time) : fmtDate(curve[i].time) });
}
const last = curve[curve.length - 1];
if (!xLabels.some(l => l.label === fmtDate(last.time))) {
xLabels.push({ x: toX(last.time), label: fmtDate(last.time) });
}
const markerPts = markers.map(m => ({ x: toX(m.time), y: toY(m.equity), m }));
return { areaPath, segments, yTicks, xLabels, markerPts, toY };
}, [curve, markers, innerH, innerW, pad.l, pad.t]);
const fmtY = (v: number) => {
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
if (v >= 10_000) return `${Math.round(v / 10_000)}`;
return Math.round(v).toLocaleString();
};
return (
<div className="btd-chart-wrap">
<svg className="btd-chart-svg" viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none">
<defs>
<linearGradient id="btd-area-grad-up" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(34,197,94,0.28)" />
<stop offset="100%" stopColor="rgba(34,197,94,0.02)" />
</linearGradient>
</defs>
{[0.25, 0.5, 0.75].map(r => {
const y = pad.t + innerH * (1 - r);
return <line key={r} x1={pad.l} y1={y} x2={W - pad.r} y2={y} stroke="rgba(201,162,39,0.07)" strokeWidth="1" />;
})}
<text x={pad.l - 8} y={pad.t - 6} textAnchor="start" fill="#6272a4" fontSize="8"></text>
{chart.yTicks.map(v => (
<text
key={v}
x={pad.l - 4}
y={chart.toY(v)}
textAnchor="end"
dominantBaseline="middle"
fill="#6272a4"
fontSize="8.5"
>
{fmtY(v)}
</text>
))}
<path d={chart.areaPath} fill="url(#btd-area-grad-up)" />
{chart.segments.map((seg, i) => (
<path
key={i}
d={seg.d}
fill="none"
stroke={seg.up ? '#22c55e' : '#ef4444'}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
))}
{chart.markerPts.map(({ x, y, m }, i) => (
<g key={`${m.time}-${m.type}-${i}`}>
{m.type === 'buy' ? (
<>
<polygon points={`${x},${y - 9} ${x - 4},${y - 2} ${x + 4},${y - 2}`} fill="#22c55e" />
<text x={x} y={y - 12} textAnchor="middle" fill="#22c55e" fontSize="7.5" fontWeight="700">
{fmtDate(m.time).slice(5)}
</text>
</>
) : (
<>
<polygon points={`${x},${y + 9} ${x - 4},${y + 2} ${x + 4},${y + 2}`} fill="#ef4444" />
<text x={x} y={y + 20} textAnchor="middle" fill={m.pnlPct != null && m.pnlPct >= 0 ? '#22c55e' : '#ef4444'} fontSize="7.5" fontWeight="700">
{m.pnlPct != null ? `${m.pnlPct >= 0 ? '+' : ''}${(m.pnlPct * 100).toFixed(1)}%` : '매도'}
</text>
</>
)}
</g>
))}
{chart.xLabels.map((l, i) => (
<text key={i} x={l.x} y={H - 5} textAnchor="middle" fill="#6272a4" fontSize="8">
{l.label}
</text>
))}
</svg>
</div>
);
});
BacktestAssetChart.displayName = 'BacktestAssetChart';
export default BacktestAssetChart;
@@ -0,0 +1,165 @@
/**
* BacktestResultDashboard — 첨부 이미지 스타일 백테스팅 결과 대시보드
*/
import React, { useMemo } from 'react';
import type { BacktestAnalysis, BacktestSignal } from '../../utils/backendApi';
import { buildEquityFromSignals, type TradeHistoryRow } from '../../utils/backtestEquity';
import BacktestAssetChart from './BacktestAssetChart';
export interface BacktestResultDashboardProps {
analysis: BacktestAnalysis | null;
signals: BacktestSignal[];
strategyName: string;
symbol: string;
timeframe: string;
barCount: number;
createdAt?: string;
}
const pct = (v: number, dec = 1) =>
isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(dec)}%` : '0.0%';
const pctAbs = (v: number, dec = 0) =>
isFinite(v) ? `${(v * 100).toFixed(dec)}%` : '';
const num = (v: number, dec = 2) =>
isFinite(v) ? v.toFixed(dec) : '';
function fmtDateTime(ts: number): string {
const d = new Date(ts * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function SectionTitle({ title, sub }: { title: string; sub?: string }) {
return (
<div className="btd-section-head">
<span className="btd-section-bar" />
<div>
<h2 className="btd-section-title">{title}</h2>
{sub && <p className="btd-section-sub">{sub}</p>}
</div>
</div>
);
}
export function BacktestResultDashboard({
analysis,
signals,
strategyName: _strategyName,
symbol,
timeframe: _timeframe,
barCount: _barCount,
createdAt: _createdAt,
}: BacktestResultDashboardProps) {
const a = analysis;
const { curve, markers, trades } = useMemo(() => {
if (!a) return { curve: [], markers: [], trades: [] };
return buildEquityFromSignals(signals, a.initialCapital, symbol.startsWith('KRW-') ? symbol : `KRW-${symbol.replace(/^KRW-/, '')}`);
}, [a, signals, symbol]);
if (!a) {
return (
<div className="btd-empty">
<span>📊</span>
<p> .</p>
</div>
);
}
const profitFactor = a.profitLossRatio > 0 ? a.profitLossRatio : (
a.grossLoss !== 0 ? Math.abs(a.grossProfit / a.grossLoss) : 0
);
return (
<div className="btd-dashboard">
<div className="btd-section btd-section--kpi">
<SectionTitle title="핵심 성과 지표" sub="Core KPIs" />
<div className="btd-kpi-grid">
<div className="btd-kpi-card">
<div className="btd-kpi-label"> (ROI)</div>
<div className={`btd-kpi-value ${a.totalReturnPct >= 0 ? 'up' : 'down'}`}>
{pct(a.totalReturnPct)}
</div>
<p className="btd-kpi-desc"> </p>
</div>
<div className="btd-kpi-card">
<div className="btd-kpi-label"> (MDD)</div>
<div className={`btd-kpi-value ${a.maxDrawdownPct <= 0 ? 'down' : ''}`}>
{pct(a.maxDrawdownPct)}
</div>
<p className="btd-kpi-desc"> </p>
</div>
<div className="btd-kpi-card btd-kpi-card--sharpe">
<div className="btd-kpi-label"> (Sharpe Ratio)</div>
<div className="btd-kpi-row">
<span className={`btd-kpi-value ${a.sharpeRatio >= 1 ? 'up' : a.sharpeRatio >= 0 ? 'neutral' : 'down'}`}>{num(a.sharpeRatio)}</span>
<span className="btd-kpi-icon" aria-hidden></span>
</div>
<p className="btd-kpi-desc"> </p>
</div>
<div className="btd-kpi-card btd-kpi-card--dual">
<div className="btd-kpi-label"> Profit Factor</div>
<div className="btd-kpi-dual">
<div className="btd-kpi-value up">{pctAbs(a.winRate)}</div>
<div className="btd-kpi-divider" />
<div className="btd-kpi-value gold">{num(profitFactor)}</div>
</div>
</div>
</div>
</div>
<div className="btd-section btd-section--chart">
<SectionTitle title="자산 곡선 및 차트 시각화" sub="Asset Curve" />
<div className="btd-chart-card">
<BacktestAssetChart curve={curve} markers={markers} />
</div>
</div>
<div className="btd-section btd-section--table">
<SectionTitle title="상세 거래 내역" sub="Trade History Table" />
<div className="btd-table-card">
<div className="btd-table-scroll">
<table className="btd-table">
<thead>
<tr>
<th> </th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> (%)</th>
</tr>
</thead>
<tbody>
{trades.length === 0 ? (
<tr>
<td colSpan={6} className="btd-table-empty"> .</td>
</tr>
) : trades.map((t: TradeHistoryRow) => (
<tr key={t.id}>
<td className="btd-td-time">{fmtDateTime(t.time)}</td>
<td>{t.symbol.startsWith('KRW-') ? t.symbol : `KRW-${t.symbol}`}</td>
<td className={t.side === 'buy' ? 'buy' : 'sell'}>
{t.side === 'buy' ? '매수' : '매도'}
</td>
<td>{Math.round(t.price).toLocaleString()}</td>
<td>{t.quantity.toFixed(4)}</td>
<td className={t.pnlPct != null ? (t.pnlPct >= 0 ? 'up' : 'down') : ''}>
{t.pnlPct != null ? pct(t.pnlPct) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
export default BacktestResultDashboard;
@@ -0,0 +1,35 @@
import React, { memo, useMemo } from 'react';
import { equityToSparkline, sparklinePath } from '../../utils/backtestEquity';
import type { EquityPoint } from '../../utils/backtestEquity';
interface Props {
curve: EquityPoint[];
positive?: boolean;
width?: number;
height?: number;
}
const BacktestSparkline: React.FC<Props> = memo(({
curve,
positive = true,
width = 72,
height = 28,
}) => {
const path = useMemo(() => {
const values = equityToSparkline(curve);
return sparklinePath(values, width, height);
}, [curve, width, height]);
const stroke = positive ? '#22c55e' : '#ef4444';
const fill = positive ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.12)';
return (
<svg className="btd-sparkline" width={width} height={height} viewBox={`0 0 ${width} ${height}`}>
<path d={`${path} L${width},${height} L0,${height} Z`} fill={fill} stroke="none" />
<path d={path} fill="none" stroke={stroke} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
});
BacktestSparkline.displayName = 'BacktestSparkline';
export default BacktestSparkline;
@@ -0,0 +1,76 @@
import React, { memo } from 'react';
import { useUpbitOrderbook } from '../../hooks/useUpbitOrderbook';
interface Props {
market: string;
onPick?: (price: number, rowType: 'ask' | 'bid') => void;
fillHeight?: boolean;
hideHeader?: boolean;
depth?: number;
}
function fmtPrice(p: number): string {
if (!Number.isFinite(p)) return '-';
return p >= 1000 ? p.toLocaleString('ko-KR', { maximumFractionDigits: 0 }) : p.toFixed(4);
}
function fmtSize(s: number): string {
if (!Number.isFinite(s)) return '-';
if (s >= 1000) return s.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
return s.toFixed(4);
}
const PaperCompactOrderbook: React.FC<Props> = memo(({
market,
onPick,
fillHeight = false,
hideHeader = false,
depth = 8,
}) => {
const { orderbook } = useUpbitOrderbook(market);
const rowCount = fillHeight ? Math.max(depth, 12) : depth;
const asks = orderbook.asks.slice(0, rowCount).reverse();
const bids = orderbook.bids.slice(0, rowCount);
const maxSize = Math.max(
...asks.map(a => a.size),
...bids.map(b => b.size),
1,
);
const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0;
return (
<div className={`ptd-ob${fillHeight ? ' ptd-ob--fill' : ''}`}>
{!hideHeader && <div className="ptd-ob-head"> (Depth)</div>}
<div className="ptd-ob-body">
{asks.map(a => (
<button
key={`a-${a.price}`}
type="button"
className="ptd-ob-row ptd-ob-row--ask"
onClick={() => onPick?.(a.price, 'ask')}
>
<span className="ptd-ob-bar" style={{ width: `${(a.size / maxSize) * 100}%` }} />
<span className="ptd-ob-price">{fmtPrice(a.price)}</span>
<span className="ptd-ob-size">{fmtSize(a.size)}</span>
</button>
))}
<div className="ptd-ob-mid">{mid ? fmtPrice(mid) : '—'}</div>
{bids.map(b => (
<button
key={`b-${b.price}`}
type="button"
className="ptd-ob-row ptd-ob-row--bid"
onClick={() => onPick?.(b.price, 'bid')}
>
<span className="ptd-ob-bar" style={{ width: `${(b.size / maxSize) * 100}%` }} />
<span className="ptd-ob-price">{fmtPrice(b.price)}</span>
<span className="ptd-ob-size">{fmtSize(b.size)}</span>
</button>
))}
</div>
</div>
);
});
PaperCompactOrderbook.displayName = 'PaperCompactOrderbook';
export default PaperCompactOrderbook;
@@ -0,0 +1,110 @@
import React, { useMemo } from 'react';
import { fetchUpbitCandles } from '../../utils/upbitApi';
import type { Timeframe } from '../../types';
function computeRsi(closes: number[], period = 14): number[] {
if (closes.length < period + 1) return [];
const out: number[] = [];
let avgGain = 0;
let avgLoss = 0;
for (let i = 1; i <= period; i++) {
const d = closes[i] - closes[i - 1];
if (d >= 0) avgGain += d; else avgLoss -= d;
}
avgGain /= period;
avgLoss /= period;
for (let i = period; i < closes.length; i++) {
const d = closes[i] - closes[i - 1];
const gain = d > 0 ? d : 0;
const loss = d < 0 ? -d : 0;
avgGain = (avgGain * (period - 1) + gain) / period;
avgLoss = (avgLoss * (period - 1) + loss) / period;
const rs = avgLoss === 0 ? 100 : avgGain / avgLoss;
out.push(100 - 100 / (1 + rs));
}
return out;
}
function sparkPath(values: number[], w: number, h: number, min?: number, max?: number): string {
if (!values.length) return '';
const lo = min ?? Math.min(...values);
const hi = max ?? Math.max(...values);
const range = hi - lo || 1;
return values.map((v, i) => {
const x = (i / Math.max(values.length - 1, 1)) * w;
const y = h - ((v - lo) / range) * (h - 4) - 2;
return `${i === 0 ? 'M' : 'L'}${x.toFixed(1)},${y.toFixed(1)}`;
}).join(' ');
}
interface Props {
market: string;
timeframe?: Timeframe;
}
const PaperIndicatorPanel: React.FC<Props> = ({ market, timeframe = '1h' }) => {
const [rsi, setRsi] = React.useState<number[]>([]);
const [macd, setMacd] = React.useState<number[]>([]);
const [cci, setCci] = React.useState<number[]>([]);
React.useEffect(() => {
let cancelled = false;
(async () => {
try {
const bars = await fetchUpbitCandles(market, timeframe, 120);
if (cancelled) return;
const closes = bars.map(b => b.close);
setRsi(computeRsi(closes).slice(-40));
const ema = (arr: number[], p: number) => {
const k = 2 / (p + 1);
let v = arr[0];
return arr.map((x, i) => (i === 0 ? x : (v = x * k + v * (1 - k))));
};
const e12 = ema(closes, 12);
const e26 = ema(closes, 26);
setMacd(e12.slice(-40).map((v, i) => v - e26[closes.length - 40 + i]));
const tp = bars.map(b => (b.high + b.low + b.close) / 3);
const cciVals: number[] = [];
for (let i = 20; i < tp.length; i++) {
const slice = tp.slice(i - 20, i);
const sma = slice.reduce((a, b) => a + b, 0) / slice.length;
const md = slice.reduce((a, v) => a + Math.abs(v - sma), 0) / slice.length;
cciVals.push(md === 0 ? 0 : (tp[i] - sma) / (0.015 * md));
}
setCci(cciVals.slice(-40));
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [market, timeframe]);
const panels = useMemo(() => [
{ label: 'RSI', values: rsi, color: '#a78bfa', ref: [30, 70] },
{ label: 'MACD', values: macd, color: '#38bdf8' },
{ label: 'CCI', values: cci, color: '#fbbf24', ref: [-100, 100] },
], [rsi, macd, cci]);
return (
<div className="ptd-indicators">
{panels.map(p => (
<div key={p.label} className="ptd-ind-panel">
<div className="ptd-ind-label">{p.label}</div>
<svg viewBox="0 0 120 48" className="ptd-ind-svg" preserveAspectRatio="none">
{p.ref?.map((r, i) => {
const lo = p.ref![0];
const hi = p.ref![1];
const y = 48 - ((r - lo) / (hi - lo)) * 44 - 2;
return (
<line key={i} x1="0" y1={y} x2="120" y2={y} stroke="rgba(122,162,247,0.15)" strokeDasharray="2 2" />
);
})}
{p.values.length > 1 && (
<path d={sparkPath(p.values, 120, 48)} fill="none" stroke={p.color} strokeWidth="1.5" />
)}
</svg>
</div>
))}
</div>
);
};
export default PaperIndicatorPanel;
@@ -0,0 +1,114 @@
import React, { useEffect, useRef, useState } from 'react';
import { createChart, CandlestickSeries, type IChartApi, type ISeriesApi, type Time, ColorType } from 'lightweight-charts';
import type { Timeframe } from '../../types';
import { fetchUpbitCandles } from '../../utils/upbitApi';
const TF_OPTIONS: Timeframe[] = ['1m', '5m', '15m', '1h', '4h', '1D'];
interface Props {
market: string;
wsLabel?: string;
}
const PaperMiniChart: React.FC<Props> = ({ market, wsLabel = 'WebSocket <100ms' }) => {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const seriesRef = useRef<ISeriesApi<'Candlestick'> | null>(null);
const [timeframe, setTimeframe] = useState<Timeframe>('1h');
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!containerRef.current) return undefined;
const chart = createChart(containerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#121520' },
textColor: '#9aa5ce',
},
grid: {
vertLines: { color: 'rgba(122,162,247,0.06)' },
horzLines: { color: 'rgba(122,162,247,0.06)' },
},
rightPriceScale: { borderColor: 'rgba(122,162,247,0.12)' },
timeScale: { borderColor: 'rgba(122,162,247,0.12)' },
crosshair: { mode: 1 },
});
const series = chart.addSeries(CandlestickSeries, {
upColor: '#22c55e',
downColor: '#ef4444',
borderUpColor: '#22c55e',
borderDownColor: '#ef4444',
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
});
chartRef.current = chart;
seriesRef.current = series;
const ro = new ResizeObserver(() => {
if (containerRef.current) {
chart.applyOptions({
width: containerRef.current.clientWidth,
height: containerRef.current.clientHeight,
});
}
});
ro.observe(containerRef.current);
return () => {
ro.disconnect();
chart.remove();
chartRef.current = null;
seriesRef.current = null;
};
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
(async () => {
try {
const bars = await fetchUpbitCandles(market, timeframe, 200);
if (cancelled || !seriesRef.current) return;
seriesRef.current.setData(
bars.map(b => ({
time: b.time as Time,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
})),
);
chartRef.current?.timeScale().fitContent();
} catch {
/* ignore */
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [market, timeframe]);
return (
<div className="ptd-chart-wrap">
<div className="ptd-chart-head">
<span className="ptd-chart-title"> </span>
<span className="ptd-ws-badge">{wsLabel} </span>
</div>
<div ref={containerRef} className="ptd-chart-canvas" />
{loading && <div className="ptd-chart-loading"> </div>}
<div className="ptd-tf-row">
{TF_OPTIONS.map(tf => (
<button
key={tf}
type="button"
className={`ptd-tf-btn${timeframe === tf ? ' ptd-tf-btn--active' : ''}`}
onClick={() => setTimeframe(tf)}
>
{tf}
</button>
))}
</div>
</div>
);
};
export default PaperMiniChart;