feat: Walk-Forward-Runner mit Grid-Search und Deploy-Gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
36
src/server/backtest/walkforward.test.ts
Normal file
36
src/server/backtest/walkforward.test.ts
Normal 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);
|
||||||
|
});
|
||||||
160
src/server/backtest/walkforward.ts
Normal file
160
src/server/backtest/walkforward.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user