import React, { useMemo } from 'react'; import type { BacktestAnalysis } from '../../utils/backendApi'; import BacktestSparkline from './BacktestSparkline'; import type { EquityPoint } from '../../utils/backtestEquity'; import { krw, pct, pctAbs } from '../../utils/backtestUiUtils'; import { formatUpbitKrwPrice } from '../../utils/safeFormat'; interface Props { analysis: BacktestAnalysis | null; equityCurve: EquityPoint[]; totalReturnOverride?: number; winRateOverride?: number; tradeCountOverride?: number; winCountOverride?: number; mddOverride?: number; sharpeOverride?: number; profitFactorOverride?: number; netProfitOverride?: number; emptyText?: string; } const num = (v: number, dec = 2) => (isFinite(v) ? v.toFixed(dec) : '–'); const fmtWon = (v: number) => isFinite(v) ? `₩${formatUpbitKrwPrice(v, '–')}` : '–'; function MetricCell({ label, value, tone, }: { label: string; value: React.ReactNode; tone?: 'up' | 'down' | 'gold' | 'neutral'; }) { return (
{label} {value}
); } export default function BacktestKpiPanel({ analysis, equityCurve, totalReturnOverride, winRateOverride, tradeCountOverride, winCountOverride, mddOverride, sharpeOverride, profitFactorOverride, netProfitOverride, emptyText = '분석 결과를 선택하세요.', }: Props) { const positive = useMemo(() => { const ret = totalReturnOverride ?? analysis?.totalReturnPct ?? 0; return ret >= 0; }, [analysis, totalReturnOverride]); if (!analysis && totalReturnOverride == null) { return
{emptyText}
; } const totalReturn = totalReturnOverride ?? analysis?.totalReturnPct ?? 0; const winRate = winRateOverride ?? analysis?.winRate ?? 0; const tradeCount = tradeCountOverride ?? analysis?.numberOfPositions ?? 0; const winCount = winCountOverride ?? analysis?.numberOfWinning ?? Math.round(tradeCount * winRate); const lossCount = analysis?.numberOfLosing ?? Math.max(0, tradeCount - winCount); const breakEven = analysis?.numberOfBreakEven ?? Math.max(0, tradeCount - winCount - lossCount); const mdd = mddOverride ?? analysis?.maxDrawdownPct ?? 0; const sharpe = sharpeOverride ?? analysis?.sharpeRatio ?? 0; const profitFactor = profitFactorOverride ?? ( analysis && analysis.grossLoss !== 0 ? Math.abs(analysis.grossProfit / analysis.grossLoss) : analysis?.profitLossRatio ?? 0 ); const netProfit = netProfitOverride ?? analysis?.totalProfitLoss ?? 0; const initialCap = analysis?.initialCapital ?? 0; const finalEquity = analysis?.finalEquity ?? initialCap + netProfit; const grossProfit = analysis?.grossProfit ?? 0; const grossLoss = analysis?.grossLoss ?? 0; const avgWin = winCount > 0 ? grossProfit / winCount : 0; const avgLoss = lossCount > 0 ? grossLoss / lossCount : 0; const buyHold = analysis?.buyAndHoldReturnPct ?? 0; const vsHold = analysis?.vsBuyAndHold ?? 0; const maxRunup = analysis?.maxRunupPct ?? 0; const sortino = analysis?.sortinoRatio ?? 0; const calmar = analysis?.calmarRatio ?? 0; const var95 = analysis?.valueAtRisk95 ?? 0; const es = analysis?.expectedShortfall ?? 0; const plRatio = analysis?.profitLossRatio ?? profitFactor; const recovery = mdd !== 0 ? totalReturn / Math.abs(mdd) : 0; return (

수익성

Profitability
총 수익률 = 0 ? 'up' : 'down'}`}> {pct(totalReturn)}
= initialCap ? 'up' : 'down'} /> = 0 ? 'up' : 'down'} /> = 0 ? 'up' : 'down'} /> = 0 ? 'up' : 'down'} /> = 1 ? 'up' : 'down'} />

리스크 관리

Risk
MDD (최대낙폭) {pct(mdd)}
Max Runup {pct(maxRunup)}
= 1 ? 'up' : 'neutral'} />

거래 통계

Trades
승률 = 0.5 ? 'up' : 'down'}`}> {pctAbs(winRate, 1)}
총 거래 {tradeCount}회
0 ? pctAbs(winCount / tradeCount, 0) : '0%'} tone="gold" /> = 1 ? 'up' : 'down'} />

총 {tradeCount}회 · 승 {winCount} / 패 {lossCount} {breakEven > 0 ? ` / 무 ${breakEven}` : ''} · PF {num(profitFactor)}

); }