400 lines
19 KiB
TypeScript
400 lines
19 KiB
TypeScript
/**
|
||
* BacktestResultModal / BacktestDashboard
|
||
* ─────────────────────────────────────────────────────────────────────
|
||
* BacktestDashboard — 카드 기반 대시보드 (재사용 가능)
|
||
* BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼
|
||
*/
|
||
import React, { useState } from 'react';
|
||
import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi';
|
||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||
import { repairUtf8Mojibake } from '../utils/textEncoding';
|
||
import {
|
||
pct,
|
||
pctAbs,
|
||
num,
|
||
wonFmt,
|
||
colorCls,
|
||
SectionTitle,
|
||
KpiCard,
|
||
MetricRow,
|
||
WinRateCircle,
|
||
CompareBar,
|
||
} from './shared/analysisDashboardUi';
|
||
|
||
const fmtDate = (ts: number) => {
|
||
const d = new Date(ts * 1000);
|
||
return `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
|
||
};
|
||
const fmtDateStr = (iso: string) => {
|
||
const d = new Date(iso);
|
||
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')}`;
|
||
};
|
||
|
||
// ── Props ─────────────────────────────────────────────────────────────────
|
||
|
||
export interface BacktestResultModalProps {
|
||
analysis?: BacktestAnalysis | null;
|
||
stats?: BacktestStats | null;
|
||
signals?: BacktestSignal[];
|
||
strategyName: string;
|
||
symbol: string;
|
||
timeframe: string;
|
||
barCount: number;
|
||
onClose: () => void;
|
||
embedded?: boolean;
|
||
}
|
||
|
||
export interface BacktestDashboardProps {
|
||
analysis: BacktestAnalysis | null;
|
||
signals: BacktestSignal[];
|
||
strategyName: string;
|
||
symbol: string;
|
||
timeframe: string;
|
||
barCount: number;
|
||
createdAt?: string;
|
||
/** 레포트·PDF — 시그널 전체 표시·인쇄용 레이아웃 */
|
||
reportMode?: boolean;
|
||
}
|
||
|
||
// ── 시그널 타입 ───────────────────────────────────────────────────────────
|
||
|
||
const SIG_LABEL: Record<string, string> = {
|
||
BUY:'매수', SELL:'매도', SHORT_ENTRY:'공매도', SHORT_EXIT:'공매도청산', PARTIAL_SELL:'분할매도',
|
||
};
|
||
const SIG_COLOR: Record<string, string> = {
|
||
BUY:'brd-sig-buy', SELL:'brd-sig-sell', SHORT_ENTRY:'brd-neg', SHORT_EXIT:'brd-pos', PARTIAL_SELL:'brd-warn',
|
||
};
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능)
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
export function BacktestDashboard({
|
||
analysis, signals, strategyName, symbol, timeframe, barCount, createdAt,
|
||
reportMode = false,
|
||
}: BacktestDashboardProps) {
|
||
const [sigExpanded, setSigExpanded] = useState(reportMode);
|
||
|
||
const a = analysis;
|
||
if (!a) {
|
||
return (
|
||
<div className="brd-empty-state">
|
||
<div>📊</div>
|
||
<p>결과 데이터가 없습니다.</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const sigLimit = reportMode || sigExpanded ? signals.length : 8;
|
||
const periodStr = signals.length >= 2
|
||
? `${fmtDate(signals[0].time)} ~ ${fmtDate(signals[signals.length-1].time)}`
|
||
: `${barCount}봉`;
|
||
|
||
const sharpeLevel = a.sharpeRatio >= 2 ? '탁월' : a.sharpeRatio >= 1 ? '우수' : a.sharpeRatio >= 0 ? '보통' : '미흡';
|
||
|
||
return (
|
||
<div className={`brd-dashboard${reportMode ? ' brd-dashboard--report' : ''}`}>
|
||
|
||
{/* ── 대시보드 헤더 ── */}
|
||
<div className="brd-dash-header">
|
||
<div className="brd-dash-header-left">
|
||
<div className="brd-dash-strategy">{repairUtf8Mojibake(strategyName)}</div>
|
||
<div className="brd-dash-meta">
|
||
<span className="brd-dash-badge">{symbol}</span>
|
||
<span className="brd-dash-badge">{timeframe}</span>
|
||
<span className="brd-dash-badge">{periodStr}</span>
|
||
{createdAt && <span className="brd-dash-badge brd-dash-badge--time">실행 {fmtDateStr(createdAt)}</span>}
|
||
</div>
|
||
</div>
|
||
<div className={`brd-dash-header-kpi${reportMode ? ' brd-dash-header-kpi--report' : ''}`}>
|
||
<div className={`brd-dash-big-kpi${reportMode ? ' brd-dash-big-kpi--report' : ''}`}>
|
||
<div className={`brd-dash-big-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</div>
|
||
<div className="brd-dash-big-label">총 수익률</div>
|
||
</div>
|
||
<div className={`brd-dash-big-kpi${reportMode ? ' brd-dash-big-kpi--report' : ''}`}>
|
||
<div className={`brd-dash-big-val ${colorCls(a.finalEquity - a.initialCapital)}`}>
|
||
{wonFmt(a.finalEquity)}
|
||
</div>
|
||
<div className="brd-dash-big-label">최종 자산</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Row 1: KPI 카드 6개 ── */}
|
||
<div className="brd-row">
|
||
<div className="brd-kpi-grid">
|
||
<KpiCard icon="🎯" label="승률" value={pctAbs(a.winRate)} sub={`${a.numberOfWinning}승 ${a.numberOfLosing}패`} valueColor={colorCls(a.winRate - 0.5)} accent="blue"/>
|
||
<KpiCard icon="📉" label="최대 낙폭" value={pct(a.maxDrawdownPct)} sub="기간 중 최대 손실" valueColor={a.maxDrawdownPct < 0 ? 'brd-neg' : ''} accent="red"/>
|
||
<KpiCard icon="⚡" label="샤프 비율" value={num(a.sharpeRatio)} sub={sharpeLevel} valueColor={colorCls(a.sharpeRatio - 1)} accent="yellow"/>
|
||
<KpiCard icon="🔄" label="소르티노" value={num(a.sortinoRatio)} sub="하방 변동성 기준" valueColor={colorCls(a.sortinoRatio - 1)} accent="purple"/>
|
||
<KpiCard icon="⚖️" label="손익비" value={num(a.profitLossRatio)} sub="이익/손실 배수" valueColor={colorCls(a.profitLossRatio - 1)} accent="green"/>
|
||
<KpiCard icon="📐" label="칼마 비율" value={num(a.calmarRatio)} sub="수익 / |MDD|" valueColor={colorCls(a.calmarRatio - 1)} accent="teal"/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Row 2: 수익성 + 거래 통계 + 승률 도넛 ── */}
|
||
<div className="brd-row brd-row--3col">
|
||
|
||
{/* 수익성 지표 카드 */}
|
||
<div className="brd-card">
|
||
<SectionTitle icon="📊" title="수익성 지표"/>
|
||
<div className="brd-card-body">
|
||
<MetricRow label="총 수익률" value={pct(a.totalReturnPct)} cls={colorCls(a.totalReturnPct)}/>
|
||
<MetricRow label="총 손익 (금액)" value={wonFmt(a.totalProfitLoss)} cls={colorCls(a.totalProfitLoss)}/>
|
||
<MetricRow label="총 이익" value={wonFmt(a.grossProfit)} cls="brd-pos"/>
|
||
<MetricRow label="총 손실" value={wonFmt(a.grossLoss)} cls="brd-neg"/>
|
||
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
||
<MetricRow label="최대 상승폭" value={pct(a.maxRunupPct)} cls="brd-pos"/>
|
||
<MetricRow label="손익분기 거래" value={`${a.numberOfBreakEven}회`}/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 리스크 지표 카드 */}
|
||
<div className="brd-card">
|
||
<SectionTitle icon="🛡" title="리스크 지표"/>
|
||
<div className="brd-card-body">
|
||
<MetricRow label="최대 낙폭 (MDD)" value={pct(a.maxDrawdownPct)} cls={colorCls(a.maxDrawdownPct)} note="고점 대비"/>
|
||
<MetricRow label="샤프 비율" value={num(a.sharpeRatio)} cls={colorCls(a.sharpeRatio - 1)} note="≥1 우수"/>
|
||
<MetricRow label="소르티노 비율" value={num(a.sortinoRatio)} cls={colorCls(a.sortinoRatio - 1)} note="하방 기준"/>
|
||
<MetricRow label="칼마 비율" value={num(a.calmarRatio)} cls={colorCls(a.calmarRatio - 1)}/>
|
||
<MetricRow label="VaR 95%" value={pct(a.valueAtRisk95)} cls="brd-neg" note="최대 손실 예상"/>
|
||
<MetricRow label="기대 손실 (CVaR)" value={pct(a.expectedShortfall)} cls="brd-neg" note="꼬리 리스크"/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 승률 + 자본 카드 */}
|
||
<div className="brd-card">
|
||
<SectionTitle icon="🔢" title="거래 통계"/>
|
||
<div className="brd-card-body brd-card-body--center">
|
||
<WinRateCircle winRate={a.winRate} winning={a.numberOfWinning} total={a.numberOfPositions}/>
|
||
<div className="brd-trade-stats">
|
||
<MetricRow label="총 거래" value={`${a.numberOfPositions}회`}/>
|
||
<MetricRow label="수익 거래" value={`${a.numberOfWinning}회`} cls="brd-pos"/>
|
||
<MetricRow label="손실 거래" value={`${a.numberOfLosing}회`} cls="brd-neg"/>
|
||
<MetricRow label="초기 자본" value={`${Math.round(a.initialCapital/10000).toLocaleString()}만원`}/>
|
||
<MetricRow label="최종 자산" value={`${Math.round(a.finalEquity/10000).toLocaleString()}만원`} cls={colorCls(a.finalEquity - a.initialCapital)}/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Row 3: 벤치마크 비교 + 자본 분석 ── */}
|
||
<div className="brd-row brd-row--2col">
|
||
|
||
{/* 벤치마크 비교 */}
|
||
<div className="brd-card">
|
||
<SectionTitle icon="📐" title="벤치마크 비교" right={
|
||
<span className={`brd-badge-inline ${colorCls(a.vsBuyAndHold - 1)}`}>
|
||
{isFinite(a.vsBuyAndHold) && a.vsBuyAndHold !== 0
|
||
? `${a.vsBuyAndHold >= 1 ? '전략 우위 ' : ''}${a.vsBuyAndHold.toFixed(2)}×`
|
||
: '–'
|
||
}
|
||
</span>
|
||
}/>
|
||
<div className="brd-card-body">
|
||
<CompareBar
|
||
labelA="전략 수익률" valA={a.totalReturnPct}
|
||
labelB="바이앤홀드" valB={a.buyAndHoldReturnPct}
|
||
colorA={a.totalReturnPct >= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'}
|
||
colorB="var(--text3)"
|
||
/>
|
||
<div className="brd-bench-summary">
|
||
<div className="brd-bench-item">
|
||
<span className="brd-bench-label">전략</span>
|
||
<span className={`brd-bench-val ${colorCls(a.totalReturnPct)}`}>{pct(a.totalReturnPct)}</span>
|
||
</div>
|
||
<div className="brd-bench-divider"/>
|
||
<div className="brd-bench-item">
|
||
<span className="brd-bench-label">바이앤홀드</span>
|
||
<span className={`brd-bench-val ${colorCls(a.buyAndHoldReturnPct)}`}>{pct(a.buyAndHoldReturnPct)}</span>
|
||
</div>
|
||
<div className="brd-bench-divider"/>
|
||
<div className="brd-bench-item">
|
||
<span className="brd-bench-label">초과 수익</span>
|
||
<span className={`brd-bench-val ${colorCls(a.totalReturnPct - a.buyAndHoldReturnPct)}`}>
|
||
{pct(a.totalReturnPct - a.buyAndHoldReturnPct)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 수익/손실 상세 */}
|
||
<div className="brd-card">
|
||
<SectionTitle icon="💰" title="손익 분석"/>
|
||
<div className="brd-card-body">
|
||
<div className="brd-pnl-bar-section">
|
||
<div className="brd-pnl-row">
|
||
<span className="brd-pnl-label brd-pos">총 이익</span>
|
||
<div className="brd-pnl-track">
|
||
<div className="brd-pnl-bar brd-pnl-bar--pos" style={{
|
||
width: `${a.grossProfit > 0 && Math.abs(a.grossLoss) > 0
|
||
? Math.min(100, a.grossProfit / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
|
||
: a.grossProfit > 0 ? 100 : 0}%`
|
||
}}/>
|
||
</div>
|
||
<span className="brd-pnl-val brd-pos">{wonFmt(a.grossProfit)}</span>
|
||
</div>
|
||
<div className="brd-pnl-row">
|
||
<span className="brd-pnl-label brd-neg">총 손실</span>
|
||
<div className="brd-pnl-track">
|
||
<div className="brd-pnl-bar brd-pnl-bar--neg" style={{
|
||
width: `${Math.abs(a.grossLoss) > 0 && a.grossProfit > 0
|
||
? Math.min(100, Math.abs(a.grossLoss) / (a.grossProfit + Math.abs(a.grossLoss)) * 100)
|
||
: Math.abs(a.grossLoss) > 0 ? 100 : 0}%`
|
||
}}/>
|
||
</div>
|
||
<span className="brd-pnl-val brd-neg">{wonFmt(a.grossLoss)}</span>
|
||
</div>
|
||
</div>
|
||
<div className="brd-pnl-net">
|
||
<span className="brd-pnl-net-label">순 손익</span>
|
||
<span className={`brd-pnl-net-val ${colorCls(a.totalProfitLoss)}`}>{wonFmt(a.totalProfitLoss)}</span>
|
||
</div>
|
||
<MetricRow label="손익비" value={num(a.profitLossRatio)} cls={colorCls(a.profitLossRatio - 1)}/>
|
||
<MetricRow label="평균 수익률/거래" value={pct(a.avgReturnPct)} cls={colorCls(a.avgReturnPct)}/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Row 4: 거래 시그널 목록 ── */}
|
||
<div className="brd-row">
|
||
<div className="brd-card brd-card--full">
|
||
<SectionTitle icon="📋" title="거래 시그널" right={
|
||
<span className="brd-section-count">{signals.length}건</span>
|
||
}/>
|
||
{signals.length === 0 ? (
|
||
<div className="brd-empty-msg">시그널이 없습니다.</div>
|
||
) : (
|
||
<>
|
||
<div className="brd-sig-scroll">
|
||
<table className="brd-sig-table">
|
||
<thead>
|
||
<tr>
|
||
<th style={{width:36}}>#</th>
|
||
<th>날짜</th>
|
||
<th>구분</th>
|
||
<th style={{textAlign:'right'}}>가격</th>
|
||
<th style={{textAlign:'center',width:50}}>봉#</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{signals.slice(0, sigLimit).map((s, i) => (
|
||
<tr key={i} className={i % 2 === 0 ? 'brd-sig-even' : ''}>
|
||
<td style={{textAlign:'center',color:'var(--text3)'}}>{i+1}</td>
|
||
<td style={{fontVariantNumeric:'tabular-nums'}}>{fmtDate(s.time)}</td>
|
||
<td>
|
||
<span className={`brd-sig-badge ${SIG_COLOR[s.type] ?? ''}`}>
|
||
{repairUtf8Mojibake(SIG_LABEL[s.type] ?? s.type)}
|
||
</span>
|
||
</td>
|
||
<td style={{textAlign:'right',fontVariantNumeric:'tabular-nums',fontWeight:600}}>
|
||
{Math.round(s.price).toLocaleString()}
|
||
</td>
|
||
<td style={{textAlign:'center',color:'var(--text3)'}}>{s.barIndex}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{!reportMode && signals.length > 8 && (
|
||
<button className="brd-expand-btn" onClick={() => setSigExpanded(v => !v)}>
|
||
{sigExpanded ? '▲ 접기' : `▼ 전체 보기 (${signals.length}건)`}
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
// BacktestResultModal — 팝업 래퍼
|
||
// ══════════════════════════════════════════════════════════════════════════
|
||
|
||
export function BacktestResultModal({
|
||
analysis, stats, signals = [], strategyName, symbol, timeframe, barCount, onClose, embedded = false,
|
||
}: BacktestResultModalProps) {
|
||
|
||
const {
|
||
panelRef: modalRef,
|
||
dragging,
|
||
onHeaderPointerDown, headerTouchStyle,
|
||
panelStyle,
|
||
headerCursor,
|
||
} = useDraggablePanel({ centerOnMount: !embedded });
|
||
|
||
const a = analysis ?? (stats ? buildAnalysisFromStats(stats) : null);
|
||
if (!a && !stats) return null;
|
||
|
||
const inner = (
|
||
<div
|
||
ref={modalRef}
|
||
className={`brm-modal ${embedded ? 'brm-modal--embedded' : ''}`}
|
||
style={embedded ? undefined : {
|
||
...panelStyle,
|
||
zIndex: 9200,
|
||
cursor: dragging ? 'grabbing' : undefined,
|
||
}}
|
||
onMouseDown={embedded ? undefined : e => e.stopPropagation()}
|
||
>
|
||
<div
|
||
className="gc-popup-header brm-header"
|
||
onPointerDown={embedded ? undefined : onHeaderPointerDown}
|
||
style={embedded ? undefined : { cursor: headerCursor, ...headerTouchStyle }}
|
||
>
|
||
<div className="brm-header-left">
|
||
<span className="brm-header-title">백테스팅 결과</span>
|
||
</div>
|
||
{!embedded && (
|
||
<button className="brm-close" onClick={onClose} title="닫기">✕</button>
|
||
)}
|
||
</div>
|
||
<div className="brm-body">
|
||
<BacktestDashboard
|
||
analysis={a}
|
||
signals={signals}
|
||
strategyName={strategyName}
|
||
symbol={symbol}
|
||
timeframe={timeframe}
|
||
barCount={barCount}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
if (embedded) return inner;
|
||
|
||
return (
|
||
<div className="brm-overlay" onClick={e => { if (e.target === e.currentTarget) onClose(); }}>
|
||
{inner}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function buildAnalysisFromStats(s: BacktestStats): BacktestAnalysis {
|
||
return {
|
||
initialCapital: 10_000_000,
|
||
finalEquity: s.finalEquity ?? 0,
|
||
totalReturnPct: s.totalReturn ?? 0,
|
||
totalProfitLoss: (s.finalEquity ?? 0) - 10_000_000,
|
||
grossProfit: 0, grossLoss: 0,
|
||
avgReturnPct: s.avgReturn ?? 0,
|
||
profitLossRatio: 0,
|
||
numberOfPositions: s.totalTrades ?? 0,
|
||
numberOfWinning: s.winTrades ?? 0,
|
||
numberOfLosing: (s.totalTrades ?? 0) - (s.winTrades ?? 0),
|
||
numberOfBreakEven: 0,
|
||
winRate: s.winRate ?? 0,
|
||
maxDrawdownPct: s.maxDrawdown ?? 0,
|
||
maxRunupPct: 0, sharpeRatio: 0, sortinoRatio: 0, calmarRatio: 0,
|
||
valueAtRisk95: 0, expectedShortfall: 0,
|
||
buyAndHoldReturnPct: 0, vsBuyAndHold: 0,
|
||
};
|
||
}
|
||
|
||
export default BacktestResultModal;
|