test: Walk-Forward End-to-End-Leak-Test, Cold-Start dokumentiert

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:54:01 +00:00
parent f19f5592fa
commit f318446ebf
2 changed files with 100 additions and 1 deletions

View File

@@ -1,5 +1,8 @@
import { expect, test } from 'bun:test'; import { expect, test } from 'bun:test';
import { buildWindows, evaluateGate, PARAM_GRID } from './walkforward'; import { buildWindows, evaluateGate, PARAM_GRID, runWalkForward } from './walkforward';
import { DEFAULT_RISK } from '../engine/sizing';
import { DEFAULT_EXEC } from '../engine/portfolio';
import type { Candle, Pair } from '../types';
const DAY = 24 * 60 * 60 * 1000; const DAY = 24 * 60 * 60 * 1000;
@@ -34,3 +37,94 @@ test('Gate: alle Kriterien müssen bestehen', () => {
// PF<0.5 zählt nur bei ≥5 Trades im Fenster // PF<0.5 zählt nur bei ≥5 Trades im Fenster
expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.2, trades: 3 } }).pass).toBe(true); expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.2, trades: 3 } }).pass).toBe(true);
}); });
/**
* Synthetische 15m-Candle-Serie für einen einzelnen Walk-Forward-Durchlauf.
*
* Preis-Modell: stetiger Aufwärtstrend (+0.006%/15m) + deterministisches
* Rauschen (LCG-PRNG, Seed 42) + alle 480 Candles (≈5 Tage) ein -1.5%
* Pullback. Das erzeugt wiederholt Donchian-Breakout-Signale, während der
* Chandelier-Stop durch Pullbacks ausgelöst wird → tatsächlich geschlossene Trades.
*
* Länge: 151 Tage × 96 Candles/Tag = 14496 15m-Candles
* → buildWindows(dataFrom, dataTo) mit Default 120d/30d/30d ergibt genau 1 Fenster.
* Laufzeit: ~40ms (18 PARAM_GRID-Kombos × 1 Fenster).
*/
function buildSyntheticCandles(): { candles: Candle[]; origin: number } {
const INTERVAL = 15 * 60 * 1000;
const H4 = 4 * 60 * 60 * 1000;
// Starte auf einer sauberen 4h-Grenze
const origin = 1_700_000_000_000 - (1_700_000_000_000 % H4);
const N = 151 * 96; // 14496
// Deterministischer LCG-PRNG (Seed 42)
let seed = 42;
const rand = () => { seed = (seed * 1664525 + 1013904223) & 0xffffffff; return (seed >>> 0) / 0xffffffff; };
const candles: Candle[] = [];
let price = 30_000;
for (let i = 0; i < N; i++) {
const ts = origin + i * INTERVAL;
const noise = (rand() - 0.5) * 0.0005; // ±0.025% pro 15m
price = price * (1 + 0.00006 + noise); // +0.006% Drift
if (i > 0 && i % 480 === 240) price *= 0.985; // -1.5% Pullback alle ~5 Tage
const open = price;
const close = price;
const high = price * (1 + rand() * 0.002);
const low = price * (1 - rand() * 0.002);
candles.push({ ts, open, high, low, close, volume: 10 });
}
return { candles, origin };
}
test('runWalkForward: OOS-Leak-Test mit synthetischen Daten', () => {
const { candles, origin } = buildSyntheticCandles();
const dataFrom = origin;
const dataTo = origin + 151 * DAY;
const data = new Map<Pair, Candle[]>([['BTC_USDT', candles]]);
const result = runWalkForward(
data,
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 },
dataFrom,
dataTo,
);
// (c) Mindestens 1 Fenster
expect(result.windows.length).toBeGreaterThanOrEqual(1);
let totalTrades = 0;
for (const wr of result.windows) {
const { window: w, testTrades, testEquityCurve } = wr;
// (a) Jeder Test-Trade hat entryTs innerhalb des Test-Fensters
for (const trade of testTrades) {
expect(trade.entryTs).toBeGreaterThanOrEqual(w.testFrom);
expect(trade.entryTs).toBeLessThan(w.testTo);
// exitTs darf == testTo sein (forced close am Ende)
expect(trade.exitTs).toBeGreaterThanOrEqual(w.testFrom);
expect(trade.exitTs).toBeLessThanOrEqual(w.testTo);
}
// (b) Jeder Equity-Punkt liegt nach testFrom
for (const pt of testEquityCurve) {
expect(pt.ts).toBeGreaterThanOrEqual(w.testFrom);
}
totalTrades += testTrades.length;
}
// (d) OOS-Equity-Kurve ist nicht-fallend in ts
for (let i = 1; i < result.oosEquityCurve.length; i++) {
expect(result.oosEquityCurve[i].ts).toBeGreaterThanOrEqual(result.oosEquityCurve[i - 1].ts);
}
// Sanity-Check: mindestens 1 Trade (damit Assertions nicht vacuous sind)
expect(totalTrades).toBeGreaterThan(0);
console.log(`[walkforward e2e] Fenster: ${result.windows.length}, OOS-Trades gesamt: ${totalTrades}`);
}, 15_000); // 15s Timeout für 18 PARAM_GRID-Kombos × 1 Fenster

View File

@@ -13,6 +13,11 @@ export interface Window {
testTo: number; testTo: number;
} }
/**
* Rollierende Train/Test-Fenster. Hinweis: Im ersten Fenster sind die ersten
* ~EMA200-Perioden (≈33 Tage auf 4h) Indikator-Cold-Start — effektive
* Train-Länge von Fenster 0 ist entsprechend kürzer als bei späteren Fenstern.
*/
export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, testDays = 30, stepDays = 30): Window[] { export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, testDays = 30, stepDays = 30): Window[] {
const out: Window[] = []; const out: Window[] = [];
for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) { for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) {