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; // adxThreshold: 0 — neutralisiert den ADX-Filter, damit Breakout-Tests unverändert bleiben const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5, adxThreshold: 0 }; /** * 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, allowShort: false, }); 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, allowShort: false, // nach Serien-Ende }); expect(result.trades).toHaveLength(0); }); test('Stop-Order ist ab Entry aktiv: Low der Entry-Candle unter Stop → sofortiger Exit (pessimistisch)', () => { const s: Candle[] = []; let b = 0; for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100)); s.push(...flat4h(b, 100, 111, 100, 110)); b += H4; // Breakout, Entry ~110, ATR klein → Stop nahe // Erste Candle des Folge-Buckets reißt mit Low 80 sofort den Stop s.push(...flat4h(b, 110, 110, 80, 109)); b += H4; s.push(...flat4h(b, 109, 110, 108, 109)); b += H4; const data = new Map([['BTC_USDT', s]]); const result = runBacktest(data, { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false, }); expect(result.trades).toHaveLength(1); expect(result.trades[0].exitReason).toBe('trailing_stop'); // Exit in derselben 4h-Periode wie der Entry expect(result.trades[0].exitTs - result.trades[0].entryTs).toBeLessThan(H4); }); test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge', () => { // series() crasht im selben Bucket wie der Entry (gleicher ts) → BTC-Position wird // bereits geschlossen, bevor ETH evaluiert wird, sodass ETH noch reinkommt. // Daher eigene Serie: ein ruhiger Halte-Bucket nach dem Entry verhindert das. function seriesWithHold(): Candle[] { const s: Candle[] = []; let b = 0; for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100)); s.push(...flat4h(b, 100, 111, 100, 110)); b += H4; // Breakout, Entry s.push(...flat4h(b, 110, 112, 108, 111)); b += H4; // Halte-Bucket, kein Crash s.push(...flat4h(b, 111, 111, 80, 85)); b += H4; // Crash s.push(...flat4h(b, 85, 86, 84, 85)); b += H4; return s; } const data = new Map([ ['BTC_USDT', seriesWithHold()], ['ETH_USDT', seriesWithHold()], ]); const result = runBacktest(data, { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1, params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false, }); // beide Pairs haben identische Serien → beide signalisieren; nur BTC (erster in PAIRS) darf expect(result.trades).toHaveLength(1); expect(result.trades[0].pair).toBe('BTC_USDT'); }); 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, allowShort: false, }; expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg))); }); /** * Synthetische Short-Serie: * - 7 Plateau-Buckets @ 100 (lows=100, highs=100): donchianLow=100, EMA5=100 * → close(100) NICHT < donchianLow(100) → kein Short-Signal auf dem Plateau * - Breakdown-Bucket: close=85 < donchianLow(100) UND < EMA5(95) → Short-Signal * - Rallye-Bucket: High 130 reißt den Short-Stop (≈85 + 1×ATR) * - tradeTo = Breakdown-barCloseTs + H4: blockiert Long-Entry nach dem Short-Exit */ function shortSeries(): { candles: Candle[]; breakdownBarCloseTs: number } { const s: Candle[] = []; let b = 0; // 7 Plateau-Buckets mit exakt flachen Candles (low=100=close=high) for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 100, 100, 100)); const breakdownBucketStart = b; // Breakdown: close=85 < donchianLow(100) AND < EMA5(~95) s.push(...flat4h(b, 100, 100, 84, 85)); b += H4; // Rallye: High 130 reißt Short-Stop (Stop ≈ 85+ATR) s.push(...flat4h(b, 85, 130, 85, 120)); b += H4; // Abschluss-Bucket (damit Rallye als abgeschlossen gilt) s.push(...flat4h(b, 120, 121, 119, 120)); b += H4; // tradeTo: Entry-Zeitpunkt des Breakdown = breakdownBucketStart + H4 // +H4 dahinter blockiert die Rallye von einem Long-Entry return { candles: s, breakdownBarCloseTs: breakdownBucketStart + H4 }; } test('Short-Breakout → Short-Entry auf 4h-Close, Rallye → Stop-Exit auf 15m', () => { const { candles, breakdownBarCloseTs } = shortSeries(); // tradeTo = breakdownBarCloseTs + H4: Short-Entry (barCloseTs=breakdownBarCloseTs) ist erlaubt // (breakdownBarCloseTs < tradeTo), aber der nächste Bar (barCloseTs=breakdownBarCloseTs+H4=tradeTo) // ist blockiert → kein Long-Entry nach dem Short-Exit const tradeTo = breakdownBarCloseTs + H4; const data = new Map([['BTC_USDT', candles]]); const result = runBacktest(data, { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, params: P, tradeFrom: 0, tradeTo, allowShort: true, }); expect(result.trades).toHaveLength(1); const t = result.trades[0]; // Entry: Short bei Close 85, Fill = 85*(1−slippage) = 85*0.9995 expect(t.entryPrice).toBeCloseTo(85 * 0.9995); expect(t.exitReason).toBe('trailing_stop'); expect(t.side).toBe('short'); // Verlustbringender Short (Preis stieg stark) → pnl < 0 expect(t.pnl).toBeLessThan(0); // Sanity: gleiche Daten mit allowShort=false → kein Trade // (kein Long-Signal: nach Plateau breakout close=85 ist weit unter EMA → blocked) const resultLongOnly = runBacktest(data, { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, params: P, tradeFrom: 0, tradeTo, allowShort: false, }); expect(resultLongOnly.trades).toHaveLength(0); });