feat: Short-Seite im Runner + Walk-Forward-CLI (--shorts Flag)

- BacktestConfig.allowShort: boolean (required, explizit)
- Runner: Short-Entry (stop = close + mult×ATR), updateChandelierShort für Trail,
  Stop-Check auf High >= stop, Gap-Fill nach oben
- Bestehende Runner-Tests: allowShort: false ergänzt (Verhalten byte-identisch)
- Neuer E2E-Test: Short-Breakout → trailing_stop; Long-Only-Sanity-Check
- walkforward.ts script: --shorts Flag, Ausgabe "Shorts: AN/AUS"
- walkforward.test.ts: allowShort: false ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:33:58 +00:00
parent 0e1b477e27
commit 736db184ab
4 changed files with 106 additions and 22 deletions

View File

@@ -41,7 +41,7 @@ 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,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
});
expect(result.trades).toHaveLength(1);
const t = result.trades[0];
@@ -57,7 +57,7 @@ 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, // nach Serien-Ende
params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false, // nach Serien-Ende
});
expect(result.trades).toHaveLength(0);
});
@@ -73,7 +73,7 @@ test('Stop-Order ist ab Entry aktiv: Low der Entry-Candle unter Stop → soforti
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,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
});
expect(result.trades).toHaveLength(1);
expect(result.trades[0].exitReason).toBe('trailing_stop');
@@ -101,7 +101,7 @@ test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge',
]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
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);
@@ -112,7 +112,61 @@ 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,
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);
});