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:
@@ -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<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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user