diff --git a/src/server/backtest/walkforward.test.ts b/src/server/backtest/walkforward.test.ts new file mode 100644 index 0000000..b661897 --- /dev/null +++ b/src/server/backtest/walkforward.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'bun:test'; +import { buildWindows, evaluateGate, PARAM_GRID } from './walkforward'; + +const DAY = 24 * 60 * 60 * 1000; + +test('Fensterung: Train 120d / Test 30d / Schritt 30d, kein Leak', () => { + const windows = buildWindows(0, 365 * DAY, 120, 30, 30); + expect(windows.length).toBe(8); // Tests bei Tag 120..150, 150..180, … 330..360 + for (const w of windows) { + expect(w.testFrom).toBe(w.trainTo); // Test beginnt exakt nach Train + expect(w.trainTo - w.trainFrom).toBe(120 * DAY); + expect(w.testTo - w.testFrom).toBe(30 * DAY); + expect(w.testTo).toBeLessThanOrEqual(365 * DAY); + } + expect(windows[1].trainFrom - windows[0].trainFrom).toBe(30 * DAY); +}); + +test('Grid hat 18 Kombinationen', () => { + expect(PARAM_GRID).toHaveLength(18); +}); + +test('Gate: alle Kriterien müssen bestehen', () => { + const good = { + oosProfitFactor: 1.5, oosTrades: 30, oosMaxDrawdownPct: 0.15, + worstWindow: { profitFactor: 0.9, trades: 6 }, avgTrainPf: 2.0, + }; + expect(evaluateGate(good).pass).toBe(true); + + expect(evaluateGate({ ...good, oosProfitFactor: 1.1 }).pass).toBe(false); + expect(evaluateGate({ ...good, oosTrades: 20 }).pass).toBe(false); + expect(evaluateGate({ ...good, oosMaxDrawdownPct: 0.3 }).pass).toBe(false); + expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.4, trades: 6 } }).pass).toBe(false); + expect(evaluateGate({ ...good, avgTrainPf: 3.1 }).pass).toBe(false); + // PF<0.5 zählt nur bei ≥5 Trades im Fenster + expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.2, trades: 3 } }).pass).toBe(true); +}); diff --git a/src/server/backtest/walkforward.ts b/src/server/backtest/walkforward.ts new file mode 100644 index 0000000..79977b8 --- /dev/null +++ b/src/server/backtest/walkforward.ts @@ -0,0 +1,160 @@ +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; +} + +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[]; +} + +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 windowsWithTrades = results.filter((r) => r.testMetrics.trades > 0); + const worst = windowsWithTrades.reduce( + (acc, r) => (r.testMetrics.profitFactor < acc.profitFactor ? { profitFactor: r.testMetrics.profitFactor, trades: r.testMetrics.trades } : acc), + { profitFactor: Infinity, trades: 0 }, + ); + 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 }; +}