130 lines
4.8 KiB
TypeScript
130 lines
4.8 KiB
TypeScript
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;
|
|
|
|
// Bekannte Grenze: 4h-Bars eines Pairs werden erst verarbeitet, wenn dessen
|
|
// nächste 15m-Candle eintrifft — bei Datenlücken eines Pairs verschiebt sich
|
|
// dessen Verarbeitung relativ zu anderen Pairs (betrifft maxPositions-Reihenfolge).
|
|
// 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.
|
|
// Bewusst AUCH auf der Entry-Candle (Entry = Open-Zeitpunkt dieser Candle,
|
|
// ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv).
|
|
// Pessimistisch-realistisch, nicht "wegoptimieren".
|
|
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),
|
|
};
|
|
}
|