diff --git a/src/server/backtest/walkforward.test.ts b/src/server/backtest/walkforward.test.ts index b661897..fe027df 100644 --- a/src/server/backtest/walkforward.test.ts +++ b/src/server/backtest/walkforward.test.ts @@ -1,5 +1,8 @@ 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; @@ -34,3 +37,94 @@ test('Gate: alle Kriterien müssen bestehen', () => { // PF<0.5 zählt nur bei ≥5 Trades im Fenster 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([['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 diff --git a/src/server/backtest/walkforward.ts b/src/server/backtest/walkforward.ts index 79977b8..9f2411e 100644 --- a/src/server/backtest/walkforward.ts +++ b/src/server/backtest/walkforward.ts @@ -13,6 +13,11 @@ export interface Window { 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[] { const out: Window[] = []; for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) {