feat: ATR-GridBot mit Regime-Filter — Walk-Forward, Gate nicht bestanden

3 fixe Varianten (spacing 1.0/1.5×ATR, ADX<20/15): OOS-PF 0.87/1.03/0.94.
Grid-Stops bei Range-Breakdowns fressen die TP-Gewinne — kein Paper-Deploy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 06:43:38 +00:00
parent 2dcab7f24d
commit b5dd953afc
7 changed files with 474 additions and 1 deletions

View File

@@ -0,0 +1,99 @@
import { describe, expect, test } from 'bun:test';
import type { Candle, Pair } from '../types';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { runGridBacktest, DEFAULT_GRID_PARAMS, type GridConfig } from './grid';
const M15 = 15 * 60 * 1000;
const PAIR: Pair = 'BTC_USDT';
const T0 = Date.UTC(2025, 0, 1);
function cfg(c15: Candle[], over: Partial<GridConfig> = {}): GridConfig {
return {
startCapital: 1000,
exec: DEFAULT_EXEC,
params: DEFAULT_GRID_PARAMS,
minNotionalUsdt: 10,
tradeFrom: 0,
tradeTo: c15[c15.length - 1].ts + M15,
...over,
};
}
/** 15m-Candle als flacher Bar um `price` mit gegebener Range. */
function bar(k: number, price: number, lo = 0.1, hi = 0.1): Candle {
return { ts: T0 + k * M15, open: price, high: price + hi, low: price - lo, close: price, volume: 1 };
}
/**
* Seitwärtsserie um 100: erst 50 4h-Bars Mini-Oszillation (ATR klein → Grid
* aktiviert mit engen Levels nahe 100), dann breite Oszillation ±2, die Levels
* füllt und TPs auslöst. Sinus-Periode 1 Tag → ADX bleibt < 10.
*/
function sideways(bars4h: number): Candle[] {
const out: Candle[] = [];
const warmup = 50 * 16;
for (let k = 0; k < bars4h * 16; k++) {
const amp = k < warmup ? 0.2 : 2;
out.push(bar(k, 100 + amp * Math.sin((2 * Math.PI * k) / 96)));
}
return out;
}
/** Starker Trend: monotone Steigung → hoher ADX. */
function trending(bars4h: number, slope = 1): Candle[] {
const out: Candle[] = [];
for (let k = 0; k < bars4h * 16; k++) out.push(bar(k, 100 + k * slope));
return out;
}
describe('runGridBacktest', () => {
test('Seitwärtsmarkt: Grid handelt und ist nach Fees profitabel', () => {
const c15 = sideways(200);
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
const tps = res.trades.filter((t) => t.exitReason === 'grid_tp');
expect(tps.length).toBeGreaterThan(3);
// Jeder TP gewinnt genau ~1 Spacing minus Fees → positiv
for (const t of tps) expect(t.pnl).toBeGreaterThan(0);
});
test('starker Trend: ADX-Filter verhindert Aktivierung (keine Trades)', () => {
const c15 = trending(150);
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
expect(res.trades.length).toBe(0);
expect(res.finalEquity).toBe(1000);
});
test('Range-Breakdown liquidiert alle Lots als grid_stop', () => {
// Seitwärts (Grid aktiviert, Dips füllen Levels), dann Absturz weit unter den Stop
const side = sideways(120);
const crash: Candle[] = [];
let price = side[side.length - 1].close;
for (let k = 0; k < 32 * 16; k++) {
price = Math.max(5, price - 0.4);
crash.push(bar(side.length + k, price));
}
const c15 = [...side, ...crash];
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
const stops = res.trades.filter((t) => t.exitReason === 'grid_stop');
expect(stops.length).toBeGreaterThan(0);
for (const t of stops) expect(t.pnl).toBeLessThan(0);
// Nach dem Crash bleibt kein Grid mit Lots offen, das beim end_of_data verliert
const eod = res.trades.filter((t) => t.exitReason === 'end_of_data');
// Re-Aktivierung nach dem Crash ist erlaubt — aber alle Crash-Lots wurden via grid_stop geschlossen
expect(stops.length + eod.length + res.trades.filter((t) => t.exitReason === 'grid_tp').length).toBe(res.trades.length);
});
test('Determinismus: identischer Input → identisches Ergebnis', () => {
const c15 = sideways(150);
const a = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
const b = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
});
test('kein Trade vor tradeFrom', () => {
const c15 = sideways(200);
const mid = c15[Math.floor(c15.length / 2)].ts;
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15, { tradeFrom: mid }));
for (const t of res.trades) expect(t.entryTs).toBeGreaterThanOrEqual(mid);
});
});

194
src/server/backtest/grid.ts Normal file
View File

@@ -0,0 +1,194 @@
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { aggregate4h, 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(14, 4h) bei Aktivierung
gridLevels: number; // Buy-Levels unterhalb des Centers
adxMax: number; // Grid nur aktiv im Seitwärtsregime (ADX < adxMax)
atrPeriod: number;
}
export const DEFAULT_GRID_PARAMS: GridParams = {
spacingAtrMult: 1.0,
gridLevels: 4,
adxMax: 20,
atrPeriod: 14,
};
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.
* 4h-Close: 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 = aggregate4h(c15);
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 / H4) * H4;
// 1) Neu abgeschlossene 4h-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 + H4;
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() };
}

View File

@@ -31,7 +31,7 @@ export interface ClosedTrade {
qty: number;
pnl: number;
r: number;
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation';
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop';
side: 'long' | 'short';
}

View File

@@ -0,0 +1,112 @@
import { PAIRS, type Candle, type Pair } from '../types';
import { getCandles, getCoverage } from '../market/candle-store';
import { buildWindows, aggregateOos } from '../backtest/walkforward';
import { runGridBacktest, DEFAULT_GRID_PARAMS, type GridConfig } from '../backtest/grid';
import { computeMetrics } from '../backtest/metrics';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { db, sql } from '../db/client';
import { backtestRuns } from '../db/schema';
// --- Feste A-priori-Parameter (kein Grid-Search; Varianten nur als bewusste
// Design-Entscheidung via CLI: --spacing 1.5 --levels 4 --adx 15) ---
function argNum(flag: string, def: number): number {
const i = process.argv.indexOf(flag);
return i >= 0 ? Number(process.argv[i + 1]) : def;
}
const PARAMS = {
...DEFAULT_GRID_PARAMS, // spacing 1×ATR, 4 Levels, ADX < 20
spacingAtrMult: argNum('--spacing', DEFAULT_GRID_PARAMS.spacingAtrMult),
gridLevels: argNum('--levels', DEFAULT_GRID_PARAMS.gridLevels),
adxMax: argNum('--adx', DEFAULT_GRID_PARAMS.adxMax),
};
const START_CAPITAL = 1000;
const EXEC = DEFAULT_EXEC;
const MIN_NOTIONAL = 10;
const candles15ByPair = new Map<Pair, Candle[]>();
let dataFrom = 0;
let dataTo = Number.MAX_SAFE_INTEGER;
for (const pair of PAIRS) {
const cov = await getCoverage(pair);
if (!cov.from || !cov.to) throw new Error(`Keine Candles für ${pair} — erst 'bun run backfill' ausführen.`);
candles15ByPair.set(pair, await getCandles(pair));
dataFrom = Math.max(dataFrom, cov.from.getTime());
dataTo = Math.min(dataTo, cov.to.getTime());
console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()}${cov.to.toISOString()})`);
}
console.log(`\nATR-Grid (fix: spacing ${PARAMS.spacingAtrMult}×ATR, ${PARAMS.gridLevels} Levels, ADX < ${PARAMS.adxMax}, long-only)`);
console.log(`Walk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`);
const windows = buildWindows(dataFrom, dataTo);
type WindowResult = {
window: { trainFrom: number; trainTo: number; testFrom: number; testTo: number };
trainMetrics: ReturnType<typeof computeMetrics>;
testMetrics: ReturnType<typeof computeMetrics>;
testTrades: import('../engine/portfolio').ClosedTrade[];
testEquityCurve: import('../backtest/metrics').EquityPoint[];
};
const results: WindowResult[] = [];
for (const [wi, w] of windows.entries()) {
const mkCfg = (tradeFrom: number, tradeTo: number): GridConfig => ({
startCapital: START_CAPITAL,
exec: EXEC,
params: PARAMS,
minNotionalUsdt: MIN_NOTIONAL,
tradeFrom,
tradeTo,
});
const trainResult = runGridBacktest(candles15ByPair, mkCfg(w.trainFrom, w.trainTo));
const trainMetrics = computeMetrics(trainResult.trades, trainResult.equityCurve, START_CAPITAL);
const testResult = runGridBacktest(candles15ByPair, mkCfg(w.testFrom, w.testTo));
const testMetrics = computeMetrics(testResult.trades, testResult.equityCurve, START_CAPITAL);
results.push({
window: w,
trainMetrics,
testMetrics,
testTrades: testResult.trades,
testEquityCurve: testResult.equityCurve,
});
console.log(
`Fenster ${wi + 1}/${windows.length}: Train-PF ${trainMetrics.profitFactor.toFixed(2)} ` +
`→ Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`,
);
}
const { oosMetrics, oosEquityCurve, gate } = aggregateOos(results, START_CAPITAL);
console.log('\n========== OOS-GESAMTERGEBNIS ==========');
const m = oosMetrics;
console.log(`Trades: ${m.trades} | WinRate: ${(m.winRate * 100).toFixed(1)}% | PF: ${m.profitFactor.toFixed(2)}`);
console.log(`TotalPnl: ${m.totalPnl.toFixed(2)} USDT | MaxDD: ${(m.maxDrawdownPct * 100).toFixed(1)}% | AvgR: ${m.avgR.toFixed(2)}`);
console.log('\n========== DEPLOY-GATE ==========');
for (const c of gate.checks) {
console.log(`${c.pass ? '✅' : '❌'} ${c.name}: ${Number.isFinite(c.value) ? c.value.toFixed(2) : c.value}`);
}
console.log(`\n→ GATE ${gate.pass ? 'BESTANDEN' : 'NICHT BESTANDEN'}`);
await db.insert(backtestRuns).values({
kind: 'grid-walkforward',
config: { startCapital: START_CAPITAL, exec: EXEC, params: PARAMS, minNotionalUsdt: MIN_NOTIONAL } as any,
result: {
gate,
oosMetrics,
oosEquityCurve,
windows: results.map((r) => ({
window: r.window,
trainMetrics: r.trainMetrics,
testMetrics: r.testMetrics,
})),
} as any,
});
console.log('Run in backtest_runs gespeichert.');
await sql.end();