From b5dd953afc3014aa964a6443b859ff3dfd1f5c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 06:43:38 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20ATR-GridBot=20mit=20Regime-Filter=20?= =?UTF-8?q?=E2=80=94=20Walk-Forward,=20Gate=20nicht=20bestanden?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/specs/2026-06-10-grid-bot-design.md | 38 +++++ docs/walkforward-grid-2026-06-10.md | 29 ++++ package.json | 1 + src/server/backtest/grid.test.ts | 99 ++++++++++++ src/server/backtest/grid.ts | 194 +++++++++++++++++++++++ src/server/engine/portfolio.ts | 2 +- src/server/scripts/grid-walkforward.ts | 112 +++++++++++++ 7 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 docs/specs/2026-06-10-grid-bot-design.md create mode 100644 docs/walkforward-grid-2026-06-10.md create mode 100644 src/server/backtest/grid.test.ts create mode 100644 src/server/backtest/grid.ts create mode 100644 src/server/scripts/grid-walkforward.ts diff --git a/docs/specs/2026-06-10-grid-bot-design.md b/docs/specs/2026-06-10-grid-bot-design.md new file mode 100644 index 0000000..2eb1f89 --- /dev/null +++ b/docs/specs/2026-06-10-grid-bot-design.md @@ -0,0 +1,38 @@ +# trade-kuns — ATR-GridBot mit Regime-Filter (Design) + +**Datum:** 2026-06-10 +**Status:** Umsetzung Phase Backtest (User-Entscheidung: Walk-Forward-Gate vor Paper-Lauf) +**Lehren aus krypto-kuns-GridBot v1:** Spacing zu eng → Fees fraßen Profit · Level-Chasing/Rebalance-Flapping · kein Regime-Bewusstsein (kaufte in fallende Messer) + +## 1. Strategie (long-only Spot, je Pair unabhängig) + +**Aktivierung** (auf 4h-Close, kein aktives Grid): +- ADX(14, 4h) < `adxMax` (Seitwärtsregime — komplementär zum Trend-Bot, der ADX ≥ 20 verlangt) +- Center `C` = Close, Spacing `S` = `spacingAtrMult` × ATR(14, 4h) — **eingefroren** für die Grid-Lebensdauer (kein Chasing) +- `N` Buy-Levels bei C − k·S (k = 1…N), Budget je Level = (Equity / 4 Pairs) / N; unter 10 USDT keine Aktivierung + +**Fills** (auf 15m): +- Sells zuerst, dann Buys — ein im selben 15m-Bar gekaufter Lot kann nicht im selben Bar verkaufen (pessimistisch, keine Intrabar-Reihenfolge-Annahme) +- Buy: Low ≤ Level-Preis und Level frei → Fill zum Level-Preis (+ Slippage + Fee) +- Take-Profit: High ≥ Level-Preis + S → Verkauf genau ein Spacing über dem Einstand; Level wird wieder frei (Re-Buy beim nächsten Dip) + +**Deaktivierung** (auf 4h-Close, alle Lots werden zum Close glattgestellt, `grid_stop`): +- Range-Breakdown: Close < C − (N+1)·S (harter Stop des gesamten Grids) +- Range-Ausbruch oben: Close > C + (N+1)·S (Re-Center beim nächsten Aktivierungs-Check) +- Trend setzt ein: ADX ≥ `adxMax` + 5 (Hysterese gegen Flapping) + +Danach ist Re-Aktivierung an jedem späteren 4h-Close möglich (neues Center). + +**Fixe A-priori-Parameter** (keine Grid-Suche — die hat beim Trend-Bot 2.58× Overfitting erzeugt): +`spacingAtrMult` 1.0 · `gridLevels` 4 · `adxMax` 20 · ATR/ADX-Periode 14 · Fees 0.1 % + 5 bps Slippage je Seite (4h-ATR ≈ 1–3 % → Spacing schlägt die ~0.3 % Round-Trip-Kosten strukturell) + +## 2. Umsetzung + +- `src/server/backtest/grid.ts` — `runGridBacktest(candles15ByPair, cfg): BacktestResult`. Eigener Lot-Ledger (mehrere Lots je Pair — `Portfolio` kann nur eine Position je Pair), identische Fee/Slippage-Mathematik, R-Multiple gegen Grid-Stop-Distanz. 15m-Timeline + 4h-Erkennung wie `runner.ts`. +- `exitReason` erweitert um `'grid_tp' | 'grid_stop'`. +- `src/server/scripts/grid-walkforward.ts` — wie `rotation-walkforward.ts`: fixe Params, `buildWindows` + `aggregateOos` + Gate, Persistenz in `backtest_runs` (kind `grid-walkforward`). +- **Gate unverändert** (§5 der Haupt-Spec). Besteht das Grid das Gate, folgt die Paper-Integration als zweite Engine; fällt es durch, ist das ein legitimes Ergebnis. + +## 3. Tests + +Aktivierung nur bei ADX < adxMax · Fill bei Low-Touch · TP bei High-Touch (nicht im Entry-Bar) · Breakdown liquidiert alles · Oszillation zwischen zwei Levels ist nach Fees profitabel bei S = 1×ATR · Determinismus. diff --git a/docs/walkforward-grid-2026-06-10.md b/docs/walkforward-grid-2026-06-10.md new file mode 100644 index 0000000..2913793 --- /dev/null +++ b/docs/walkforward-grid-2026-06-10.md @@ -0,0 +1,29 @@ +# Walk-Forward-Ergebnisse: ATR-GridBot — 2026-06-10 + +**Daten:** 103 799 15m-Candles/Pair, 2023-06-24 → 2026-06-09 (3 Jahre), 32 Fenster (Train 120d / Test 30d). +**Strategie:** ATR-Grid mit ADX-Regime-Filter, long-only (Design: `docs/specs/2026-06-10-grid-bot-design.md`). +**Methodik:** fixe A-priori-Parameter je Variante, kein Grid-Search. CLI: `bun run grid [--spacing X --levels N --adx Y]`. Runs in `backtest_runs` (kind `grid-walkforward`). + +| Variante | OOS-PF | Trades | WinRate | MaxDD | Overfit-Ratio | Gate | +|---|---|---|---|---|---|---| +| A: spacing 1.0×ATR, 4 Levels, ADX<20 | 0.87 | 1002 | 64.6 % | 18.4 % | 1.42 | ❌ PF + Fenster | +| B: spacing 1.5×ATR | 1.03 | 514 | 56.0 % | 10.6 % | 1.53 | ❌ PF + Fenster | +| C: ADX<15 (strenger) | 0.94 | 425 | 63.3 % | 6.5 % | 1.24 | ❌ PF + Fenster | + +## Befund + +Klassische Grid-Pathologie, durch Regime-Filter abgemildert, aber nicht behoben: +hohe WinRate (viele kleine TP-Gewinne à 1 Spacing), doch die `grid_stop`-Verluste +bei Range-Breakdowns (−5 Spacings über N Lots) fressen alles. Breiteres Spacing +(B) hebt den PF Richtung Break-even (1.03 ≈ Fees zurückverdient, mehr nicht), +strengerer Filter (C) senkt nur den Drawdown. Schlechtestes Fenster durchgängig +PF ≈ 0.1 — Crash-Monate treffen das Grid voll. + +**Schlussfolgerung:** Mean-Reversion-Grids haben auf Krypto-4h über 3 Jahre +keinen handelbaren Edge nach Fees — konsistent mit dem v1-GridBot-Erlebnis +(krypto-kuns) und spiegelbildlich zum Trendfolge-Befund (dünner Edge, weil +Krypto eben trendet/crasht statt sauber zu ranged). + +**Entscheidung:** Gate nicht bestanden → **kein Paper-Deploy des GridBots.** +Das Gate wird nicht aufgeweicht. Der laufende Paper-Probelauf des Trend-Bots +(trading.kuns.dev) bleibt das Live-Experiment. diff --git a/package.json b/package.json index f2b9d9a..baa96ff 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "backfill": "bun run src/server/scripts/backfill.ts", "walkforward": "bun run src/server/scripts/walkforward.ts", "rotation": "bun run src/server/scripts/rotation-walkforward.ts", + "grid": "bun run src/server/scripts/grid-walkforward.ts", "db:generate": "bunx drizzle-kit generate", "db:migrate": "bun run src/server/db/migrate.ts" }, diff --git a/src/server/backtest/grid.test.ts b/src/server/backtest/grid.test.ts new file mode 100644 index 0000000..9c546f0 --- /dev/null +++ b/src/server/backtest/grid.test.ts @@ -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 { + 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); + }); +}); diff --git a/src/server/backtest/grid.ts b/src/server/backtest/grid.ts new file mode 100644 index 0000000..0f3c23c --- /dev/null +++ b/src/server/backtest/grid.ts @@ -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, cfg: GridConfig): BacktestResult { + const { exec, params: p } = cfg; + let cash = cfg.startCapital; + const trades: ClosedTrade[] = []; + const grids = new Map(); + const lastClose = new Map(); + 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() }; +} diff --git a/src/server/engine/portfolio.ts b/src/server/engine/portfolio.ts index b4ad1ef..7e81c00 100644 --- a/src/server/engine/portfolio.ts +++ b/src/server/engine/portfolio.ts @@ -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'; } diff --git a/src/server/scripts/grid-walkforward.ts b/src/server/scripts/grid-walkforward.ts new file mode 100644 index 0000000..768fa23 --- /dev/null +++ b/src/server/scripts/grid-walkforward.ts @@ -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(); +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; + testMetrics: ReturnType; + 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();