feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
72
src/server/backtest/runner.test.ts
Normal file
72
src/server/backtest/runner.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { expect, test } from 'bun:test';
|
||||||
|
import type { Candle, Pair } from '../types';
|
||||||
|
import { runBacktest } from './runner';
|
||||||
|
import { DEFAULT_RISK } from '../engine/sizing';
|
||||||
|
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||||
|
import { H4 } from '../market/aggregate';
|
||||||
|
|
||||||
|
const M15 = 15 * 60 * 1000;
|
||||||
|
const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5 };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle
|
||||||
|
* (Close 110), dann Absturz unter den Stop.
|
||||||
|
* Jede 4h-Candle besteht aus 16 flachen 15m-Candles mit definiertem OHLC.
|
||||||
|
*/
|
||||||
|
function flat4h(bucketStart: number, o: number, h: number, l: number, cl: number): Candle[] {
|
||||||
|
const out: Candle[] = [];
|
||||||
|
for (let i = 0; i < 16; i++) {
|
||||||
|
// alle 15m-Candles tragen die 4h-Range, Close interpoliert linear o→cl
|
||||||
|
const c = o + ((cl - o) * (i + 1)) / 16;
|
||||||
|
out.push({ ts: bucketStart + i * M15, open: o, high: h, low: l, close: c, volume: 1 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function series(): Candle[] {
|
||||||
|
const s: Candle[] = [];
|
||||||
|
let b = 0;
|
||||||
|
// 7 Plateau-Buckets: Closes 100, Highs 101 → Donchian-High 101, EMA ~100
|
||||||
|
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100));
|
||||||
|
// Breakout-Bucket: Close 110 > 101 (Donchian) und > EMA5
|
||||||
|
s.push(...flat4h(b, 100, 111, 100, 110)); b += H4;
|
||||||
|
// Crash-Bucket: Low 80 reißt jeden Stop
|
||||||
|
s.push(...flat4h(b, 110, 110, 80, 85)); b += H4;
|
||||||
|
// Abschluss-Bucket, damit der Crash-Bucket als abgeschlossen gilt
|
||||||
|
s.push(...flat4h(b, 85, 86, 84, 85)); b += H4;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Breakout → Entry auf 4h-Close, Crash → Stop-Exit auf 15m', () => {
|
||||||
|
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
|
||||||
|
const result = runBacktest(data, {
|
||||||
|
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
|
||||||
|
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
|
||||||
|
});
|
||||||
|
expect(result.trades).toHaveLength(1);
|
||||||
|
const t = result.trades[0];
|
||||||
|
expect(t.entryPrice).toBeCloseTo(110 * 1.0005); // 4h-Close + Slippage
|
||||||
|
expect(t.exitReason).toBe('trailing_stop');
|
||||||
|
expect(t.pnl).toBeLessThan(0);
|
||||||
|
// Verlust ≈ 1R (Stop = Entry − 1×ATR), Fees machen ihn etwas größer
|
||||||
|
expect(t.r).toBeLessThan(-0.8);
|
||||||
|
expect(t.r).toBeGreaterThan(-1.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tradeFrom verhindert Entries im Warmup-Fenster', () => {
|
||||||
|
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
|
||||||
|
const result = runBacktest(data, {
|
||||||
|
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
|
||||||
|
params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, // nach Serien-Ende
|
||||||
|
});
|
||||||
|
expect(result.trades).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Determinismus: identischer Input → identisches Ergebnis', () => {
|
||||||
|
const data = new Map<Pair, Candle[]>([['BTC_USDT', series()]]);
|
||||||
|
const cfg = {
|
||||||
|
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
|
||||||
|
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
|
||||||
|
};
|
||||||
|
expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg)));
|
||||||
|
});
|
||||||
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