172 lines
6.4 KiB
TypeScript
172 lines
6.4 KiB
TypeScript
import type { Candle, Pair } from '../types';
|
|
import { runBacktest, type BacktestConfig } from './runner';
|
|
import { computeMetrics, type Metrics, type EquityPoint } from './metrics';
|
|
import type { StrategyParams } from '../strategy/donchian-trend';
|
|
import type { ClosedTrade } from '../engine/portfolio';
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
|
|
export interface Window {
|
|
trainFrom: number;
|
|
trainTo: number;
|
|
testFrom: number;
|
|
testTo: number;
|
|
}
|
|
|
|
/**
|
|
* Rollierende Train/Test-Fenster. Hinweis: Im ersten Fenster sind die ersten
|
|
* ~EMA200-Perioden (≈33 Tage auf 4h) Indikator-Cold-Start — effektive
|
|
* Train-Länge von Fenster 0 ist entsprechend kürzer als bei späteren Fenstern.
|
|
*/
|
|
export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, testDays = 30, stepDays = 30): Window[] {
|
|
const out: Window[] = [];
|
|
for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) {
|
|
const trainTo = start + trainDays * DAY;
|
|
out.push({ trainFrom: start, trainTo, testFrom: trainTo, testTo: trainTo + testDays * DAY });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) =>
|
|
[2, 3, 4].flatMap((atrMultiplier) =>
|
|
[100, 200].map((trendEmaPeriod) => ({ donchianPeriod, atrPeriod: 14, atrMultiplier, trendEmaPeriod })),
|
|
),
|
|
);
|
|
|
|
export interface WindowResult {
|
|
window: Window;
|
|
bestParams: StrategyParams;
|
|
trainMetrics: Metrics;
|
|
testMetrics: Metrics;
|
|
testTrades: ClosedTrade[];
|
|
testEquityCurve: EquityPoint[];
|
|
}
|
|
|
|
export interface GateInput {
|
|
oosProfitFactor: number;
|
|
oosTrades: number;
|
|
oosMaxDrawdownPct: number;
|
|
worstWindow: { profitFactor: number; trades: number };
|
|
avgTrainPf: number;
|
|
}
|
|
|
|
export interface GateCheck {
|
|
name: string;
|
|
pass: boolean;
|
|
value: number;
|
|
threshold: number;
|
|
}
|
|
|
|
export interface GateResult {
|
|
pass: boolean;
|
|
checks: GateCheck[];
|
|
}
|
|
|
|
/** Schlechtestes Test-Fenster UNTER den für Check 4 relevanten (>= 5 Trades). */
|
|
export function pickWorstEligibleWindow(metricsList: { profitFactor: number; trades: number }[]): { profitFactor: number; trades: number } {
|
|
return metricsList
|
|
.filter((m) => m.trades >= 5)
|
|
.reduce(
|
|
(acc, m) => (m.profitFactor < acc.profitFactor ? { profitFactor: m.profitFactor, trades: m.trades } : acc),
|
|
{ profitFactor: Infinity, trades: 0 },
|
|
);
|
|
}
|
|
|
|
export function evaluateGate(g: GateInput): GateResult {
|
|
const overfitRatio = g.oosProfitFactor > 0 ? g.avgTrainPf / g.oosProfitFactor : Infinity;
|
|
const windowFail = g.worstWindow.trades >= 5 && g.worstWindow.profitFactor < 0.5;
|
|
const checks: GateCheck[] = [
|
|
{ name: 'OOS-ProfitFactor >= 1.2', pass: g.oosProfitFactor >= 1.2, value: g.oosProfitFactor, threshold: 1.2 },
|
|
{ name: 'OOS-Trades >= 25', pass: g.oosTrades >= 25, value: g.oosTrades, threshold: 25 },
|
|
{ name: 'OOS-MaxDrawdown <= 25%', pass: g.oosMaxDrawdownPct <= 0.25, value: g.oosMaxDrawdownPct, threshold: 0.25 },
|
|
{ name: 'kein Fenster PF < 0.5 (bei >= 5 Trades)', pass: !windowFail, value: g.worstWindow.profitFactor, threshold: 0.5 },
|
|
{ name: 'Train-PF / OOS-PF < 2 (Overfitting)', pass: overfitRatio < 2, value: overfitRatio, threshold: 2 },
|
|
];
|
|
return { pass: checks.every((c) => c.pass), checks };
|
|
}
|
|
|
|
export interface WalkForwardResult {
|
|
windows: WindowResult[];
|
|
oosMetrics: Metrics;
|
|
oosEquityCurve: EquityPoint[];
|
|
gate: GateResult;
|
|
}
|
|
|
|
type BaseConfig = Omit<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>;
|
|
|
|
/** 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;
|
|
return a.totalPnl > b.totalPnl;
|
|
}
|
|
|
|
export function runWalkForward(
|
|
candles15ByPair: Map<Pair, Candle[]>,
|
|
baseCfg: BaseConfig,
|
|
dataFrom: number,
|
|
dataTo: number,
|
|
onProgress?: (msg: string) => void,
|
|
): WalkForwardResult {
|
|
const windows = buildWindows(dataFrom, dataTo);
|
|
const results: WindowResult[] = [];
|
|
|
|
for (const [wi, w] of windows.entries()) {
|
|
let bestParams = PARAM_GRID[0];
|
|
let bestMetrics: Metrics | null = null;
|
|
let bestEligible = false;
|
|
|
|
for (const params of PARAM_GRID) {
|
|
const r = runBacktest(candles15ByPair, { ...baseCfg, params, tradeFrom: w.trainFrom, tradeTo: w.trainTo });
|
|
const m = computeMetrics(r.trades, r.equityCurve, baseCfg.startCapital);
|
|
const eligible = m.trades >= 5;
|
|
const wins =
|
|
bestMetrics === null ||
|
|
(eligible && !bestEligible) ||
|
|
(eligible === bestEligible && (eligible ? better(m, bestMetrics) : m.trades > bestMetrics.trades));
|
|
if (wins) {
|
|
bestParams = params;
|
|
bestMetrics = m;
|
|
bestEligible = eligible;
|
|
}
|
|
}
|
|
|
|
const test = runBacktest(candles15ByPair, { ...baseCfg, params: bestParams, tradeFrom: w.testFrom, tradeTo: w.testTo });
|
|
const testMetrics = computeMetrics(test.trades, test.equityCurve, baseCfg.startCapital);
|
|
results.push({
|
|
window: w, bestParams, trainMetrics: bestMetrics!, testMetrics,
|
|
testTrades: test.trades, testEquityCurve: test.equityCurve,
|
|
});
|
|
onProgress?.(
|
|
`Fenster ${wi + 1}/${windows.length}: Train-PF ${bestMetrics!.profitFactor.toFixed(2)} ` +
|
|
`(${JSON.stringify(bestParams)}) → Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`,
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
});
|
|
|
|
return { windows: results, oosMetrics, oosEquityCurve, gate };
|
|
}
|