feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 20:37:42 +00:00
parent 2fa4695f1b
commit 8ad1516665
2 changed files with 195 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { aggregate4h, H4 } from '../market/aggregate';
import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend';
import { updateChandelier } from '../strategy/chandelier';
import { sizePosition, type RiskConfig } from '../engine/sizing';
import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio';
import type { EquityPoint } from './metrics';
export interface BacktestConfig {
startCapital: number;
risk: RiskConfig;
exec: ExecConfig;
maxPositions: number;
params: StrategyParams;
tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup
tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt
}
export interface BacktestResult {
trades: ClosedTrade[];
equityCurve: EquityPoint[];
finalEquity: number;
}
interface PairContext {
pair: Pair;
c15: Candle[];
c4h: Candle[];
ind: IndicatorSet;
next4h: number; // Index der nächsten noch nicht verarbeiteten 4h-Candle
}
export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestConfig): BacktestResult {
const portfolio = new Portfolio(cfg.startCapital, cfg.exec);
const lastClose = new Map<Pair, number>();
const equityCurve: EquityPoint[] = [];
const contexts: PairContext[] = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => {
const c15 = candles15ByPair.get(pair)!;
const c4h = aggregate4h(c15);
return { pair, c15, c4h, ind: computeIndicators(c4h, cfg.params), next4h: 0 };
});
// Gemergte 15m-Timeline (Pair-Reihenfolge stabil → deterministisch)
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
for (const ctx of contexts) {
for (const candle of ctx.c15) {
if (candle.ts < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
}
}
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair) - PAIRS.indexOf(b.pair));
const byPair = new Map<Pair, PairContext>(contexts.map((c) => [c.pair, c]));
let lastEquityBucket = -1;
for (const { ts, pair, candle } of timeline) {
const ctx = byPair.get(pair)!;
const bucket = Math.floor(ts / H4) * H4;
// 1) Neu abgeschlossene 4h-Candles dieses Pairs verarbeiten (alles < aktueller Bucket)
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
const i = ctx.next4h++;
const bar = ctx.c4h[i];
// 1a) Trailing-Stop der offenen Position nachziehen
const pos = portfolio.positions.get(pair);
if (pos) {
const next = updateChandelier(
{ highestHigh: pos.highestHigh, stop: pos.stop },
bar.high,
ctx.ind.atr[i],
cfg.params.atrMultiplier,
);
pos.highestHigh = next.highestHigh;
pos.stop = next.stop;
}
// 1b) Entry-Evaluation
const barCloseTs = bar.ts + H4;
if (
!portfolio.positions.has(pair) &&
portfolio.positions.size < cfg.maxPositions &&
barCloseTs >= cfg.tradeFrom &&
barCloseTs < cfg.tradeTo
) {
const ev = evaluateAt(ctx.c4h, ctx.ind, i);
if (ev.signal === 'long') {
const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr;
const equity = portfolio.equity(lastClose);
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk);
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount);
}
}
}
// 2) Stop-Check auf der 15m-Candle
const pos = portfolio.positions.get(pair);
if (pos && candle.low <= pos.stop) {
const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
}
lastClose.set(pair, candle.close);
// 3) Equity-Punkt einmal pro 4h-Bucket
if (bucket !== lastEquityBucket && ts >= cfg.tradeFrom) {
lastEquityBucket = bucket;
equityCurve.push({ ts: bucket, equity: portfolio.equity(lastClose) });
}
}
// Offene Positionen glattstellen
for (const pair of [...portfolio.positions.keys()]) {
portfolio.close(pair, cfg.tradeTo, lastClose.get(pair)!, 'end_of_data');
}
return {
trades: portfolio.trades,
equityCurve,
finalEquity: portfolio.equity(lastClose),
};
}