Files
goldenChart/frontend/src/components/BacktestResultModal.tsx
T

400 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;