207 lines
7.6 KiB
TypeScript
207 lines
7.6 KiB
TypeScript
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)
|
||
/**
|
||
* true: Range-Breakdown/Ausbruch/Trendbeginn liquidiert alle Lots (harter Stop).
|
||
* false: Lots werden nie mit Verlust verkauft (nur TP oder end_of_data);
|
||
* Re-Center nur, wenn das Grid leer ist und der Preis die Range verlassen hat.
|
||
*/
|
||
hardStop: boolean;
|
||
}
|
||
|
||
export const DEFAULT_GRID_PARAMS: GridParams = {
|
||
spacingAtrMult: 1.0,
|
||
gridLevels: 4,
|
||
adxMax: 20,
|
||
atrPeriod: 14,
|
||
tfMs: H4,
|
||
hardStop: true,
|
||
};
|
||
|
||
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<Pair, (typeof contexts)[number]>(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 as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number]));
|
||
|
||
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 outOfRange = bar.close < g.stopPrice || bar.close > g.upperExit;
|
||
if (p.hardStop) {
|
||
const trendStart = !Number.isNaN(ctx.adx[i]) && ctx.adx[i] >= p.adxMax + 5; // Hysterese
|
||
if (outOfRange || trendStart) liquidate(pair, barCloseTs, bar.close, 'grid_stop');
|
||
} else if (outOfRange && g.lots.every((l) => !l)) {
|
||
grids.delete(pair); // leeres Grid folgt dem Preis (Re-Center ohne Verlust)
|
||
}
|
||
} 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() / contexts.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() };
|
||
}
|