feat: Walk-Forward-Runner mit Grid-Search und Deploy-Gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
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