Files
trade-kuns/src/server/backtest/runner.test.ts

174 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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, 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<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, 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<Pair, Candle[]>([['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<Pair, Candle[]>([
['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<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, 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<Pair, Candle[]>([['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*(1slippage) = 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);
});