/** * BacktestResultModal / BacktestDashboard * ───────────────────────────────────────────────────────────────────── * BacktestDashboard — 카드 기반 대시보드 (재사용 가능) * BacktestResultModal — 드래그 팝업으로 Dashboard를 감싸는 래퍼 */ import React, { useMemo, useState } from 'react'; import type { BacktestAnalysis, BacktestSignal, BacktestStats } from '../utils/backendApi'; import { useDraggablePanel } from '../hooks/useDraggablePanel'; import { repairUtf8Mojibake } from '../utils/textEncoding'; import type { EvaluationAllocationReport } from './backtest/BacktestAnalysisReportModal'; import { pct, pctAbs, num, wonFmt, colorCls, SectionTitle, KpiCard, MetricRow, WinRateCircle, CompareBar, } from './shared/analysisDashboardUi'; import { analysisMethodLabel } from '../utils/analysisMethodLabels'; import BacktestSignalTable from './backtest/BacktestSignalTable'; 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; /** false — 거래 시그널 섹션 숨김 (별도 패널에 표시) */ hideSignals?: boolean; /** 전략평가 — 일괄/분할 매수 방식별 분석 */ evaluationAllocations?: EvaluationAllocationReport[]; reportSignalFilterNote?: string; } // ── 시그널 타입 ─────────────────────────────────────────────────────────── // ══════════════════════════════════════════════════════════════════════════ // BacktestDashboard — 메인 대시보드 컴포넌트 (재사용 가능) // ══════════════════════════════════════════════════════════════════════════ export function BacktestDashboard({ analysis, signals, strategyName, symbol, timeframe, barCount, createdAt, reportMode = false, hideSignals = false, evaluationAllocations, reportSignalFilterNote, }: BacktestDashboardProps) { const [allocMode, setAllocMode] = useState<'full' | 'split'>('split'); const displayAnalysis = useMemo(() => { if (!evaluationAllocations?.length) return analysis; return evaluationAllocations.find(e => e.mode === allocMode)?.analysis ?? analysis; }, [analysis, evaluationAllocations, allocMode]); const a = displayAnalysis; if (!a) { return (
📊

결과 데이터가 없습니다.

); } 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 (
{/* ── 대시보드 헤더 ── */}
{repairUtf8Mojibake(strategyName)}
{symbol} {timeframe} {periodStr} {createdAt && 실행 {fmtDateStr(createdAt)}} {a.analysisMethod && ( {analysisMethodLabel(a.analysisMethod)} )}
{pct(a.totalReturnPct)}
총 수익률
{wonFmt(a.finalEquity)}
최종 자산
{evaluationAllocations && evaluationAllocations.length >= 2 && (
{reportSignalFilterNote && (

{reportSignalFilterNote}

)}
{evaluationAllocations.map(item => ( ))}
{evaluationAllocations.map(item => (
setAllocMode(item.mode)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setAllocMode(item.mode); }} >
{item.label}
{pct(item.analysis.totalReturnPct)}
최종 {wonFmt(item.analysis.finalEquity)} · 승률 {pctAbs(item.analysis.winRate)}
))}
)} {/* ── Row 1: KPI 카드 6개 ── */}
{/* ── Row 2: 수익성 + 거래 통계 + 승률 도넛 ── */}
{/* 수익성 지표 카드 */}
{a.realizedPnl != null && ( )} {a.unrealizedPnl != null && a.unrealizedPnl !== 0 && ( )} {a.cashBalance != null && a.cashBalance > 0 && ( )} {a.holdingsValue != null && a.holdingsValue > 0 && ( )}
{/* 리스크 지표 카드 */}
{/* 승률 + 자본 카드 */}
{/* ── Row 3: 벤치마크 비교 + 자본 분석 ── */}
{/* 벤치마크 비교 */}
{isFinite(a.vsBuyAndHold) && a.vsBuyAndHold !== 0 ? `${a.vsBuyAndHold >= 1 ? '전략 우위 ' : ''}${a.vsBuyAndHold.toFixed(2)}×` : '–' } }/>
= 0 ? 'var(--brd-pos)' : 'var(--brd-neg)'} colorB="var(--text3)" />
전략 {pct(a.totalReturnPct)}
바이앤홀드 {pct(a.buyAndHoldReturnPct)}
초과 수익 {pct(a.totalReturnPct - a.buyAndHoldReturnPct)}
{/* 수익/손실 상세 */}
총 이익
0 && Math.abs(a.grossLoss) > 0 ? Math.min(100, a.grossProfit / (a.grossProfit + Math.abs(a.grossLoss)) * 100) : a.grossProfit > 0 ? 100 : 0}%` }}/>
{wonFmt(a.grossProfit)}
총 손실
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}%` }}/>
{wonFmt(a.grossLoss)}
순 손익 {wonFmt(a.totalProfitLoss)}
{/* ── Row 4: 거래 시그널 목록 ── */} {!hideSignals && (
)}
); } // ══════════════════════════════════════════════════════════════════════════ // 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 = (
e.stopPropagation()} >
백테스팅 결과
{!embedded && ( )}
); if (embedded) return inner; return (
{ if (e.target === e.currentTarget) onClose(); }}> {inner}
); } 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;