Files
goldenChart/frontend/src/components/backtest/BacktestKpiPanel.tsx
T
2026-06-06 00:56:45 +09:00

193 lines
8.6 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.
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 (
<div className="btd-analysis-metric">
<span className="btd-analysis-metric-label">{label}</span>
<span className={`btd-analysis-metric-value${tone ? ` btd-analysis-metric-value--${tone}` : ''}`}>
{value}
</span>
</div>
);
}
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 <div className="btd-analysis-empty">{emptyText}</div>;
}
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 (
<div className="btd-analysis-stack">
<section className="btd-analysis-card btd-analysis-card--profit">
<header className="btd-analysis-card-head">
<h3 className="btd-analysis-card-title">수익성</h3>
<span className="btd-analysis-card-tag">Profitability</span>
</header>
<div className="btd-analysis-card-body">
<div className="btd-analysis-hero">
<div className="btd-analysis-hero-main">
<span className="btd-analysis-hero-label"> 수익률</span>
<strong className={`btd-analysis-hero-value ${totalReturn >= 0 ? 'up' : 'down'}`}>
{pct(totalReturn)}
</strong>
</div>
<BacktestSparkline curve={equityCurve} positive={positive} width={80} height={30} />
</div>
<div className="btd-analysis-grid">
<MetricCell label="초기 자본" value={fmtWon(initialCap)} />
<MetricCell label="최종 자산" value={fmtWon(finalEquity)} tone={finalEquity >= initialCap ? 'up' : 'down'} />
<MetricCell label="순이익" value={krw(netProfit)} tone={netProfit >= 0 ? 'up' : 'down'} />
<MetricCell label="평균 수익률" value={pct(analysis?.avgReturnPct ?? 0)} tone={(analysis?.avgReturnPct ?? 0) >= 0 ? 'up' : 'down'} />
<MetricCell label="총 이익 (Gross)" value={krw(grossProfit)} tone="up" />
<MetricCell label="총 손실 (Gross)" value={krw(-Math.abs(grossLoss))} tone="down" />
<MetricCell label="Buy & Hold" value={pct(buyHold)} tone={buyHold >= 0 ? 'up' : 'down'} />
<MetricCell label="vs Buy & Hold" value={num(vsHold, 2)} tone={vsHold >= 1 ? 'up' : 'down'} />
</div>
</div>
</section>
<section className="btd-analysis-card btd-analysis-card--risk">
<header className="btd-analysis-card-head">
<h3 className="btd-analysis-card-title">리스크 관리</h3>
<span className="btd-analysis-card-tag">Risk</span>
</header>
<div className="btd-analysis-card-body">
<div className="btd-analysis-hero btd-analysis-hero--compact">
<div className="btd-analysis-hero-main">
<span className="btd-analysis-hero-label">MDD (최대낙폭)</span>
<strong className="btd-analysis-hero-value down">{pct(mdd)}</strong>
</div>
<div className="btd-analysis-hero-side">
<span className="btd-analysis-side-label">Max Runup</span>
<strong className="btd-analysis-side-value up">{pct(maxRunup)}</strong>
</div>
</div>
<div className="btd-analysis-grid">
<MetricCell label="Sharpe Ratio" value={num(sharpe)} tone="gold" />
<MetricCell label="Sortino Ratio" value={num(sortino)} tone="gold" />
<MetricCell label="Calmar Ratio" value={num(calmar)} tone="gold" />
<MetricCell label="Recovery Factor" value={num(recovery)} tone={recovery >= 1 ? 'up' : 'neutral'} />
<MetricCell label="VaR (95%)" value={pct(var95)} tone="down" />
<MetricCell label="Expected Shortfall" value={pct(es)} tone="down" />
</div>
</div>
</section>
<section className="btd-analysis-card btd-analysis-card--stats">
<header className="btd-analysis-card-head">
<h3 className="btd-analysis-card-title">거래 통계</h3>
<span className="btd-analysis-card-tag">Trades</span>
</header>
<div className="btd-analysis-card-body">
<div className="btd-analysis-hero btd-analysis-hero--compact">
<div className="btd-analysis-hero-main">
<span className="btd-analysis-hero-label">승률</span>
<strong className={`btd-analysis-hero-value ${winRate >= 0.5 ? 'up' : 'down'}`}>
{pctAbs(winRate, 1)}
</strong>
</div>
<div className="btd-analysis-hero-side">
<span className="btd-analysis-side-label"> 거래</span>
<strong className="btd-analysis-side-value">{tradeCount}</strong>
</div>
</div>
<div className="btd-analysis-grid">
<MetricCell label="승리" value={`${winCount}회`} tone="up" />
<MetricCell label="패배" value={`${lossCount}회`} tone="down" />
<MetricCell label="무승부" value={`${breakEven}회`} tone="neutral" />
<MetricCell label="체결 비율" value={tradeCount > 0 ? pctAbs(winCount / tradeCount, 0) : '0%'} tone="gold" />
<MetricCell label="평균 수익" value={krw(avgWin)} tone="up" />
<MetricCell label="평균 손실" value={krw(-Math.abs(avgLoss))} tone="down" />
<MetricCell label="Profit Factor" value={num(profitFactor)} tone={profitFactor >= 1 ? 'up' : 'down'} />
<MetricCell label="손익비 (P/L)" value={num(plRatio)} tone="gold" />
</div>
<p className="btd-analysis-footnote">
{tradeCount} · {winCount} / {lossCount}
{breakEven > 0 ? ` / 무 ${breakEven}` : ''}
· PF {num(profitFactor)}
</p>
</div>
</section>
</div>
);
}