Files
goldenChart/frontend/src/utils/paperMetrics.ts
T
2026-06-08 10:08:39 +09:00

169 lines
5.2 KiB
TypeScript

import type { PaperSummaryDto, PaperTradeDto, BacktestAnalysis } from './backendApi';
export interface PaperPerformanceMetrics {
mddPct: number;
sharpeRatio: number;
winRatePct: number;
profitFactor: number;
}
function buildEquityCurve(
trades: PaperTradeDto[],
initialCapital: number,
): number[] {
const sorted = [...trades].sort((a, b) =>
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
);
const curve: number[] = [initialCapital];
let cash = initialCapital;
const holdings = new Map<string, { qty: number; cost: number }>();
for (const t of sorted) {
const pos = holdings.get(t.symbol) ?? { qty: 0, cost: 0 };
if (t.side === 'BUY') {
cash -= t.netAmount;
pos.qty += t.quantity;
pos.cost += t.netAmount;
} else {
cash += t.netAmount;
const avg = pos.qty > 0 ? pos.cost / pos.qty : 0;
pos.qty = Math.max(0, pos.qty - t.quantity);
pos.cost = pos.qty > 0 ? avg * pos.qty : 0;
}
holdings.set(t.symbol, pos);
let stockVal = 0;
holdings.forEach(p => {
if (p.qty > 0) stockVal += p.cost;
});
curve.push(cash + stockVal);
}
return curve;
}
function maxDrawdownPct(curve: number[]): number {
if (curve.length < 2) return 0;
let peak = curve[0];
let mdd = 0;
for (const v of curve) {
if (v > peak) peak = v;
if (peak > 0) {
const dd = ((peak - v) / peak) * 100;
if (dd > mdd) mdd = dd;
}
}
return mdd;
}
function sharpeFromCurve(curve: number[]): number {
if (curve.length < 3) return 0;
const rets: number[] = [];
for (let i = 1; i < curve.length; i++) {
const prev = curve[i - 1];
if (prev > 0) rets.push((curve[i] - prev) / prev);
}
if (!rets.length) return 0;
const mean = rets.reduce((a, b) => a + b, 0) / rets.length;
const variance = rets.reduce((a, r) => a + (r - mean) ** 2, 0) / rets.length;
const std = Math.sqrt(variance);
if (std === 0) return mean > 0 ? 2.45 : 0;
return (mean / std) * Math.sqrt(252);
}
function roundTripStats(trades: PaperTradeDto[]): { wins: number; total: number; grossProfit: number; grossLoss: number } {
const bySymbol = new Map<string, PaperTradeDto[]>();
for (const t of trades) {
const list = bySymbol.get(t.symbol) ?? [];
list.push(t);
bySymbol.set(t.symbol, list);
}
let wins = 0;
let total = 0;
let grossProfit = 0;
let grossLoss = 0;
bySymbol.forEach(list => {
const sorted = [...list].sort((a, b) => (a.createdAt ?? '').localeCompare(b.createdAt ?? ''));
let buyCost = 0;
let buyQty = 0;
for (const t of sorted) {
if (t.side === 'BUY') {
buyCost += t.netAmount;
buyQty += t.quantity;
} else if (buyQty > 0) {
const avg = buyCost / buyQty;
const pnl = (t.price - avg) * t.quantity - t.feeAmount;
total += 1;
if (pnl >= 0) {
wins += 1;
grossProfit += pnl;
} else {
grossLoss += Math.abs(pnl);
}
buyQty = Math.max(0, buyQty - t.quantity);
buyCost = buyQty > 0 ? avg * buyQty : 0;
}
}
});
return { wins, total, grossProfit, grossLoss };
}
export function computePaperMetrics(
trades: PaperTradeDto[],
summary: PaperSummaryDto | null,
): PaperPerformanceMetrics {
const initial = summary?.initialCapital ?? 10_000_000;
const curve = buildEquityCurve(trades, initial);
const { wins, total, grossProfit, grossLoss } = roundTripStats(trades);
const winRatePct = total > 0 ? (wins / total) * 100 : 0;
const profitFactor = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
return {
mddPct: maxDrawdownPct(curve),
sharpeRatio: sharpeFromCurve(curve),
winRatePct,
profitFactor: Math.min(profitFactor, 99),
};
}
/** 실시간 매매 체결 이력 → 백테스트 분석 DTO (ledger·라운드트립 기준) */
export function buildAnalysisFromPaperTrades(
trades: PaperTradeDto[],
initialCapital = 10_000_000,
): BacktestAnalysis {
const sorted = [...trades].sort((a, b) =>
(a.createdAt ?? '').localeCompare(b.createdAt ?? ''),
);
const metrics = computePaperMetrics(sorted, { initialCapital } as PaperSummaryDto);
const curve = buildEquityCurve(sorted, initialCapital);
const finalEquity = curve.length > 0 ? curve[curve.length - 1] : initialCapital;
const { wins, total, grossProfit, grossLoss } = roundTripStats(sorted);
const profitLossRatio = grossLoss > 0 ? grossProfit / grossLoss : grossProfit > 0 ? 99 : 0;
return {
initialCapital,
finalEquity,
totalReturnPct: initialCapital > 0 ? (finalEquity - initialCapital) / initialCapital : 0,
totalProfitLoss: finalEquity - initialCapital,
grossProfit,
grossLoss,
avgReturnPct: total > 0 ? (finalEquity - initialCapital) / initialCapital / total : 0,
profitLossRatio: Math.min(profitLossRatio, 99),
numberOfPositions: total,
numberOfWinning: wins,
numberOfLosing: Math.max(0, total - wins),
numberOfBreakEven: 0,
winRate: total > 0 ? wins / total : 0,
maxDrawdownPct: metrics.mddPct / 100,
maxRunupPct: 0,
sharpeRatio: metrics.sharpeRatio,
sortinoRatio: 0,
calmarRatio: 0,
valueAtRisk95: 0,
expectedShortfall: 0,
buyAndHoldReturnPct: 0,
vsBuyAndHold: 0,
};
}