From 8ad1516665f32f8c293ab41124e0b6705b20cd9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:37:42 +0000 Subject: [PATCH] feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch) Co-Authored-By: Claude Fable 5 --- src/server/backtest/runner.test.ts | 72 +++++++++++++++++ src/server/backtest/runner.ts | 123 +++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 src/server/backtest/runner.test.ts create mode 100644 src/server/backtest/runner.ts diff --git a/src/server/backtest/runner.test.ts b/src/server/backtest/runner.test.ts new file mode 100644 index 0000000..7edfbf5 --- /dev/null +++ b/src/server/backtest/runner.test.ts @@ -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([['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([['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([['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))); +}); diff --git a/src/server/backtest/runner.ts b/src/server/backtest/runner.ts new file mode 100644 index 0000000..4cc4528 --- /dev/null +++ b/src/server/backtest/runner.ts @@ -0,0 +1,123 @@ +import type { Candle, Pair } from '../types'; +import { PAIRS } from '../types'; +import { aggregate4h, H4 } from '../market/aggregate'; +import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend'; +import { updateChandelier } from '../strategy/chandelier'; +import { sizePosition, type RiskConfig } from '../engine/sizing'; +import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio'; +import type { EquityPoint } from './metrics'; + +export interface BacktestConfig { + startCapital: number; + risk: RiskConfig; + exec: ExecConfig; + maxPositions: number; + params: StrategyParams; + tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup + tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt +} + +export interface BacktestResult { + trades: ClosedTrade[]; + equityCurve: EquityPoint[]; + finalEquity: number; +} + +interface PairContext { + pair: Pair; + c15: Candle[]; + c4h: Candle[]; + ind: IndicatorSet; + next4h: number; // Index der nächsten noch nicht verarbeiteten 4h-Candle +} + +export function runBacktest(candles15ByPair: Map, cfg: BacktestConfig): BacktestResult { + const portfolio = new Portfolio(cfg.startCapital, cfg.exec); + const lastClose = new Map(); + const equityCurve: EquityPoint[] = []; + + const contexts: PairContext[] = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => { + const c15 = candles15ByPair.get(pair)!; + const c4h = aggregate4h(c15); + return { pair, c15, c4h, ind: computeIndicators(c4h, cfg.params), next4h: 0 }; + }); + + // Gemergte 15m-Timeline (Pair-Reihenfolge stabil → deterministisch) + const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; + for (const ctx of contexts) { + for (const candle of ctx.c15) { + if (candle.ts < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle }); + } + } + timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair) - PAIRS.indexOf(b.pair)); + + const byPair = new Map(contexts.map((c) => [c.pair, c])); + let lastEquityBucket = -1; + + for (const { ts, pair, candle } of timeline) { + const ctx = byPair.get(pair)!; + const bucket = Math.floor(ts / H4) * H4; + + // 1) Neu abgeschlossene 4h-Candles dieses Pairs verarbeiten (alles < aktueller Bucket) + while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { + const i = ctx.next4h++; + const bar = ctx.c4h[i]; + + // 1a) Trailing-Stop der offenen Position nachziehen + const pos = portfolio.positions.get(pair); + if (pos) { + const next = updateChandelier( + { highestHigh: pos.highestHigh, stop: pos.stop }, + bar.high, + ctx.ind.atr[i], + cfg.params.atrMultiplier, + ); + pos.highestHigh = next.highestHigh; + pos.stop = next.stop; + } + + // 1b) Entry-Evaluation + const barCloseTs = bar.ts + H4; + if ( + !portfolio.positions.has(pair) && + portfolio.positions.size < cfg.maxPositions && + barCloseTs >= cfg.tradeFrom && + barCloseTs < cfg.tradeTo + ) { + const ev = evaluateAt(ctx.c4h, ctx.ind, i); + if (ev.signal === 'long') { + const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; + const equity = portfolio.equity(lastClose); + const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk); + if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount); + } + } + } + + // 2) Stop-Check auf der 15m-Candle + const pos = portfolio.positions.get(pair); + if (pos && candle.low <= pos.stop) { + const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill + portfolio.close(pair, ts, exitPrice, 'trailing_stop'); + } + + lastClose.set(pair, candle.close); + + // 3) Equity-Punkt einmal pro 4h-Bucket + if (bucket !== lastEquityBucket && ts >= cfg.tradeFrom) { + lastEquityBucket = bucket; + equityCurve.push({ ts: bucket, equity: portfolio.equity(lastClose) }); + } + } + + // Offene Positionen glattstellen + for (const pair of [...portfolio.positions.keys()]) { + portfolio.close(pair, cfg.tradeTo, lastClose.get(pair)!, 'end_of_data'); + } + + return { + trades: portfolio.trades, + equityCurve, + finalEquity: portfolio.equity(lastClose), + }; +}