feat: Walk-Forward-Runner mit Grid-Search und Deploy-Gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:46:21 +00:00
parent 25a37f74db
commit f19f5592fa
2 changed files with 196 additions and 0 deletions

View File

@@ -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);
});

View File

@@ -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<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>;
/** 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<Pair, Candle[]>,
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 };
}