feat: Rotation-Walk-Forward-CLI (gemeinsame OOS-Aggregation extrahiert)

aggregateOos() aus runWalkForward herausgezogen und exportiert; beide
Walk-Forward-Varianten (Donchian + Rotation) nutzen dieselbe OOS-Logik.
Neues Script rotation-walkforward.ts mit identischem Report-Format und
Persistenz in backtest_runs (kind='rotation-walkforward').
package.json: "rotation"-Script ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 22:09:22 +00:00
parent b7e81374f1
commit 29000a2bba
3 changed files with 147 additions and 24 deletions

View File

@@ -6,6 +6,7 @@
"test": "bun test", "test": "bun test",
"backfill": "bun run src/server/scripts/backfill.ts", "backfill": "bun run src/server/scripts/backfill.ts",
"walkforward": "bun run src/server/scripts/walkforward.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:generate": "bunx drizzle-kit generate",
"db:migrate": "bun run src/server/db/migrate.ts" "db:migrate": "bun run src/server/db/migrate.ts"
}, },

View File

@@ -100,6 +100,42 @@ export interface WalkForwardResult {
type BaseConfig = Omit<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>; type BaseConfig = Omit<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>;
/**
* 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. */ /** PF-Vergleich mit Infinity-Handling: Infinity schlägt alles, Tie-Break TotalPnl. */
function better(a: Metrics, b: Metrics): boolean { function better(a: Metrics, b: Metrics): boolean {
if (a.profitFactor !== b.profitFactor) return a.profitFactor > b.profitFactor; 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, oosEquityCurve, oosMetrics, gate } = aggregateOos(results, baseCfg.startCapital);
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,
});
return { windows: results, oosMetrics, oosEquityCurve, gate }; return { windows: results, oosMetrics, oosEquityCurve, gate };
} }

View File

@@ -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<Pair, Candle[]>();
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<typeof computeMetrics>;
testMetrics: ReturnType<typeof computeMetrics>;
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();