174 lines
8.1 KiB
TypeScript
174 lines
8.1 KiB
TypeScript
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*(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);
|
||
});
|