From 26166c5f3cf2c87ad0cb673b864d2fdd8ee42514 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:12:11 +0000 Subject: [PATCH] =?UTF-8?q?fix:=20Gate-Check=204=20=E2=80=94=20Fenster=20 --- src/server/backtest/walkforward.test.ts | 19 ++++++++++++++++++- src/server/backtest/walkforward.ts | 16 +++++++++++----- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/server/backtest/walkforward.test.ts b/src/server/backtest/walkforward.test.ts index fe027df..d545a82 100644 --- a/src/server/backtest/walkforward.test.ts +++ b/src/server/backtest/walkforward.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { buildWindows, evaluateGate, PARAM_GRID, runWalkForward } from './walkforward'; +import { buildWindows, evaluateGate, PARAM_GRID, pickWorstEligibleWindow, runWalkForward } from './walkforward'; import { DEFAULT_RISK } from '../engine/sizing'; import { DEFAULT_EXEC } from '../engine/portfolio'; import type { Candle, Pair } from '../types'; @@ -38,6 +38,23 @@ test('Gate: alle Kriterien müssen bestehen', () => { expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.2, trades: 3 } }).pass).toBe(true); }); +test('Gate-Check 4: Fenster mit <5 Trades maskiert kein verletzendes Fenster', () => { + // PF 0 bei 3 Trades (irrelevant) darf PF 0 bei 6 Trades (Verstoß) nicht verdecken + const worst = pickWorstEligibleWindow([ + { profitFactor: 0, trades: 3 }, + { profitFactor: 0, trades: 6 }, + { profitFactor: 1.4, trades: 10 }, + ]); + expect(worst).toEqual({ profitFactor: 0, trades: 6 }); + expect(evaluateGate({ + oosProfitFactor: 1.5, oosTrades: 30, oosMaxDrawdownPct: 0.15, + worstWindow: worst, avgTrainPf: 2.0, + }).pass).toBe(false); + + // keine Fenster mit >=5 Trades → Check 4 besteht + expect(pickWorstEligibleWindow([{ profitFactor: 0, trades: 3 }])).toEqual({ profitFactor: Infinity, trades: 0 }); +}); + /** * Synthetische 15m-Candle-Serie für einen einzelnen Walk-Forward-Durchlauf. * diff --git a/src/server/backtest/walkforward.ts b/src/server/backtest/walkforward.ts index 9f2411e..5e0d944 100644 --- a/src/server/backtest/walkforward.ts +++ b/src/server/backtest/walkforward.ts @@ -62,6 +62,16 @@ export interface GateResult { 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; @@ -145,11 +155,7 @@ export function runWalkForward( } 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 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);