Files
trade-kuns/src/server/backtest/grid.ts
Claude 3d16b76f23 feat: Grid-Timeframe parametrisierbar (--tf) — kürzere TFs monoton schlechter
aggregateTf verallgemeinert aggregate4h. Walk-Forward 1h/15m:
PF 0.59/0.29 (vs 0.87 auf 4h), 15m MaxDD 97% — Fee-Mathematik bestätigt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 07:00:45 +00:00

197 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { aggregateTf, H4 } from '../market/aggregate';
import { atr } from '../indicators/atr';
import { adx } from '../indicators/adx';
import type { ClosedTrade, ExecConfig } from '../engine/portfolio';
import type { EquityPoint } from './metrics';
import type { BacktestResult } from './runner';
export interface GridParams {
spacingAtrMult: number; // Level-Abstand = mult × ATR(atrPeriod, tf) bei Aktivierung
gridLevels: number; // Buy-Levels unterhalb des Centers
adxMax: number; // Grid nur aktiv im Seitwärtsregime (ADX < adxMax)
atrPeriod: number;
tfMs: number; // Entscheidungs-Timeframe (Aktivierung/Deaktivierung, ATR/ADX-Basis)
}
export const DEFAULT_GRID_PARAMS: GridParams = {
spacingAtrMult: 1.0,
gridLevels: 4,
adxMax: 20,
atrPeriod: 14,
tfMs: H4,
};
export interface GridConfig {
startCapital: number;
exec: ExecConfig;
params: GridParams;
minNotionalUsdt: number;
tradeFrom: number; // ms inklusiv — Aktivierungen/Fills erst ab hier
tradeTo: number; // ms exklusiv — danach Zwangsglattstellung
}
interface Lot {
levelIdx: number;
qty: number;
entryTs: number;
entryPrice: number; // Fill inkl. Slippage
entryCost: number; // qty×fill + Fee
riskAmount: number; // Distanz zum Grid-Stop × qty
}
interface ActiveGrid {
center: number;
spacing: number; // eingefroren bei Aktivierung — kein Level-Chasing
stopPrice: number; // center (N+1)×spacing
upperExit: number; // center + (N+1)×spacing
budgetPerLevel: number;
lots: (Lot | null)[]; // Index = Level (0 = oberstes Buy-Level bei center 1×spacing)
}
/**
* ATR-Grid mit ADX-Regime-Filter, long-only, je Pair unabhängig.
* Tf-Close (default 4h): Aktivierung/Deaktivierung; 15m: Fills (Sells vor
* Buys — ein im selben Bar gekaufter Lot kann nicht im selben Bar verkaufen).
* Fee/Slippage-Mathematik identisch zu Portfolio (pessimistische Fills).
*/
export function runGridBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: GridConfig): BacktestResult {
const { exec, params: p } = cfg;
let cash = cfg.startCapital;
const trades: ClosedTrade[] = [];
const grids = new Map<Pair, ActiveGrid>();
const lastClose = new Map<Pair, number>();
const equityCurve: EquityPoint[] = [];
const equity = (): number => {
let eq = cash;
for (const [pair, g] of grids) {
const last = lastClose.get(pair) ?? 0;
for (const lot of g.lots) if (lot) eq += lot.qty * last;
}
return eq;
};
const buy = (pair: Pair, ts: number, price: number, levelIdx: number, g: ActiveGrid): void => {
const fill = price * (1 + exec.slippage);
const qty = g.budgetPerLevel / fill;
const cost = qty * fill;
const fee = cost * exec.feeRate;
if (cash < cost + fee) return; // kein Cash → Fill entfällt
cash -= cost + fee;
g.lots[levelIdx] = {
levelIdx, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee,
riskAmount: Math.max((price - g.stopPrice) * qty, 1e-9),
};
};
const sell = (pair: Pair, ts: number, price: number, lot: Lot, reason: ClosedTrade['exitReason']): void => {
const fill = price * (1 - exec.slippage);
const proceeds = lot.qty * fill;
const fee = proceeds * exec.feeRate;
cash += proceeds - fee;
const pnl = proceeds - fee - lot.entryCost;
trades.push({
pair, entryTs: lot.entryTs, entryPrice: lot.entryPrice, exitTs: ts, exitPrice: fill,
qty: lot.qty, pnl, r: pnl / lot.riskAmount, exitReason: reason, side: 'long',
});
};
const liquidate = (pair: Pair, ts: number, price: number, reason: ClosedTrade['exitReason']): void => {
const g = grids.get(pair);
if (!g) return;
for (const lot of g.lots) if (lot) sell(pair, ts, price, lot, reason);
grids.delete(pair);
};
// --- Kontexte + gemergte 15m-Timeline (wie runner.ts) ---
const contexts = PAIRS.filter((pr) => candles15ByPair.has(pr)).map((pair) => {
const c15 = candles15ByPair.get(pair)!;
const c4h = aggregateTf(c15, p.tfMs);
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h: 0 };
});
const byPair = new Map(contexts.map((c) => [c.pair, c]));
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
for (const ctx of contexts) {
for (const candle of candles15ByPair.get(ctx.pair)!) {
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));
let lastEquityBucket = -1;
for (const { ts, pair, candle } of timeline) {
const ctx = byPair.get(pair)!;
const bucket = Math.floor(ts / p.tfMs) * p.tfMs;
// 1) Neu abgeschlossene Tf-Bars: Deaktivierung / Aktivierung
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
const i = ctx.next4h++;
const bar = ctx.c4h[i];
const barCloseTs = bar.ts + p.tfMs;
if (barCloseTs < cfg.tradeFrom || barCloseTs >= cfg.tradeTo) continue;
const g = grids.get(pair);
if (g) {
const trendStart = !Number.isNaN(ctx.adx[i]) && ctx.adx[i] >= p.adxMax + 5; // Hysterese
if (bar.close < g.stopPrice || bar.close > g.upperExit || trendStart) {
liquidate(pair, barCloseTs, bar.close, 'grid_stop');
}
} else if (
!Number.isNaN(ctx.atr[i]) &&
!Number.isNaN(ctx.adx[i]) &&
ctx.adx[i] < p.adxMax
) {
const spacing = p.spacingAtrMult * ctx.atr[i];
const budgetPerLevel = equity() / PAIRS.length / p.gridLevels;
if (spacing > 0 && budgetPerLevel >= cfg.minNotionalUsdt) {
grids.set(pair, {
center: bar.close,
spacing,
stopPrice: bar.close - (p.gridLevels + 1) * spacing,
upperExit: bar.close + (p.gridLevels + 1) * spacing,
budgetPerLevel,
lots: Array(p.gridLevels).fill(null),
});
}
}
}
// 2) 15m-Fills auf aktivem Grid: Sells zuerst (nur vor diesem Bar gekaufte Lots), dann Buys
const g = grids.get(pair);
if (g && ts >= cfg.tradeFrom) {
for (let k = 0; k < g.lots.length; k++) {
const lot = g.lots[k];
if (!lot || lot.entryTs >= ts) continue;
const tp = g.center - k * g.spacing; // ein Spacing über dem Buy-Level k (center (k+1)·S)
if (candle.high >= tp) {
sell(pair, ts, tp, lot, 'grid_tp');
g.lots[k] = null;
}
}
for (let k = 0; k < g.lots.length; k++) {
const levelPrice = g.center - (k + 1) * g.spacing;
if (!g.lots[k] && candle.low <= levelPrice) buy(pair, ts, levelPrice, k, g);
}
}
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: equity() });
}
}
// Zwangsglattstellung
for (const pair of [...grids.keys()]) {
liquidate(pair, cfg.tradeTo, lastClose.get(pair) ?? 0, 'end_of_data');
}
return { trades, equityCurve, finalEquity: equity() };
}