feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:37:42 +00:00
parent 2fa4695f1b
commit 8ad1516665
2 changed files with 195 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { expect, test } from 'bun:test';
import type { Candle, Pair } from '../types';
import { runBacktest } from './runner';
import { DEFAULT_RISK } from '../engine/sizing';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { H4 } from '../market/aggregate';
const M15 = 15 * 60 * 1000;
const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5 };
/**
* Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle
* (Close 110), dann Absturz unter den Stop.
* Jede 4h-Candle besteht aus 16 flachen 15m-Candles mit definiertem OHLC.
*/
function flat4h(bucketStart: number, o: number, h: number, l: number, cl: number): Candle[] {
const out: Candle[] = [];
for (let i = 0; i < 16; i++) {
// alle 15m-Candles tragen die 4h-Range, Close interpoliert linear o→cl
const c = o + ((cl - o) * (i + 1)) / 16;
out.push({ ts: bucketStart + i * M15, open: o, high: h, low: l, close: c, volume: 1 });
}
return out;
}
function series(): Candle[] {
const s: Candle[] = [];
let b = 0;
// 7 Plateau-Buckets: Closes 100, Highs 101 → Donchian-High 101, EMA ~100
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100));
// Breakout-Bucket: Close 110 > 101 (Donchian) und > EMA5
s.push(...flat4h(b, 100, 111, 100, 110)); b += H4;
// Crash-Bucket: Low 80 reißt jeden Stop
s.push(...flat4h(b, 110, 110, 80, 85)); b += H4;
// Abschluss-Bucket, damit der Crash-Bucket als abgeschlossen gilt
s.push(...flat4h(b, 85, 86, 84, 85)); b += H4;
return s;
}
test('Breakout → Entry auf 4h-Close, Crash → Stop-Exit auf 15m', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
});
expect(result.trades).toHaveLength(1);
const t = result.trades[0];
expect(t.entryPrice).toBeCloseTo(110 * 1.0005); // 4h-Close + Slippage
expect(t.exitReason).toBe('trailing_stop');
expect(t.pnl).toBeLessThan(0);
// Verlust ≈ 1R (Stop = Entry 1×ATR), Fees machen ihn etwas größer
expect(t.r).toBeLessThan(-0.8);
expect(t.r).toBeGreaterThan(-1.6);
});
test('tradeFrom verhindert Entries im Warmup-Fenster', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, // nach Serien-Ende
});
expect(result.trades).toHaveLength(0);
});
test('Determinismus: identischer Input → identisches Ergebnis', () => {
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
const cfg = {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
};
expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg)));
});