169 lines
5.2 KiB
TypeScript
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,
|
|
};
|
|
}
|