From 736db184ab4f42bce525104114b52d7799116709 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 21:33:58 +0000 Subject: [PATCH] feat: Short-Seite im Runner + Walk-Forward-CLI (--shorts Flag) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/server/backtest/runner.test.ts | 64 +++++++++++++++++++++++-- src/server/backtest/runner.ts | 58 ++++++++++++++++------ src/server/backtest/walkforward.test.ts | 2 +- src/server/scripts/walkforward.ts | 4 +- 4 files changed, 106 insertions(+), 22 deletions(-) diff --git a/src/server/backtest/runner.test.ts b/src/server/backtest/runner.test.ts index f2973bf..6b13a70 100644 --- a/src/server/backtest/runner.test.ts +++ b/src/server/backtest/runner.test.ts @@ -41,7 +41,7 @@ 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, + 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([['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([['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([['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([['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); +}); diff --git a/src/server/backtest/runner.ts b/src/server/backtest/runner.ts index 35f5e85..e3275ca 100644 --- a/src/server/backtest/runner.ts +++ b/src/server/backtest/runner.ts @@ -2,7 +2,7 @@ import type { Candle, Pair } from '../types'; import { PAIRS } from '../types'; import { aggregate4h, H4 } from '../market/aggregate'; import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend'; -import { updateChandelier } from '../strategy/chandelier'; +import { updateChandelier, updateChandelierShort } from '../strategy/chandelier'; import { sizePosition, type RiskConfig } from '../engine/sizing'; import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio'; import type { EquityPoint } from './metrics'; @@ -15,6 +15,7 @@ export interface BacktestConfig { params: StrategyParams; tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt + allowShort: boolean; } export interface BacktestResult { @@ -69,14 +70,25 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC // 1a) Trailing-Stop der offenen Position nachziehen const pos = portfolio.positions.get(pair); if (pos) { - const next = updateChandelier( - { highestHigh: pos.highestHigh, stop: pos.stop }, - bar.high, - ctx.ind.atr[i], - cfg.params.atrMultiplier, - ); - pos.highestHigh = next.highestHigh; - pos.stop = next.stop; + if (pos.side === 'short') { + const next = updateChandelierShort( + { lowestLow: pos.trailExtreme, stop: pos.stop }, + bar.low, + ctx.ind.atr[i], + cfg.params.atrMultiplier, + ); + pos.trailExtreme = next.lowestLow; + pos.stop = next.stop; + } else { + const next = updateChandelier( + { highestHigh: pos.trailExtreme, stop: pos.stop }, + bar.high, + ctx.ind.atr[i], + cfg.params.atrMultiplier, + ); + pos.trailExtreme = next.highestHigh; + pos.stop = next.stop; + } } // 1b) Entry-Evaluation @@ -87,12 +99,17 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC barCloseTs >= cfg.tradeFrom && barCloseTs < cfg.tradeTo ) { - const ev = evaluateAt(ctx.c4h, ctx.ind, i); + const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.allowShort); if (ev.signal === 'long') { const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; const equity = portfolio.equity(lastClose); - const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk); - if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount); + const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'long'); + if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'long'); + } else if (ev.signal === 'short') { + const initialStop = ev.close + cfg.params.atrMultiplier * ev.atr; + const equity = portfolio.equity(lastClose); + const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'short'); + if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'short'); } } } @@ -102,9 +119,20 @@ export function runBacktest(candles15ByPair: Map, cfg: BacktestC // 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 - portfolio.close(pair, ts, exitPrice, 'trailing_stop'); + if (pos) { + if (pos.side === 'short') { + // Short: Stop wird getriggert wenn High >= Stop (Deckungskauf) + if (candle.high >= pos.stop) { + const exitPrice = candle.open > pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill (höherer Preis) + portfolio.close(pair, ts, exitPrice, 'trailing_stop'); + } + } else { + // Long: Stop wird getriggert wenn Low <= Stop + if (candle.low <= pos.stop) { + const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill + portfolio.close(pair, ts, exitPrice, 'trailing_stop'); + } + } } lastClose.set(pair, candle.close); diff --git a/src/server/backtest/walkforward.test.ts b/src/server/backtest/walkforward.test.ts index d545a82..009ca47 100644 --- a/src/server/backtest/walkforward.test.ts +++ b/src/server/backtest/walkforward.test.ts @@ -105,7 +105,7 @@ test('runWalkForward: OOS-Leak-Test mit synthetischen Daten', () => { const result = runWalkForward( data, - { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 }, + { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort: false }, dataFrom, dataTo, ); diff --git a/src/server/scripts/walkforward.ts b/src/server/scripts/walkforward.ts index 83a7396..b6f4494 100644 --- a/src/server/scripts/walkforward.ts +++ b/src/server/scripts/walkforward.ts @@ -19,7 +19,9 @@ for (const pair of PAIRS) { console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()} → ${cov.to.toISOString()})`); } -const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 }; +const allowShort = process.argv.includes('--shorts'); +const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort }; +console.log(`Shorts: ${allowShort ? 'AN' : 'AUS'}`); console.log(`\nWalk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`); const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg));