From b7e81374f1f602cb07df039c43987d4dd7b90ebc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 22:09:16 +0000 Subject: [PATCH] feat: Momentum-Rotation-Backtest (dual momentum, weekly, fixe Parameter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues Modul rotation.ts: wöchentliche Rotation über BTC/ETH/SOL/XRP_USDT auf 4h-Basis, Long-Only, top-1 Pair mit positivem 30-Tage-Momentum. 4 TDD-Tests (Leader-Selektion, Flip/Rotation, Cash-Modus, Determinismus). ClosedTrade.exitReason um 'rotation' erweitert. Co-Authored-By: Claude Fable 5 --- src/server/backtest/rotation.test.ts | 241 +++++++++++++++++++++++++++ src/server/backtest/rotation.ts | 172 +++++++++++++++++++ src/server/engine/portfolio.ts | 2 +- 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 src/server/backtest/rotation.test.ts create mode 100644 src/server/backtest/rotation.ts diff --git a/src/server/backtest/rotation.test.ts b/src/server/backtest/rotation.test.ts new file mode 100644 index 0000000..49887db --- /dev/null +++ b/src/server/backtest/rotation.test.ts @@ -0,0 +1,241 @@ +import { expect, test } from 'bun:test'; +import type { Candle, Pair } from '../types'; +import { runRotationBacktest, type RotationConfig } from './rotation'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { H4 } from '../market/aggregate'; + +const M15 = 15 * 60 * 1000; +const WEEK = 7 * 24 * 3600 * 1000; + +/** + * Synthetische 15m-Serie für einen 4h-Bucket. + * Alle 16 Candles tragen denselben OHLC; Close ist konstant = cl. + * ts = Bucket-Start (muss ein exakter H4-Vielfaches sein). + */ +function flat4h(bucketStart: number, cl: number): Candle[] { + const out: Candle[] = []; + for (let i = 0; i < 16; i++) { + out.push({ ts: bucketStart + i * M15, open: cl, high: cl, low: cl, close: cl, volume: 1 }); + } + return out; +} + +/** + * Baut eine 15m-Candleserie für ein Pair aus einer Liste von (bucketStart, close)-Paaren. + * Reihenfolge muss aufsteigend nach bucketStart sein. + */ +function buildSeries(bars: { ts: number; cl: number }[]): Candle[] { + return bars.flatMap(({ ts, cl }) => flat4h(ts, cl)); +} + +/** + * Basis-Konfiguration: lookback = 3 Bars (statt 180), damit Tests mit wenigen Bars funktionieren. + * tradeFrom = 0, tradeTo = sehr groß. + * + * Hinweis: Mit lookbackBars=3 gilt momentum = close[i] / close[i-3] − 1. + */ +const BASE_CFG: RotationConfig = { + startCapital: 1000, + exec: DEFAULT_EXEC, + lookbackBars: 3, + tradeFrom: 0, + tradeTo: Number.MAX_SAFE_INTEGER, +}; + +// -------------------------------------------------------------------------- +// Test (a): Pair A stark steigend, Pair B flach/fallend +// → System hält A; keine B-Trades +// -------------------------------------------------------------------------- +test('(a) starkes A vs. flaches B → hält A, keine B-Trades', () => { + // 7 Bars Warmup (lookback=3 → ab Bar 3 kann Momentum berechnet werden) + // Bar 0..5 auf einem beliebigen H4-Raster (wir beginnen bei Ts=0) + // Rebalance-Trigger: erster Bar im Window = Bar 0 (firstBarInWindow). + // Momentum auf Bar 3: A[3]/A[0] - 1 = 150/100-1 = 0.5 > 0 → A gewinnt. + // + // A steigt: 100, 110, 130, 150, 200, 250, 300 + // B flach: 100, 100, 100, 100, 100, 100, 100 + // + // Woche 1-Trigger: erster Bar (Bar 0, ts=H4, weekBucket=0 wenn H4= 3. + // Momentum A: close[42]/close[39] - 1; wir geben A monoton steigende Preise. + + const numBars = 50; // 50 Bars reichen für 2 Wochen (42 Bars/Woche) + const barsA: { ts: number; cl: number }[] = []; + const barsB: { ts: number; cl: number }[] = []; + for (let i = 0; i < numBars; i++) { + const ts = i * H4; + barsA.push({ ts, cl: 100 + i * 5 }); // A: 100, 105, 110, … (stark steigend) + barsB.push({ ts, cl: 100 }); // B: konstant flach + } + + const data = new Map([ + ['BTC_USDT', buildSeries(barsA)], + ['ETH_USDT', buildSeries(barsB)], + ]); + + const result = runRotationBacktest(data, BASE_CFG); + + // Muss mindestens 1 Trade haben (A wird geöffnet und am Ende geschlossen) + expect(result.trades.length).toBeGreaterThanOrEqual(1); + + // Alle Trades müssen BTC_USDT (A) sein — kein Trade auf ETH_USDT (B) + const btcTrades = result.trades.filter((t) => t.pair === 'BTC_USDT'); + const ethTrades = result.trades.filter((t) => t.pair === 'ETH_USDT'); + expect(btcTrades.length).toBeGreaterThanOrEqual(1); + expect(ethTrades.length).toBe(0); + + // Alle Trades sind Long + for (const t of result.trades) { + expect(t.side).toBe('long'); + } + + // A steigt → PnL positiv (abzüglich Gebühren) + const totalPnl = result.trades.reduce((s, t) => s + t.pnl, 0); + expect(totalPnl).toBeGreaterThan(0); +}); + +// -------------------------------------------------------------------------- +// Test (b): Leader-Flip: A steigt zunächst, B beschleunigt → Rotation A→B +// -------------------------------------------------------------------------- +test('(b) Leader-Flip: Rotation von A nach B erkennbar', () => { + // Strategie: + // Phase 1 (Bars 0..44, Woche 0): A steigt, B flach → A ist Leader. + // Phase 2 (Bars 45..86, Woche 1): A stagniert/fällt, B steigt stark → B wird Leader. + // + // lookbackBars=3: Momentum = close[i]/close[i-3] - 1. + // Trigger bei Bar 42 (weekBucket=1): prüfen close[42]/close[39] für A und B. + // + // Damit Bar 42 (weekBucket=1) B besser macht als A: + // A-Preis ab Bar 39: konstant 200 (kein Wachstum → mom_A = 200/200-1 = 0) + // B-Preis ab Bar 39: 100, 110, 120, 130 → mom_B = 130/100-1 = 0.3 > 0 + // + // Phase 1 (Bars 0..41, weekBucket=0): + // A steigt: 100+i*3; B: 100 konstant + // Trigger bei Bar 0 (firstBar): idx=0 < lookback=3 → Cash (ok, kein Trade) + // + // Woche-1-Trigger bei Bar 42 (erster Bar mit barCloseTs >= 1*WEEK): + // barCloseTs von Bar 42 = 43*H4 = 620800000 ms > WEEK=604800000 → weekBucket=1 ✓ + // mom_A = A[42]/A[39]-1 = A[42]/A[39]-1 + // A[39]=100+39*3=217, A[40]=220, A[41]=223, A[42]=226 → mom_A=226/217-1≈0.041 + // B[39]=100, B[40]=110, B[41]=120, B[42]=130 → mom_B=130/100-1=0.3 + // → B gewinnt → Rotation A→B falls A zuvor gehalten wurde + // + // Damit A vorher gehalten wird, brauchen wir einen früheren Trigger mit A-Gewinn. + // Erster Bar in Window (Bar 0): idx=0 < lookback → kein Momentum → Cash. + // Das bedeutet beim ersten Trigger kein Trade. A wird erst beim nächsten + // Wochen-Trigger eröffnet (Bar 42) — aber dann gewinnt B schon. + // + // → Wir brauchen mindestens 3 Wochen-Epochen: + // Epoche 0 (Bars 0..41): erster Trigger → idx < 3 → Cash + // Epoche 1 (Bars 42..83): 2. Trigger (weekBucket=1) → A hat mom>0, B=0 → A eröffnet + // Epoche 2 (Bars 84..125): 3. Trigger (weekBucket=2) → B hat mom>A → Rotation A→B + // + // Preisdesign: + // Epoche 0 (Bars 0..41): A: 100+i*3, B: 100 + // Epoche 1 (Bars 42..83): A: 100+i*3 (steigt weiter), B: 100 (konstant) + // → Trigger bei Bar 42: mom_A = A[42]/A[39]-1 > 0, mom_B = 0 → A gewinnt + // Epoche 2 (Bars 84..125): A: konstant 352 (=100+84*3), B: 100+j*10 (schnell steigend) + // → Trigger bei Bar 84 (weekBucket=2): mom_A = 352/352-1=0, mom_B = B[84]/B[81]-1 + // B[81]=810, B[82]=820, B[83]=830, B[84]=840 → mom_B = 840/810-1 ≈ 0.037 > 0 → B gewinnt + + // ABER: B muss bereits ab früh steigen damit die Lookback-Werte stimmen. + // Einfacher Ansatz: B steigt erst ab Bar 80, A stagniert ab Bar 80. + + const numBars = 130; + const barsA: { ts: number; cl: number }[] = []; + const barsB: { ts: number; cl: number }[] = []; + for (let i = 0; i < numBars; i++) { + const ts = i * H4; + if (i < 80) { + barsA.push({ ts, cl: 100 + i * 3 }); // A steigt + barsB.push({ ts, cl: 100 }); // B flach + } else { + barsA.push({ ts, cl: 100 + 80 * 3 }); // A stagniert bei 340 + barsB.push({ ts, cl: 100 + (i - 80) * 10 }); // B steigt stark + } + } + // Bar 84 (weekBucket=2, Trigger): mom_A = 340/340-1=0; mom_B = B[84]/B[81]-1 + // B[81]=10, B[82]=20, B[83]=30, B[84]=40 → mom_B=40/10-1=3 > 0 → B gewinnt + + const data = new Map([ + ['BTC_USDT', buildSeries(barsA)], + ['ETH_USDT', buildSeries(barsB)], + ]); + + const result = runRotationBacktest(data, BASE_CFG); + + // Muss BTC-Trade UND ETH-Trade geben + const btcTrades = result.trades.filter((t) => t.pair === 'BTC_USDT'); + const ethTrades = result.trades.filter((t) => t.pair === 'ETH_USDT'); + expect(btcTrades.length).toBeGreaterThanOrEqual(1); + expect(ethTrades.length).toBeGreaterThanOrEqual(1); + + // BTC muss vor ETH geschlossen worden sein (zeitliche Abfolge) + // Letzter BTC-Exit vor erstem ETH-Entry + const lastBtcExit = Math.max(...btcTrades.map((t) => t.exitTs)); + const firstEthEntry = Math.min(...ethTrades.map((t) => t.entryTs)); + expect(lastBtcExit).toBeLessThanOrEqual(firstEthEntry); + + // BTC-Exit via 'rotation' (nicht end_of_data) + const btcRotation = btcTrades.some((t) => t.exitReason === 'rotation'); + expect(btcRotation).toBe(true); +}); + +// -------------------------------------------------------------------------- +// Test (c): Alle Pairs mit negativem Momentum → Cash, keine Trades +// -------------------------------------------------------------------------- +test('(c) alle Pairs fallend → kein Trade (Cash)', () => { + // Alle Pairs fallen monoton: Momentum < 0 bei jedem Trigger. + // lookbackBars=3: close[i]/close[i-3]-1 < 0 → kein Leader → Cash. + const numBars = 90; + const bars: { ts: number; cl: number }[] = []; + for (let i = 0; i < numBars; i++) { + bars.push({ ts: i * H4, cl: 200 - i * 2 }); // fallend: 200, 198, 196, … + } + + const data = new Map([ + ['BTC_USDT', buildSeries(bars)], + ['ETH_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 1 })))], + ['SOL_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 2 })))], + ['XRP_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 3 })))], + ]); + + const result = runRotationBacktest(data, BASE_CFG); + expect(result.trades).toHaveLength(0); + // Equity = startCapital (keine Trades, nur Cash) + expect(result.finalEquity).toBeCloseTo(1000, 1); +}); + +// -------------------------------------------------------------------------- +// Test (d): Determinismus — identischer Input → identisches JSON +// -------------------------------------------------------------------------- +test('(d) Determinismus: identischer Input → identisches JSON-Ergebnis', () => { + const numBars = 60; + const barsA = Array.from({ length: numBars }, (_, i) => ({ ts: i * H4, cl: 100 + i * 2 })); + const barsB = Array.from({ length: numBars }, (_, i) => ({ ts: i * H4, cl: 100 })); + + const makeData = (): Map => + new Map([ + ['BTC_USDT', buildSeries(barsA)], + ['ETH_USDT', buildSeries(barsB)], + ]); + + const cfg: RotationConfig = { ...BASE_CFG }; + const r1 = runRotationBacktest(makeData(), cfg); + const r2 = runRotationBacktest(makeData(), cfg); + expect(JSON.stringify(r1)).toBe(JSON.stringify(r2)); +}); diff --git a/src/server/backtest/rotation.ts b/src/server/backtest/rotation.ts new file mode 100644 index 0000000..c6578c6 --- /dev/null +++ b/src/server/backtest/rotation.ts @@ -0,0 +1,172 @@ +import type { Candle, Pair } from '../types'; +import { PAIRS } from '../types'; +import { aggregate4h, H4 } from '../market/aggregate'; +import { Portfolio, type ExecConfig } from '../engine/portfolio'; +import { computeMetrics, type EquityPoint } from './metrics'; +import type { BacktestResult } from './runner'; + +export interface RotationConfig { + startCapital: number; + exec: ExecConfig; + /** Anzahl abgeschlossener 4h-Bars für die Momentum-Berechnung. 180 = 30 Tage × 6 Bars/Tag. */ + lookbackBars: number; + tradeFrom: number; // ms inklusiv — Rotations-Entscheide erst ab hier + tradeTo: number; // ms exklusiv — danach Zwangsglattstellung +} + +const WEEK = 7 * 24 * 3600 * 1000; + +/** + * Momentum-Rotation-Backtest: Dual Momentum, wöchentliche Rotation, Long-Only. + * + * Entscheidungslogik: + * - Rebalance beim ersten abgeschlossenen 4h-Bar jeder Epochen-Woche ODER + * beim allerersten Bar im Handelsfenster. + * - Momentum = close_now / close_180bars_ago − 1 (30-Tage-Lookback auf 4h). + * - Leader = Pair mit höchstem positivem Momentum; sonst Cash. + * - Rotation: schließe aktuellen Leader, öffne neuen Leader am Close dieses Bars. + * + * Keine Stops, kein TP — Exit nur via Rotation oder end_of_data. + */ +export function runRotationBacktest( + candles15ByPair: Map, + cfg: RotationConfig, +): BacktestResult { + const portfolio = new Portfolio(cfg.startCapital, cfg.exec); + // lastClose: für Equity-Berechnung und Momentum. Wird pro Bar aktualisiert. + const lastClose = new Map(); + const equityCurve: EquityPoint[] = []; + + // --- 4h-Aggregation pro Pair --- + // c4h[pair][i].ts = Bucket-Start (barCloseTs = ts + H4) + const c4hByPair = new Map(); + for (const pair of PAIRS) { + const c15 = candles15ByPair.get(pair); + if (c15 && c15.length > 0) { + c4hByPair.set(pair, aggregate4h(c15)); + } + } + + // --- Unified Timeline: alle abgeschlossenen 4h-Bar-Close-Timestamps --- + // Wir iterieren über die Bar-Close-Timestamps (= ts + H4) aller Pairs, + // sortiert aufsteigend. Bei gleichem Timestamp: PAIRS-Reihenfolge. + // Pro Timestamp sammeln wir {pair → {close, index}} für diesen Bar. + const allBarCloseTs = new Set(); + for (const [, bars] of c4hByPair) { + for (const b of bars) allBarCloseTs.add(b.ts + H4); + } + const sortedTs = Array.from(allBarCloseTs).sort((a, b) => a - b); + + // Für schnellen Lookup: barCloseTs → per-pair {close, index} + // Aufgebaut inkrementell während der Iteration (kein Lookahead). + // next4hIndex[pair] = nächster noch nicht verarbeiteter Index in c4hByPair.get(pair) + const next4hIndex = new Map(); + for (const pair of PAIRS) next4hIndex.set(pair, 0); + + // Letzter verarbeiteter Epochen-Wochen-Bucket (für Rebalance-Trigger) + let lastWeekBucket = -1; + // Merkt ob wir bereits mindestens einen Bar im Handelsfenster gesehen haben + let firstBarInWindow = true; + // Aktuell gehaltenes Pair (null = Cash) + let heldPair: Pair | null = null; + + for (const barCloseTs of sortedTs) { + // Verarbeite alle Bars, deren Close-Timestamp == barCloseTs + // Aktualisiere lastClose für alle Pairs, die in diesem Timestamp einen Bar haben. + // Für Momentum: wir brauchen den Index dieses Bars (für Lookback). + const barIndexByPair = new Map(); + + for (const pair of PAIRS) { + const bars = c4hByPair.get(pair); + if (!bars) continue; + let idx = next4hIndex.get(pair)!; + // Verarbeite alle Bars mit barCloseTs = ts + H4 == barCloseTs, d.h. ts == barCloseTs - H4 + if (idx < bars.length && bars[idx].ts + H4 === barCloseTs) { + lastClose.set(pair, bars[idx].close); + barIndexByPair.set(pair, idx); + next4hIndex.set(pair, idx + 1); + } + // Wenn kein Bar für dieses Pair: lastClose bleibt auf dem letzten bekannten Wert, + // barIndexByPair hat keinen Eintrag → Pair ist bei Momentum ausgeschlossen. + } + + // Equity-Punkt (nur im Handelsfenster) + if (barCloseTs >= cfg.tradeFrom && barCloseTs < cfg.tradeTo) { + equityCurve.push({ ts: barCloseTs, equity: portfolio.equity(lastClose) }); + } + + // Rebalance-Entscheid: nur innerhalb des Handelsfensters + if (barCloseTs < cfg.tradeFrom || barCloseTs >= cfg.tradeTo) continue; + + const weekBucket = Math.floor(barCloseTs / WEEK); + const isNewWeek = weekBucket !== lastWeekBucket; + const isFirstBar = firstBarInWindow; + + if (!isFirstBar && !isNewWeek) continue; // kein Rebalance-Trigger + + lastWeekBucket = weekBucket; + firstBarInWindow = false; + + // --- Momentum berechnen --- + // Nur Pairs, die in diesem barCloseTs einen Bar haben UND deren Index >= lookbackBars. + // Kein Lookahead: wir lesen nur bars[idx] (aktuell) und bars[idx - lookbackBars] (Vergangenheit). + let bestPair: Pair | null = null; + let bestMom = 0; // 0 = Schwelle (positiver Momentum erforderlich) + + for (const pair of PAIRS) { + const idx = barIndexByPair.get(pair); + if (idx === undefined) continue; // Pair hat keinen Bar an diesem Timestamp + if (idx < cfg.lookbackBars) continue; // unzureichende Historie + const bars = c4hByPair.get(pair)!; + const closeNow = bars[idx].close; + const close30dAgo = bars[idx - cfg.lookbackBars].close; + // Momentum = closeNow / close30dAgo − 1 + const mom = closeNow / close30dAgo - 1; + if (mom > bestMom) { + bestMom = mom; + bestPair = pair; + } + } + + // Ziel: bestPair (oder null = Cash) + const target = bestPair; // null wenn alle Momenta <= 0 + + if (target === heldPair) continue; // kein Wechsel nötig + + // --- Rotation ausführen --- + const fillPrice = (pair: Pair) => lastClose.get(pair) ?? 0; + + // Schließe aktuelle Position + if (heldPair !== null && portfolio.positions.has(heldPair)) { + portfolio.close(heldPair, barCloseTs, fillPrice(heldPair), 'rotation'); + } + heldPair = null; + + // Öffne neue Position + if (target !== null) { + const price = fillPrice(target); + if (price > 0) { + const equity = portfolio.equity(lastClose); + // qty = cash × 0.995 / fill (Anpassung für Gebühren beim Entry) + // riskAmount = 1% des Eigenkapitals (nur für R-Multiple-Berechnung) + const qty = (portfolio.cash * 0.995) / (price * (1 + cfg.exec.slippage)); + const riskAmount = equity * 0.01; + // initialStop=0: kein Stop; Portfolio.open benötigt den Parameter, + // aber Rotation-Exits passieren nur via rotation/end_of_data. + portfolio.open(target, barCloseTs, price, 0, qty, riskAmount, 'long'); + heldPair = target; + } + } + } + + // Offene Positionen am Ende glattstellen + if (heldPair !== null && portfolio.positions.has(heldPair)) { + portfolio.close(heldPair, cfg.tradeTo, lastClose.get(heldPair) ?? 0, 'end_of_data'); + } + + return { + trades: portfolio.trades, + equityCurve, + finalEquity: portfolio.equity(lastClose), + }; +} diff --git a/src/server/engine/portfolio.ts b/src/server/engine/portfolio.ts index a6452f0..b4ad1ef 100644 --- a/src/server/engine/portfolio.ts +++ b/src/server/engine/portfolio.ts @@ -31,7 +31,7 @@ export interface ClosedTrade { qty: number; pnl: number; r: number; - exitReason: 'trailing_stop' | 'end_of_data'; + exitReason: 'trailing_stop' | 'end_of_data' | 'rotation'; side: 'long' | 'short'; }