193 lines
8.6 KiB
TypeScript
193 lines
8.6 KiB
TypeScript
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>
|
||
);
|
||
}
|