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:
38
docs/specs/2026-06-10-grid-bot-design.md
Normal file
38
docs/specs/2026-06-10-grid-bot-design.md
Normal file
@@ -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.
|
||||||
29
docs/walkforward-grid-2026-06-10.md
Normal file
29
docs/walkforward-grid-2026-06-10.md
Normal file
@@ -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.
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
"backfill": "bun run src/server/scripts/backfill.ts",
|
"backfill": "bun run src/server/scripts/backfill.ts",
|
||||||
"walkforward": "bun run src/server/scripts/walkforward.ts",
|
"walkforward": "bun run src/server/scripts/walkforward.ts",
|
||||||
"rotation": "bun run src/server/scripts/rotation-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:generate": "bunx drizzle-kit generate",
|
||||||
"db:migrate": "bun run src/server/db/migrate.ts"
|
"db:migrate": "bun run src/server/db/migrate.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
99
src/server/backtest/grid.test.ts
Normal file
99
src/server/backtest/grid.test.ts
Normal 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
194
src/server/backtest/grid.ts
Normal 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() };
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ export interface ClosedTrade {
|
|||||||
qty: number;
|
qty: number;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
r: number;
|
r: number;
|
||||||
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation';
|
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop';
|
||||||
side: 'long' | 'short';
|
side: 'long' | 'short';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
src/server/scripts/grid-walkforward.ts
Normal file
112
src/server/scripts/grid-walkforward.ts
Normal 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();
|
||||||
Reference in New Issue
Block a user