import type { BacktestSignal } from './backendApi'; import type { TradeHistoryRow, EquityPoint } from './backtestEquity'; /** CAGR 추정 */ export function estimateCagr(totalReturnPct: number, signals: BacktestSignal[]): number { if (signals.length < 2 || !isFinite(totalReturnPct)) return totalReturnPct; const t0 = signals[0].time; const t1 = signals[signals.length - 1].time; const years = (t1 - t0) / (365.25 * 86400); if (years <= 0.01) return totalReturnPct; const mult = 1 + totalReturnPct; if (mult <= 0) return totalReturnPct; return Math.pow(mult, 1 / years) - 1; } /** 월별×연도 수익률 행렬 (히트맵 + Temporal 탭) */ export function buildMonthlyMatrix( trades: TradeHistoryRow[], ): { year: number; month: number; returnPct: number }[] { const byKey = new Map(); for (const t of trades) { if (t.pnlPct == null) continue; const d = new Date(t.time * 1000); const key = `${d.getFullYear()}-${d.getMonth()}`; byKey.set(key, (byKey.get(key) ?? 0) + t.pnlPct); } const out: { year: number; month: number; returnPct: number }[] = []; for (const [key, v] of byKey) { const [y, m] = key.split('-').map(Number); out.push({ year: y, month: m, returnPct: v }); } return out.sort((a, b) => a.year - b.year || a.month - b.month); } /** YTD(연도별 합산) */ export function buildYtdByYear(monthly: { year: number; month: number; returnPct: number }[]): Map { const m = new Map(); for (const r of monthly) { m.set(r.year, (m.get(r.year) ?? 0) + r.returnPct); } return m; } /** 수익률 분포 버킷 (Stats 탭 히스토그램) */ export function buildReturnDistribution( trades: TradeHistoryRow[], ): { bucket: string; from: number; to: number; count: number }[] { const withPnl = trades.filter(t => t.pnlPct != null).map(t => (t.pnlPct ?? 0) * 100); if (withPnl.length === 0) return []; const buckets: { label: string; min: number; max: number }[] = [ { label: '< -10%', min: -Infinity, max: -10 }, { label: '-10~-5%', min: -10, max: -5 }, { label: '-5~0%', min: -5, max: 0 }, { label: '0~2%', min: 0, max: 2 }, { label: '2~4%', min: 2, max: 4 }, { label: '4~6%', min: 4, max: 6 }, { label: '6~10%', min: 6, max: 10 }, { label: '10~50%', min: 10, max: 50 }, { label: '50~100%', min: 50, max: 100 }, { label: '100~500%', min: 100, max: 500 }, { label: '500%+', min: 500, max: Infinity }, ]; return buckets.map(b => ({ bucket: b.label, from: b.min, to: b.max, count: withPnl.filter(v => v >= b.min && v < b.max).length, })).filter(b => b.count > 0 || (b.from >= -10 && b.to <= 10)); } /** 보유시간(분/시간) 계산 — BUY 신호 후 SELL 신호까지 */ export interface HoldingPoint { durationMin: number; pnlPct: number; symbol: string; } export function buildHoldingPoints( signals: BacktestSignal[], symbol: string, ): HoldingPoint[] { const sorted = [...signals].sort((a, b) => a.time - b.time); const pts: HoldingPoint[] = []; const buys: BacktestSignal[] = []; for (const s of sorted) { if (s.type === 'BUY') { buys.push(s); } else if ((s.type === 'SELL' || s.type === 'SHORT_EXIT' || s.type === 'PARTIAL_SELL') && buys.length > 0) { const buy = buys.pop()!; const durSec = s.time - buy.time; const pnl = buy.price > 0 ? (s.price - buy.price) / buy.price : 0; pts.push({ durationMin: durSec / 60, pnlPct: pnl * 100, symbol }); } } return pts; } /** 자본 배분 슬라이스 (Allocation 탭) */ export interface AllocationSlice { symbol: string; pct: number; capitalKrw: number; color: string; } const SLICE_COLORS = ['#00e5ff', '#00e676', '#ffd700', '#ab47bc', '#ff7043', '#42a5f5']; export function buildAllocationSlices( trades: TradeHistoryRow[], initialCapital: number, ): AllocationSlice[] { const bySymbol = new Map(); for (const t of trades) { bySymbol.set(t.symbol, (bySymbol.get(t.symbol) ?? 0) + 1); } const total = [...bySymbol.values()].reduce((a, b) => a + b, 0) || 1; return [...bySymbol.entries()] .map(([sym, cnt], i) => ({ symbol: sym, pct: cnt / total, capitalKrw: initialCapital * (cnt / total), color: SLICE_COLORS[i % SLICE_COLORS.length], })) .sort((a, b) => b.pct - a.pct); } /** 변동성 시리즈 (롤링 20봉 표준편차) */ export function buildVolatilitySeries(curve: EquityPoint[], window = 20): { time: number; vol: number }[] { if (curve.length < 2) return []; const returns: number[] = []; for (let i = 1; i < curve.length; i++) { const r = curve[i - 1].equity > 0 ? (curve[i].equity - curve[i - 1].equity) / curve[i - 1].equity : 0; returns.push(r); } const out: { time: number; vol: number }[] = []; for (let i = window - 1; i < returns.length; i++) { const slice = returns.slice(i - window + 1, i + 1); const mean = slice.reduce((a, b) => a + b, 0) / window; const variance = slice.reduce((a, b) => a + (b - mean) ** 2, 0) / window; out.push({ time: curve[i + 1].time, vol: Math.sqrt(variance) * 100 }); } return out; } /** 드로다운 시리즈 */ export function drawdownSeries(curve: EquityPoint[]): { time: number; ddPct: number }[] { let peak = curve[0]?.equity ?? 0; return curve.map(p => { if (p.equity > peak) peak = p.equity; const dd = peak > 0 ? (p.equity - peak) / peak : 0; return { time: p.time, ddPct: dd }; }); }