feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
123
src/server/backtest/runner.ts
Normal file
123
src/server/backtest/runner.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user