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:
241
src/server/backtest/rotation.test.ts
Normal file
241
src/server/backtest/rotation.test.ts
Normal 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));
|
||||||
|
});
|
||||||
172
src/server/backtest/rotation.ts
Normal file
172
src/server/backtest/rotation.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export interface ClosedTrade {
|
|||||||
qty: number;
|
qty: number;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
r: number;
|
r: number;
|
||||||
exitReason: 'trailing_stop' | 'end_of_data';
|
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation';
|
||||||
side: 'long' | 'short';
|
side: 'long' | 'short';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user