feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
72
src/server/backtest/runner.test.ts
Normal file
72
src/server/backtest/runner.test.ts
Normal 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)));
|
||||
});
|
||||
Reference in New Issue
Block a user