import type { Candle, Pair } from '../types'; import { runBacktest, type BacktestConfig } from './runner'; import { computeMetrics, type Metrics, type EquityPoint } from './metrics'; import type { StrategyParams } from '../strategy/donchian-trend'; import type { ClosedTrade } from '../engine/portfolio'; const DAY = 24 * 60 * 60 * 1000; export interface Window { trainFrom: number; trainTo: number; testFrom: number; testTo: number; } /** * Rollierende Train/Test-Fenster. Hinweis: Im ersten Fenster sind die ersten * ~EMA200-Perioden (≈33 Tage auf 4h) Indikator-Cold-Start — effektive * Train-Länge von Fenster 0 ist entsprechend kürzer als bei späteren Fenstern. */ export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, testDays = 30, stepDays = 30): Window[] { const out: Window[] = []; for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) { const trainTo = start + trainDays * DAY; out.push({ trainFrom: start, trainTo, testFrom: trainTo, testTo: trainTo + testDays * DAY }); } return out; } export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) => [2, 3, 4].flatMap((atrMultiplier) => [100, 200].map((trendEmaPeriod) => ({ donchianPeriod, atrPeriod: 14, atrMultiplier, trendEmaPeriod })), ), ); export interface WindowResult { window: Window; bestParams: StrategyParams; trainMetrics: Metrics; testMetrics: Metrics; testTrades: ClosedTrade[]; testEquityCurve: EquityPoint[]; } export interface GateInput { oosProfitFactor: number; oosTrades: number; oosMaxDrawdownPct: number; worstWindow: { profitFactor: number; trades: number }; avgTrainPf: number; } export interface GateCheck { name: string; pass: boolean; value: number; threshold: number; } export interface GateResult { pass: boolean; checks: GateCheck[]; } /** Schlechtestes Test-Fenster UNTER den für Check 4 relevanten (>= 5 Trades). */ export function pickWorstEligibleWindow(metricsList: { profitFactor: number; trades: number }[]): { profitFactor: number; trades: number } { return metricsList .filter((m) => m.trades >= 5) .reduce( (acc, m) => (m.profitFactor < acc.profitFactor ? { profitFactor: m.profitFactor, trades: m.trades } : acc), { profitFactor: Infinity, trades: 0 }, ); } export function evaluateGate(g: GateInput): GateResult { const overfitRatio = g.oosProfitFactor > 0 ? g.avgTrainPf / g.oosProfitFactor : Infinity; const windowFail = g.worstWindow.trades >= 5 && g.worstWindow.profitFactor < 0.5; const checks: GateCheck[] = [ { name: 'OOS-ProfitFactor >= 1.2', pass: g.oosProfitFactor >= 1.2, value: g.oosProfitFactor, threshold: 1.2 }, { name: 'OOS-Trades >= 25', pass: g.oosTrades >= 25, value: g.oosTrades, threshold: 25 }, { name: 'OOS-MaxDrawdown <= 25%', pass: g.oosMaxDrawdownPct <= 0.25, value: g.oosMaxDrawdownPct, threshold: 0.25 }, { name: 'kein Fenster PF < 0.5 (bei >= 5 Trades)', pass: !windowFail, value: g.worstWindow.profitFactor, threshold: 0.5 }, { name: 'Train-PF / OOS-PF < 2 (Overfitting)', pass: overfitRatio < 2, value: overfitRatio, threshold: 2 }, ]; return { pass: checks.every((c) => c.pass), checks }; } export interface WalkForwardResult { windows: WindowResult[]; oosMetrics: Metrics; oosEquityCurve: EquityPoint[]; gate: GateResult; } type BaseConfig = Omit; /** PF-Vergleich mit Infinity-Handling: Infinity schlägt alles, Tie-Break TotalPnl. */ function better(a: Metrics, b: Metrics): boolean { if (a.profitFactor !== b.profitFactor) return a.profitFactor > b.profitFactor; return a.totalPnl > b.totalPnl; } export function runWalkForward( candles15ByPair: Map, baseCfg: BaseConfig, dataFrom: number, dataTo: number, onProgress?: (msg: string) => void, ): WalkForwardResult { const windows = buildWindows(dataFrom, dataTo); const results: WindowResult[] = []; for (const [wi, w] of windows.entries()) { let bestParams = PARAM_GRID[0]; let bestMetrics: Metrics | null = null; let bestEligible = false; for (const params of PARAM_GRID) { const r = runBacktest(candles15ByPair, { ...baseCfg, params, tradeFrom: w.trainFrom, tradeTo: w.trainTo }); const m = computeMetrics(r.trades, r.equityCurve, baseCfg.startCapital); const eligible = m.trades >= 5; const wins = bestMetrics === null || (eligible && !bestEligible) || (eligible === bestEligible && (eligible ? better(m, bestMetrics) : m.trades > bestMetrics.trades)); if (wins) { bestParams = params; bestMetrics = m; bestEligible = eligible; } } const test = runBacktest(candles15ByPair, { ...baseCfg, params: bestParams, tradeFrom: w.testFrom, tradeTo: w.testTo }); const testMetrics = computeMetrics(test.trades, test.equityCurve, baseCfg.startCapital); results.push({ window: w, bestParams, trainMetrics: bestMetrics!, testMetrics, testTrades: test.trades, testEquityCurve: test.equityCurve, }); onProgress?.( `Fenster ${wi + 1}/${windows.length}: Train-PF ${bestMetrics!.profitFactor.toFixed(2)} ` + `(${JSON.stringify(bestParams)}) → Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`, ); } // OOS-Aggregat: Trades kombiniert, Equity-Kurven multiplikativ verkettet const oosTrades = results.flatMap((r) => r.testTrades); const oosEquityCurve: EquityPoint[] = []; let scale = 1; for (const r of results) { for (const p of r.testEquityCurve) { oosEquityCurve.push({ ts: p.ts, equity: baseCfg.startCapital * scale * (p.equity / baseCfg.startCapital) }); } const last = r.testEquityCurve.at(-1); if (last) scale *= last.equity / baseCfg.startCapital; } const oosMetrics = computeMetrics(oosTrades, oosEquityCurve, baseCfg.startCapital); const worst = pickWorstEligibleWindow(results.map((r) => r.testMetrics)); const finiteTrainPfs = results.map((r) => Math.min(r.trainMetrics.profitFactor, 10)); // Infinity kappen const avgTrainPf = finiteTrainPfs.reduce((s, v) => s + v, 0) / Math.max(1, finiteTrainPfs.length); const gate = evaluateGate({ oosProfitFactor: oosMetrics.profitFactor, oosTrades: oosMetrics.trades, oosMaxDrawdownPct: oosMetrics.maxDrawdownPct, worstWindow: worst, avgTrainPf, }); return { windows: results, oosMetrics, oosEquityCurve, gate }; }