feat: Momentum-Rotation-Backtest (dual momentum, weekly, fixe Parameter)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:09:16 +00:00
parent 8e838c4a66
commit b7e81374f1
3 changed files with 414 additions and 1 deletions

View File

@@ -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<WEEK).
// Bar 0 liegt bei ts=H4 (Close-ts), kein Momentum da idx=0 < lookbackBars=3.
// → Cash.
//
// Nächster Trigger: erster Bar nächster Woche.
// WEEK = 7×24×3600×1000 = 604800000 ms; H4 = 14400000 ms → 42 Bars/Woche.
// Wir bauen kleine Zeitreihe: Bars an Positionen 0,1,2,... × H4.
// Bar-Close-Ts von Bar i = (i+1)*H4 (da bucketStart=i*H4, Close-ts=bucketStart+H4).
// weekBucket(barCloseTs) = Math.floor(barCloseTs / WEEK).
// weekBucket ändert sich wenn barCloseTs überquert ein WEEK-Vielfaches.
// Für einfache Tests bauen wir Bars in 2 Wochen-Epochen direkt auf.
// Epoche 0: Bars 0..41 (weekBucket=0), Epoche 1: Bars 42..83.
// Warmup: Bars 0..2 (idx 0,1,2 sind < lookbackBars=3).
// Erster Trigger: Bar 0 (firstBarInWindow) → idx=0 < 3 → Cash.
// Woche-1-Trigger: Bar 42 (erster Bar mit weekBucket=1) → idx=42 >= 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<Pair, Candle[]>([
['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<Pair, Candle[]>([
['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<Pair, Candle[]>([
['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<Pair, Candle[]> =>
new Map<Pair, Candle[]>([
['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));
});

View File

@@ -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<Pair, Candle[]>,
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<Pair, number>();
const equityCurve: EquityPoint[] = [];
// --- 4h-Aggregation pro Pair ---
// c4h[pair][i].ts = Bucket-Start (barCloseTs = ts + H4)
const c4hByPair = new Map<Pair, Candle[]>();
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<number>();
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<Pair, number>();
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<Pair, number>();
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),
};
}

View File

@@ -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';
}