diff --git a/package.json b/package.json index 95238f1..e3c045a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "test": "bun test", "backfill": "bun run src/server/scripts/backfill.ts", "walkforward": "bun run src/server/scripts/walkforward.ts", + "rotation": "bun run src/server/scripts/rotation-walkforward.ts", "db:generate": "bunx drizzle-kit generate", "db:migrate": "bun run src/server/db/migrate.ts" }, diff --git a/src/server/backtest/walkforward.ts b/src/server/backtest/walkforward.ts index 165aa2c..5c829e1 100644 --- a/src/server/backtest/walkforward.ts +++ b/src/server/backtest/walkforward.ts @@ -100,6 +100,42 @@ export interface WalkForwardResult { type BaseConfig = Omit; +/** + * OOS-Aggregat aus einer Liste von WindowResults (oder strukturgleichen Objekten): + * Trades kombiniert, Equity-Kurven multiplikativ verkettet, Gate berechnet. + * Wiederverwendbar für alle Walk-Forward-Varianten (Donchian, Rotation, …). + */ +export function aggregateOos( + results: { trainMetrics: Metrics; testMetrics: Metrics; testTrades: ClosedTrade[]; testEquityCurve: EquityPoint[] }[], + startCapital: number, +): { oosTrades: ClosedTrade[]; oosEquityCurve: EquityPoint[]; oosMetrics: Metrics; gate: GateResult } { + const oosTrades = results.flatMap((r) => r.testTrades); + const oosEquityCurve: EquityPoint[] = []; + let scale = 1; + for (const r of results) { + for (const p of r.testEquityCurve) { + oosEquityCurve.push({ ts: p.ts, equity: startCapital * scale * (p.equity / startCapital) }); + } + const last = r.testEquityCurve.at(-1); + if (last) scale *= last.equity / startCapital; + } + const oosMetrics = computeMetrics(oosTrades, oosEquityCurve, startCapital); + + const worst = pickWorstEligibleWindow(results.map((r) => r.testMetrics)); + const finiteTrainPfs = results.map((r) => Math.min(r.trainMetrics.profitFactor, 10)); // Infinity kappen + const avgTrainPf = finiteTrainPfs.reduce((s, v) => s + v, 0) / Math.max(1, finiteTrainPfs.length); + + const gate = evaluateGate({ + oosProfitFactor: oosMetrics.profitFactor, + oosTrades: oosMetrics.trades, + oosMaxDrawdownPct: oosMetrics.maxDrawdownPct, + worstWindow: worst, + avgTrainPf, + }); + + return { oosTrades, oosEquityCurve, oosMetrics, gate }; +} + /** PF-Vergleich mit Infinity-Handling: Infinity schlägt alles, Tie-Break TotalPnl. */ function better(a: Metrics, b: Metrics): boolean { if (a.profitFactor !== b.profitFactor) return a.profitFactor > b.profitFactor; @@ -149,30 +185,7 @@ export function runWalkForward( ); } - // OOS-Aggregat: Trades kombiniert, Equity-Kurven multiplikativ verkettet - const oosTrades = results.flatMap((r) => r.testTrades); - const oosEquityCurve: EquityPoint[] = []; - let scale = 1; - for (const r of results) { - for (const p of r.testEquityCurve) { - oosEquityCurve.push({ ts: p.ts, equity: baseCfg.startCapital * scale * (p.equity / baseCfg.startCapital) }); - } - const last = r.testEquityCurve.at(-1); - if (last) scale *= last.equity / baseCfg.startCapital; - } - const oosMetrics = computeMetrics(oosTrades, oosEquityCurve, baseCfg.startCapital); - - const worst = pickWorstEligibleWindow(results.map((r) => r.testMetrics)); - const finiteTrainPfs = results.map((r) => Math.min(r.trainMetrics.profitFactor, 10)); // Infinity kappen - const avgTrainPf = finiteTrainPfs.reduce((s, v) => s + v, 0) / Math.max(1, finiteTrainPfs.length); - - const gate = evaluateGate({ - oosProfitFactor: oosMetrics.profitFactor, - oosTrades: oosMetrics.trades, - oosMaxDrawdownPct: oosMetrics.maxDrawdownPct, - worstWindow: worst, - avgTrainPf, - }); + const { oosTrades, oosEquityCurve, oosMetrics, gate } = aggregateOos(results, baseCfg.startCapital); return { windows: results, oosMetrics, oosEquityCurve, gate }; } diff --git a/src/server/scripts/rotation-walkforward.ts b/src/server/scripts/rotation-walkforward.ts new file mode 100644 index 0000000..70d5ad6 --- /dev/null +++ b/src/server/scripts/rotation-walkforward.ts @@ -0,0 +1,109 @@ +import { PAIRS, type Candle, type Pair } from '../types'; +import { getCandles, getCoverage } from '../market/candle-store'; +import { buildWindows, aggregateOos } from '../backtest/walkforward'; +import { runRotationBacktest, type RotationConfig } from '../backtest/rotation'; +import { computeMetrics } from '../backtest/metrics'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { db, sql } from '../db/client'; +import { backtestRuns } from '../db/schema'; + +// --- Feste A-priori-Parameter (kein Grid, kein Overfitting möglich) --- +const LOOKBACK_BARS = 180; // 30 Tage × 6 Bars/Tag auf 4h-TF +const START_CAPITAL = 1000; +const EXEC = DEFAULT_EXEC; + +const candles15ByPair = new Map(); +let dataFrom = 0; +let dataTo = Number.MAX_SAFE_INTEGER; + +for (const pair of PAIRS) { + const cov = await getCoverage(pair); + if (!cov.from || !cov.to) throw new Error(`Keine Candles für ${pair} — erst 'bun run backfill' ausführen.`); + candles15ByPair.set(pair, await getCandles(pair)); + dataFrom = Math.max(dataFrom, cov.from.getTime()); + dataTo = Math.min(dataTo, cov.to.getTime()); + console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()} → ${cov.to.toISOString()})`); +} + +console.log(`\nMomentum-Rotation (fix: lookback 30d, weekly, top-1, long-only)`); +console.log(`Walk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`); + +const windows = buildWindows(dataFrom, dataTo); + +type WindowResult = { + window: { trainFrom: number; trainTo: number; testFrom: number; testTo: number }; + trainMetrics: ReturnType; + testMetrics: ReturnType; + testTrades: import('../engine/portfolio').ClosedTrade[]; + testEquityCurve: import('../backtest/metrics').EquityPoint[]; +}; + +const results: WindowResult[] = []; + +for (const [wi, w] of windows.entries()) { + const trainCfg: RotationConfig = { + startCapital: START_CAPITAL, + exec: EXEC, + lookbackBars: LOOKBACK_BARS, + tradeFrom: w.trainFrom, + tradeTo: w.trainTo, + }; + const testCfg: RotationConfig = { + startCapital: START_CAPITAL, + exec: EXEC, + lookbackBars: LOOKBACK_BARS, + tradeFrom: w.testFrom, + tradeTo: w.testTo, + }; + + const trainResult = runRotationBacktest(candles15ByPair, trainCfg); + const trainMetrics = computeMetrics(trainResult.trades, trainResult.equityCurve, START_CAPITAL); + + const testResult = runRotationBacktest(candles15ByPair, testCfg); + const testMetrics = computeMetrics(testResult.trades, testResult.equityCurve, START_CAPITAL); + + results.push({ + window: w, + trainMetrics, + testMetrics, + testTrades: testResult.trades, + testEquityCurve: testResult.equityCurve, + }); + + console.log( + `Fenster ${wi + 1}/${windows.length}: Train-PF ${trainMetrics.profitFactor.toFixed(2)} ` + + `→ Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`, + ); +} + +// OOS-Aggregat (gemeinsame Logik aus walkforward.ts) +const { oosMetrics, oosEquityCurve, gate } = aggregateOos(results, START_CAPITAL); + +console.log('\n========== OOS-GESAMTERGEBNIS =========='); +const m = oosMetrics; +console.log(`Trades: ${m.trades} | WinRate: ${(m.winRate * 100).toFixed(1)}% | PF: ${m.profitFactor.toFixed(2)}`); +console.log(`TotalPnl: ${m.totalPnl.toFixed(2)} USDT | MaxDD: ${(m.maxDrawdownPct * 100).toFixed(1)}% | AvgR: ${m.avgR.toFixed(2)}`); + +console.log('\n========== DEPLOY-GATE =========='); +for (const c of gate.checks) { + console.log(`${c.pass ? '✅' : '❌'} ${c.name}: ${Number.isFinite(c.value) ? c.value.toFixed(2) : c.value}`); +} +console.log(`\n→ GATE ${gate.pass ? 'BESTANDEN' : 'NICHT BESTANDEN'}`); + +// Persistenz +await db.insert(backtestRuns).values({ + kind: 'rotation-walkforward', + config: { startCapital: START_CAPITAL, exec: EXEC, lookbackBars: LOOKBACK_BARS } as any, + result: { + gate, + oosMetrics, + oosEquityCurve, + windows: results.map((r) => ({ + window: r.window, + trainMetrics: r.trainMetrics, + testMetrics: r.testMetrics, + })), + } as any, +}); +console.log('Run in backtest_runs gespeichert.'); +await sql.end();