diff --git a/src/server/backtest/runner.test.ts b/src/server/backtest/runner.test.ts index 7edfbf5..f2973bf 100644 --- a/src/server/backtest/runner.test.ts +++ b/src/server/backtest/runner.test.ts @@ -62,6 +62,52 @@ test('tradeFrom verhindert Entries im Warmup-Fenster', () => { 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, + }); + 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, + }); + // 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 = { diff --git a/src/server/backtest/runner.ts b/src/server/backtest/runner.ts index 4cc4528..35f5e85 100644 --- a/src/server/backtest/runner.ts +++ b/src/server/backtest/runner.ts @@ -58,6 +58,9 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC const ctx = byPair.get(pair)!; const bucket = Math.floor(ts / H4) * H4; + // Bekannte Grenze: 4h-Bars eines Pairs werden erst verarbeitet, wenn dessen + // nächste 15m-Candle eintrifft — bei Datenlücken eines Pairs verschiebt sich + // dessen Verarbeitung relativ zu anderen Pairs (betrifft maxPositions-Reihenfolge). // 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++; @@ -94,7 +97,10 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC } } - // 2) Stop-Check auf der 15m-Candle + // 2) Stop-Check auf der 15m-Candle. + // Bewusst AUCH auf der Entry-Candle (Entry = Open-Zeitpunkt dieser Candle, + // ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv). + // Pessimistisch-realistisch, nicht "wegoptimieren". 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