From 8298b522fcd7efb1276f26ff24799e7edc873f47 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 20:17:09 +0000 Subject: [PATCH] docs: Implementierungsplan Phase 1+2 (Fundament + Backtest/Walk-Forward bis Gate) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-06-09-phase1-backtest-gate.md | 1735 +++++++++++++++++ 1 file changed, 1735 insertions(+) create mode 100644 docs/plans/2026-06-09-phase1-backtest-gate.md diff --git a/docs/plans/2026-06-09-phase1-backtest-gate.md b/docs/plans/2026-06-09-phase1-backtest-gate.md new file mode 100644 index 0000000..2819c73 --- /dev/null +++ b/docs/plans/2026-06-09-phase1-backtest-gate.md @@ -0,0 +1,1735 @@ +# trade-kuns Phase 1+2: Fundament + Backtest/Walk-Forward bis Gate-Entscheidung + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Backtest- und Walk-Forward-Infrastruktur für die Donchian-Trendfolge-Strategie bauen, echte Marktdaten (15m, 4 Pairs, ≥ 12 Monate) backfillen und die Gate-Entscheidung (Spec §5) auf echten Daten treffen. + +**Architecture:** Pure Funktionen für Indikatoren/Strategie/Sizing, ein deterministischer Backtest-Runner über gemergte 15m-Candle-Timelines (Entries auf 4h-Close, Stop-Checks auf 15m-Lows), darüber ein Walk-Forward-Runner mit Grid-Search nur auf Train-Fenstern. DB nur für Candles + Run-Persistenz. + +**Tech Stack:** Bun 1.3, TypeScript, Drizzle ORM + postgres (porsager), Zod, `bun test`. Repo: `~/trade-kuns`. Spec: `docs/specs/2026-06-09-trade-kuns-design.md`. + +**Konventionen:** +- Alle Pfade relativ zu `~/trade-kuns`. +- Tests kolokiert als `*.test.ts` neben dem Modul, Runner: `bun test`. +- Preise/Mengen als `number` (Paper-Trading, keine Cent-Genauigkeit nötig). +- Timestamps als Unix-ms (`ts` = Start der Candle). +- Commits: Conventional Commits, nach jedem Task. + +--- + +### Task 1: Projekt-Setup + +**Files:** +- Create: `package.json`, `tsconfig.json`, `.gitignore`, `.env.example`, `src/server/types.ts`, `src/server/types.test.ts` + +- [ ] **Step 1: Dateien anlegen** + +`package.json`: +```json +{ + "name": "trade-kuns", + "private": true, + "type": "module", + "scripts": { + "test": "bun test", + "backfill": "bun run src/server/scripts/backfill.ts", + "walkforward": "bun run src/server/scripts/walkforward.ts", + "db:generate": "bunx drizzle-kit generate", + "db:migrate": "bun run src/server/db/migrate.ts" + }, + "dependencies": { + "drizzle-orm": "^0.44.0", + "postgres": "^3.4.5", + "zod": "^3.24.0" + }, + "devDependencies": { + "@types/bun": "^1.2.0", + "drizzle-kit": "^0.31.0", + "typescript": "^5.8.0" + } +} +``` + +`tsconfig.json`: +```json +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"] +} +``` + +`.gitignore`: +``` +node_modules/ +.env +drizzle/meta/_journal.json.bak +``` + +`.env.example`: +``` +# Shared-Postgres via Host-Port (siehe ~/shared-postgres/README.md) +# Passwort: SHARED_POSTGRES_PASSWORD aus ~/.secrets/coolify-tokens.env +DATABASE_URL=postgres://mika:PASSWORT@localhost:54320/tradekuns +``` + +`src/server/types.ts`: +```ts +export interface Candle { + ts: number; // Unix ms, Start der Candle + open: number; + high: number; + low: number; + close: number; + volume: number; +} + +export const PAIRS = ['BTC_USDT', 'ETH_USDT', 'SOL_USDT', 'XRP_USDT'] as const; +export type Pair = (typeof PAIRS)[number]; +``` + +`src/server/types.test.ts` (Smoke-Test, prüft Toolchain): +```ts +import { expect, test } from 'bun:test'; +import { PAIRS } from './types'; + +test('vier Pairs definiert', () => { + expect(PAIRS).toHaveLength(4); +}); +``` + +- [ ] **Step 2: Installieren und Test ausführen** + +```bash +cd ~/trade-kuns && ~/.bun/bin/bun install && ~/.bun/bin/bun test +``` +Erwartung: 1 pass. + +- [ ] **Step 3: Commit** + +```bash +git add -A && git commit -m "feat: Projekt-Skelett (Bun, TypeScript, Drizzle-Deps)" +``` + +--- + +### Task 2: 4h-Aggregation aus 15m-Candles + +**Files:** +- Create: `src/server/market/aggregate.ts`, `src/server/market/aggregate.test.ts` + +- [ ] **Step 1: Failing Test schreiben** + +`src/server/market/aggregate.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import type { Candle } from '../types'; +import { aggregate4h, H4 } from './aggregate'; + +const M15 = 15 * 60 * 1000; + +function c15(ts: number, o: number, h: number, l: number, cl: number, v = 1): Candle { + return { ts, open: o, high: h, low: l, close: cl, volume: v }; +} + +test('aggregiert 16 15m-Candles zu einer 4h-Candle, verwirft unvollständigen letzten Bucket', () => { + const candles: Candle[] = []; + // Bucket 1: ts 0..15*M15 — Preise 100..115 + for (let i = 0; i < 16; i++) candles.push(c15(i * M15, 100 + i, 101 + i, 99 + i, 100.5 + i)); + // Bucket 2: nur 2 Candles (unvollständig, wird aber als "letzter" sowieso verworfen, + // sobald kein dritter Bucket folgt) + candles.push(c15(H4, 200, 210, 195, 205)); + candles.push(c15(H4 + M15, 205, 220, 204, 218)); + + const out = aggregate4h(candles); + expect(out).toHaveLength(1); + expect(out[0].ts).toBe(0); + expect(out[0].open).toBe(100); + expect(out[0].high).toBe(101 + 15); + expect(out[0].low).toBe(99); + expect(out[0].close).toBe(100.5 + 15); + expect(out[0].volume).toBe(16); +}); + +test('Lücken in den Daten erzeugen keine Phantom-Buckets', () => { + const candles: Candle[] = [ + c15(0, 1, 2, 0.5, 1.5), + c15(2 * H4, 3, 4, 2.5, 3.5), // Bucket dazwischen fehlt komplett + c15(2 * H4 + M15, 3.5, 5, 3, 4), + c15(3 * H4, 9, 9, 9, 9), // öffnet neuen Bucket → Bucket 2*H4 wird finalisiert + ]; + const out = aggregate4h(candles); + expect(out.map((c) => c.ts)).toEqual([0, 2 * H4]); + expect(out[1].high).toBe(5); +}); +``` + +- [ ] **Step 2: Test ausführen** — `~/.bun/bin/bun test aggregate` → FAIL (Modul fehlt). + +- [ ] **Step 3: Implementieren** + +`src/server/market/aggregate.ts`: +```ts +import type { Candle } from '../types'; + +export const H4 = 4 * 60 * 60 * 1000; + +/** + * Aggregiert 15m-Candles (sortiert, ts aufsteigend) zu 4h-Candles. + * Der letzte Bucket wird verworfen, weil nicht feststellbar ist, ob er + * abgeschlossen ist — der Backtest-Runner arbeitet nur auf geschlossenen Candles. + */ +export function aggregate4h(c15: Candle[]): Candle[] { + const out: Candle[] = []; + let cur: Candle | null = null; + for (const c of c15) { + const bucket = Math.floor(c.ts / H4) * H4; + if (cur && cur.ts === bucket) { + cur.high = Math.max(cur.high, c.high); + cur.low = Math.min(cur.low, c.low); + cur.close = c.close; + cur.volume += c.volume; + } else { + if (cur) out.push(cur); + cur = { ts: bucket, open: c.open, high: c.high, low: c.low, close: c.close, volume: c.volume }; + } + } + return out; // letzter Bucket absichtlich nicht gepusht +} +``` + +- [ ] **Step 4: Test ausführen** — `~/.bun/bin/bun test aggregate` → 2 pass. + +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: 4h-Aggregation aus 15m-Candles"` + +--- + +### Task 3: Indikator EMA + +**Files:** +- Create: `src/server/indicators/ema.ts`, `src/server/indicators/ema.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/indicators/ema.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { ema } from './ema'; + +test('EMA: NaN vor period-1, SMA-Seed, dann rekursiv', () => { + // ema([1..8], 5): Seed bei i=4 = SMA(1..5) = 3, k = 1/3 + // i=5: 6/3 + 3*2/3 = 4 ; i=6: 7/3 + 4*2/3 = 5 ; i=7: 6 + const out = ema([1, 2, 3, 4, 5, 6, 7, 8], 5); + expect(out.slice(0, 4).every(Number.isNaN)).toBe(true); + expect(out[4]).toBeCloseTo(3); + expect(out[5]).toBeCloseTo(4); + expect(out[6]).toBeCloseTo(5); + expect(out[7]).toBeCloseTo(6); +}); + +test('EMA: zu wenig Daten → alles NaN', () => { + expect(ema([1, 2, 3], 5).every(Number.isNaN)).toBe(true); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/indicators/ema.ts`: +```ts +export function ema(values: number[], period: number): number[] { + const out = new Array(values.length).fill(NaN); + if (values.length < period) return out; + let sum = 0; + for (let i = 0; i < period; i++) sum += values[i]; + out[period - 1] = sum / period; + const k = 2 / (period + 1); + for (let i = period; i < values.length; i++) { + out[i] = values[i] * k + out[i - 1] * (1 - k); + } + return out; +} +``` + +- [ ] **Step 4: Ausführen** → 2 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: EMA-Indikator"` + +--- + +### Task 4: Indikator ATR (Wilder) + +**Files:** +- Create: `src/server/indicators/atr.ts`, `src/server/indicators/atr.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/indicators/atr.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import type { Candle } from '../types'; +import { atr } from './atr'; + +function c(h: number, l: number, cl: number): Candle { + return { ts: 0, open: cl, high: h, low: l, close: cl, volume: 1 }; +} + +test('ATR: konstante Range ohne Gaps → ATR = Range', () => { + // High−Low = 2 überall, Close mittig → TR = 2 für alle Bars + const candles = Array.from({ length: 20 }, () => c(11, 9, 10)); + const out = atr(candles, 14); + expect(out.slice(0, 13).every(Number.isNaN)).toBe(true); + expect(out[13]).toBeCloseTo(2); + expect(out[19]).toBeCloseTo(2); +}); + +test('ATR: Gap wird über True Range erfasst', () => { + // 14 ruhige Bars (TR=2), dann Gap: prevClose=10, neue Bar h=21 l=20 → TR = max(1, 11, 10) = 11 + const candles = Array.from({ length: 14 }, () => c(11, 9, 10)); + candles.push(c(21, 20, 20.5)); + const out = atr(candles, 14); + // Wilder: (2*13 + 11) / 14 + expect(out[14]).toBeCloseTo((2 * 13 + 11) / 14); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/indicators/atr.ts`: +```ts +import type { Candle } from '../types'; + +export function atr(candles: Candle[], period: number): number[] { + const out = new Array(candles.length).fill(NaN); + if (candles.length < period) return out; + const tr = candles.map((c, i) => + i === 0 + ? c.high - c.low + : Math.max( + c.high - c.low, + Math.abs(c.high - candles[i - 1].close), + Math.abs(c.low - candles[i - 1].close), + ), + ); + let sum = 0; + for (let i = 0; i < period; i++) sum += tr[i]; + out[period - 1] = sum / period; + for (let i = period; i < candles.length; i++) { + out[i] = (out[i - 1] * (period - 1) + tr[i]) / period; + } + return out; +} +``` + +- [ ] **Step 4: Ausführen** → 2 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: ATR-Indikator (Wilder-Smoothing)"` + +--- + +### Task 5: Indikator Donchian-High + +**Files:** +- Create: `src/server/indicators/donchian.ts`, `src/server/indicators/donchian.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/indicators/donchian.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import type { Candle } from '../types'; +import { donchianHigh } from './donchian'; + +function c(h: number): Candle { + return { ts: 0, open: h, high: h, low: h - 1, close: h, volume: 1 }; +} + +test('donchianHigh: höchstes Hoch der letzten N Candles VOR i (i exklusiv)', () => { + const candles = [c(10), c(12), c(11), c(9), c(15)]; + const out = donchianHigh(candles, 3); + expect(out.slice(0, 3).every(Number.isNaN)).toBe(true); + expect(out[3]).toBe(12); // max(10,12,11) + expect(out[4]).toBe(12); // max(12,11,9) — Candle 4 selbst zählt nicht +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/indicators/donchian.ts`: +```ts +import type { Candle } from '../types'; + +/** Höchstes Hoch der letzten `period` Candles VOR Index i (Candle i ausgeschlossen). */ +export function donchianHigh(candles: Candle[], period: number): number[] { + const out = new Array(candles.length).fill(NaN); + for (let i = period; i < candles.length; i++) { + let max = -Infinity; + for (let j = i - period; j < i; j++) max = Math.max(max, candles[j].high); + out[i] = max; + } + return out; +} +``` + +- [ ] **Step 4: Ausführen** → 1 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Donchian-High-Indikator"` + +--- + +### Task 6: Strategie — Entry-Evaluation + +**Files:** +- Create: `src/server/strategy/donchian-trend.ts`, `src/server/strategy/donchian-trend.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/strategy/donchian-trend.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import type { Candle } from '../types'; +import { computeIndicators, evaluateAt, DEFAULT_PARAMS, type StrategyParams } from './donchian-trend'; + +const P: StrategyParams = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 3, trendEmaPeriod: 5 }; + +function c(o: number, h: number, l: number, cl: number, ts = 0): Candle { + return { ts, open: o, high: h, low: l, close: cl, volume: 1 }; +} + +/** Aufwärtstrend, letzte Candle bricht über das 3er-Hoch aus. */ +function breakoutSeries(): Candle[] { + const s: Candle[] = []; + for (let i = 0; i < 7; i++) s.push(c(10 + i, 11 + i, 9 + i, 10.5 + i, i)); + // bisheriges 3er-Hoch: max(high[4..6]) = 17 → Close 18 bricht aus, weit über EMA5 + s.push(c(17, 18.5, 16.5, 18, 7)); + return s; +} + +test('Long-Signal bei Donchian-Breakout über Trend-EMA', () => { + const c4h = breakoutSeries(); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); + expect(ev.signal).toBe('long'); + expect(ev.blockedBy).toBeNull(); + expect(ev.donchianHigh).toBe(17); + expect(Number.isNaN(ev.atr)).toBe(false); +}); + +test('blockiert unter Donchian-High', () => { + const c4h = breakoutSeries(); + c4h[c4h.length - 1].close = 16.9; // unter 17 + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); + expect(ev.signal).toBeNull(); + expect(ev.blockedBy).toBe('below_donchian'); +}); + +test('blockiert unter Trend-EMA (Bärenmarkt-Filter)', () => { + // Abwärtstrend mit einzelnem Mini-Breakout: Close über Donchian, aber unter EMA + const s: Candle[] = []; + for (let i = 0; i < 7; i++) s.push(c(50 - i * 3, 51 - i * 3, 49 - i * 3, 50 - i * 3, i)); + // letzte 3 Highs: 45,42,39 → Breakout über 45 nötig, EMA5 liegt weit höher + s.push(c(39, 46, 38, 45.5, 7)); + const ev = evaluateAt(s, computeIndicators(s, P), s.length - 1, P); + expect(ev.signal).toBeNull(); + expect(ev.blockedBy).toBe('below_trend_ema'); +}); + +test('blockiert bei zu wenig Daten', () => { + const c4h = breakoutSeries().slice(0, 4); + const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P); + expect(ev.blockedBy).toBe('insufficient_data'); +}); + +test('DEFAULT_PARAMS entsprechen der Spec', () => { + expect(DEFAULT_PARAMS).toEqual({ donchianPeriod: 20, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200 }); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/strategy/donchian-trend.ts`: +```ts +import type { Candle } from '../types'; +import { ema } from '../indicators/ema'; +import { atr } from '../indicators/atr'; +import { donchianHigh } from '../indicators/donchian'; + +export interface StrategyParams { + donchianPeriod: number; + atrPeriod: number; + atrMultiplier: number; + trendEmaPeriod: number; +} + +export const DEFAULT_PARAMS: StrategyParams = { + donchianPeriod: 20, + atrPeriod: 14, + atrMultiplier: 3, + trendEmaPeriod: 200, +}; + +export interface IndicatorSet { + trendEma: number[]; + donchianHigh: number[]; + atr: number[]; +} + +export interface Evaluation { + signal: 'long' | null; + blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null; + close: number; + atr: number; + donchianHigh: number; + trendEma: number; +} + +/** Indikatoren einmal über die ganze Serie — Index i nutzt nur Daten ≤ i (kein Lookahead). */ +export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSet { + return { + trendEma: ema(c4h.map((c) => c.close), p.trendEmaPeriod), + donchianHigh: donchianHigh(c4h, p.donchianPeriod), + atr: atr(c4h, p.atrPeriod), + }; +} + +/** Bewertet die (abgeschlossene) 4h-Candle an Index i. */ +export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, p: StrategyParams): Evaluation { + const close = c4h[i]?.close ?? NaN; + const base = { close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i], trendEma: ind.trendEma[i] }; + if ( + i < 0 || + Number.isNaN(ind.trendEma[i]) || + Number.isNaN(ind.donchianHigh[i]) || + Number.isNaN(ind.atr[i]) + ) { + return { signal: null, blockedBy: 'insufficient_data', ...base }; + } + if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base }; + if (close <= ind.trendEma[i]) return { signal: null, blockedBy: 'below_trend_ema', ...base }; + return { signal: 'long', blockedBy: null, ...base }; +} +``` + +- [ ] **Step 4: Ausführen** → 5 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Donchian-Trend-Strategie (Entry-Evaluation)"` + +--- + +### Task 7: Chandelier-Trailing-Stop + +**Files:** +- Create: `src/server/strategy/chandelier.ts`, `src/server/strategy/chandelier.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/strategy/chandelier.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { updateChandelier } from './chandelier'; + +test('Stop steigt mit neuem Hoch', () => { + // hh 100 → 110, ATR 2, Mult 3 → Stop 110−6 = 104 + const r = updateChandelier({ highestHigh: 100, stop: 94 }, 110, 2, 3); + expect(r.highestHigh).toBe(110); + expect(r.stop).toBe(104); +}); + +test('Stop fällt NIE — auch wenn ATR explodiert', () => { + // hh bleibt 110, ATR springt auf 10 → Kandidat 80, aber Stop bleibt 104 + const r = updateChandelier({ highestHigh: 110, stop: 104 }, 105, 10, 3); + expect(r.highestHigh).toBe(110); + expect(r.stop).toBe(104); +}); + +test('NaN-ATR lässt Stop unverändert', () => { + const r = updateChandelier({ highestHigh: 110, stop: 104 }, 120, NaN, 3); + expect(r.highestHigh).toBe(120); + expect(r.stop).toBe(104); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/strategy/chandelier.ts`: +```ts +export interface TrailState { + highestHigh: number; + stop: number; +} + +/** Chandelier-Stop: hh − mult×ATR, wandert nur aufwärts. Aufruf pro abgeschlossener 4h-Candle. */ +export function updateChandelier(state: TrailState, barHigh: number, atrValue: number, mult: number): TrailState { + const highestHigh = Math.max(state.highestHigh, barHigh); + const candidate = Number.isNaN(atrValue) ? -Infinity : highestHigh - mult * atrValue; + return { highestHigh, stop: Math.max(state.stop, candidate) }; +} +``` + +- [ ] **Step 4: Ausführen** → 3 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Chandelier-Trailing-Stop"` + +--- + +### Task 8: Position-Sizing + +**Files:** +- Create: `src/server/engine/sizing.ts`, `src/server/engine/sizing.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/engine/sizing.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { sizePosition, DEFAULT_RISK } from './sizing'; + +test('1% Equity-Risiko bestimmt die Größe', () => { + // Equity 1000, Risiko 10 USDT; Entry 100, Stop 94 → 6 USDT Risiko/Einheit → qty 10/6 + const r = sizePosition(1000, 1000, 100, 94, DEFAULT_RISK); + expect(r.qty).toBeCloseTo(10 / 6); + expect(r.notional).toBeCloseTo((10 / 6) * 100); + expect(r.blockedBy).toBeNull(); +}); + +test('Cap bei 30% der Equity', () => { + // enger Stop: Entry 100, Stop 99.5 → ungecappt 2000 USDT Notional → Cap 300 + const r = sizePosition(1000, 1000, 100, 99.5, DEFAULT_RISK); + expect(r.notional).toBeCloseTo(300); +}); + +test('Cap durch verfügbares Cash', () => { + const r = sizePosition(1000, 100, 100, 99.5, DEFAULT_RISK); + expect(r.notional).toBeLessThanOrEqual(100); +}); + +test('blockiert unter Mindestordergröße', () => { + const r = sizePosition(1000, 5, 100, 94, DEFAULT_RISK); + expect(r.qty).toBe(0); + expect(r.blockedBy).toBe('min_notional'); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/engine/sizing.ts`: +```ts +export interface RiskConfig { + riskPerTradePct: number; // 0.01 = 1% der Equity + maxPositionPct: number; // 0.30 + minNotionalUsdt: number; // 10 +} + +export const DEFAULT_RISK: RiskConfig = { riskPerTradePct: 0.01, maxPositionPct: 0.3, minNotionalUsdt: 10 }; + +export interface SizingResult { + qty: number; + notional: number; + riskAmount: number; + blockedBy: 'min_notional' | null; +} + +export function sizePosition( + equity: number, + cash: number, + entryPrice: number, + stopPrice: number, + cfg: RiskConfig, +): SizingResult { + const riskAmount = equity * cfg.riskPerTradePct; + const stopDist = entryPrice - stopPrice; + let notional = (riskAmount / stopDist) * entryPrice; + // 0.997: Puffer für Fee (0.1%) + Slippage (0.05%) auf der Entry-Seite + notional = Math.min(notional, equity * cfg.maxPositionPct, cash * 0.997); + if (!(notional >= cfg.minNotionalUsdt)) return { qty: 0, notional: 0, riskAmount, blockedBy: 'min_notional' }; + return { qty: notional / entryPrice, notional, riskAmount, blockedBy: null }; +} +``` + +- [ ] **Step 4: Ausführen** → 4 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: risikobasiertes Position-Sizing mit Caps"` + +--- + +### Task 9: Paper-Portfolio (Fills, Fees, Equity) + +**Files:** +- Create: `src/server/engine/portfolio.ts`, `src/server/engine/portfolio.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/engine/portfolio.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { Portfolio, DEFAULT_EXEC } from './portfolio'; + +test('Entry: Slippage verteuert Fill, Fee reduziert Cash', () => { + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 94, 1, 10); // ts, signalPrice, initialStop, qty, riskAmount + const pos = p.positions.get('BTC_USDT')!; + expect(pos.entryPrice).toBeCloseTo(100.05); // 100 * (1 + 0.0005) + // Cash: 1000 − 100.05 − 0.10005 (Fee 0.1%) + expect(p.cash).toBeCloseTo(1000 - 100.05 - 0.10005); +}); + +test('Exit: PnL netto inkl. beider Fees und Slippage, R-Multiple korrekt', () => { + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 94, 1, 10); + const trade = p.close('BTC_USDT', 1, 110, 'trailing_stop'); + // Exit-Fill: 110 * (1−0.0005) = 109.945; Proceeds−Fee = 109.945 * 0.999 + const cost = 100.05 + 0.10005; + const net = 109.945 * 0.999 - cost; + expect(trade.pnl).toBeCloseTo(net); + expect(trade.r).toBeCloseTo(net / 10); + expect(p.positions.size).toBe(0); +}); + +test('Equity = Cash + Marktwert offener Positionen', () => { + const p = new Portfolio(1000, DEFAULT_EXEC); + p.open('BTC_USDT', 0, 100, 94, 2, 10); + expect(p.equity(new Map([['BTC_USDT', 105]]))).toBeCloseTo(p.cash + 2 * 105); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/engine/portfolio.ts`: +```ts +import type { Pair } from '../types'; + +export interface ExecConfig { + feeRate: number; // 0.001 pro Seite + slippage: number; // 0.0005 pro Seite +} + +export const DEFAULT_EXEC: ExecConfig = { feeRate: 0.001, slippage: 0.0005 }; + +export interface Position { + pair: Pair; + qty: number; + entryTs: number; + entryPrice: number; // Fill inkl. Slippage + entryCost: number; // qty*fill + Entry-Fee + initialStop: number; + stop: number; + highestHigh: number; + riskAmount: number; +} + +export interface ClosedTrade { + pair: Pair; + entryTs: number; + entryPrice: number; + exitTs: number; + exitPrice: number; + qty: number; + pnl: number; + r: number; + exitReason: 'trailing_stop' | 'end_of_data'; +} + +export class Portfolio { + cash: number; + positions = new Map(); + trades: ClosedTrade[] = []; + + constructor(startCapital: number, private exec: ExecConfig) { + this.cash = startCapital; + } + + open(pair: Pair, ts: number, signalPrice: number, initialStop: number, qty: number, riskAmount: number): void { + const fill = signalPrice * (1 + this.exec.slippage); + const cost = qty * fill; + const fee = cost * this.exec.feeRate; + this.cash -= cost + fee; + this.positions.set(pair, { + pair, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee, + initialStop, stop: initialStop, highestHigh: signalPrice, riskAmount, + }); + } + + close(pair: Pair, ts: number, exitPrice: number, exitReason: ClosedTrade['exitReason']): ClosedTrade { + const pos = this.positions.get(pair); + if (!pos) throw new Error(`close ohne Position: ${pair}`); + const fill = exitPrice * (1 - this.exec.slippage); + const proceeds = pos.qty * fill; + const fee = proceeds * this.exec.feeRate; + this.cash += proceeds - fee; + const pnl = proceeds - fee - pos.entryCost; + const trade: ClosedTrade = { + pair, entryTs: pos.entryTs, entryPrice: pos.entryPrice, exitTs: ts, + exitPrice: fill, qty: pos.qty, pnl, r: pnl / pos.riskAmount, exitReason, + }; + this.trades.push(trade); + this.positions.delete(pair); + return trade; + } + + equity(lastClose: Map): number { + let eq = this.cash; + for (const pos of this.positions.values()) { + eq += pos.qty * (lastClose.get(pos.pair) ?? pos.entryPrice); + } + return eq; + } +} +``` + +- [ ] **Step 4: Ausführen** → 3 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Paper-Portfolio mit Fees, Slippage, R-Multiples"` + +--- + +### Task 10: Metriken + +**Files:** +- Create: `src/server/backtest/metrics.ts`, `src/server/backtest/metrics.test.ts` + +- [ ] **Step 1: Failing Test** + +`src/server/backtest/metrics.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { computeMetrics } from './metrics'; +import type { ClosedTrade } from '../engine/portfolio'; + +function t(pnl: number): ClosedTrade { + return { pair: 'BTC_USDT', entryTs: 0, entryPrice: 1, exitTs: 1, exitPrice: 1, qty: 1, pnl, r: pnl / 10, exitReason: 'trailing_stop' }; +} + +test('ProfitFactor, WinRate, AvgR', () => { + const m = computeMetrics([t(30), t(-10), t(-10)], [], 1000); + expect(m.trades).toBe(3); + expect(m.winRate).toBeCloseTo(1 / 3); + expect(m.profitFactor).toBeCloseTo(30 / 20); + expect(m.totalPnl).toBeCloseTo(10); + expect(m.avgR).toBeCloseTo((3 - 1 - 1) / 3); +}); + +test('MaxDrawdown aus Equity-Kurve', () => { + const curve = [1000, 1100, 880, 990, 1200].map((equity, i) => ({ ts: i, equity })); + const m = computeMetrics([], curve, 1000); + expect(m.maxDrawdownPct).toBeCloseTo((1100 - 880) / 1100); +}); + +test('keine Verlierer → ProfitFactor Infinity, keine Trades → 0', () => { + expect(computeMetrics([t(5)], [], 1000).profitFactor).toBe(Infinity); + expect(computeMetrics([], [], 1000).profitFactor).toBe(0); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/backtest/metrics.ts`: +```ts +import type { ClosedTrade } from '../engine/portfolio'; + +export interface EquityPoint { + ts: number; + equity: number; +} + +export interface Metrics { + trades: number; + wins: number; + winRate: number; + profitFactor: number; + totalPnl: number; + maxDrawdownPct: number; + avgR: number; +} + +export function computeMetrics(trades: ClosedTrade[], curve: EquityPoint[], startEquity: number): Metrics { + const wins = trades.filter((t) => t.pnl > 0); + const grossWin = wins.reduce((s, t) => s + t.pnl, 0); + const grossLoss = trades.filter((t) => t.pnl <= 0).reduce((s, t) => s - t.pnl, 0); + let peak = startEquity; + let maxDd = 0; + for (const p of curve) { + peak = Math.max(peak, p.equity); + maxDd = Math.max(maxDd, (peak - p.equity) / peak); + } + return { + trades: trades.length, + wins: wins.length, + winRate: trades.length ? wins.length / trades.length : 0, + profitFactor: grossLoss > 0 ? grossWin / grossLoss : grossWin > 0 ? Infinity : 0, + totalPnl: trades.reduce((s, t) => s + t.pnl, 0), + maxDrawdownPct: maxDd, + avgR: trades.length ? trades.reduce((s, t) => s + t.r, 0) / trades.length : 0, + }; +} +``` + +- [ ] **Step 4: Ausführen** → 3 pass. +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Backtest-Metriken (PF, WinRate, MaxDD, AvgR)"` + +--- + +### Task 11: Backtest-Runner + +**Files:** +- Create: `src/server/backtest/runner.ts`, `src/server/backtest/runner.test.ts` + +Kern-Semantik (aus der Spec): +- Entries werden **auf abgeschlossenen 4h-Candles** entschieden (Fill = 4h-Close + Slippage). +- Stop-Checks laufen auf **jeder 15m-Candle**: `low ≤ stop` → Exit zum Stop (Gap nach unten: Exit zum 15m-Open, pessimistisch). +- Trailing-Stop-Update **pro abgeschlossener 4h-Candle** (Chandelier auf 4h-ATR). +- `tradeFrom`/`tradeTo` begrenzen nur **Entries und Auswertung** — alle Candles davor sind Indikator-Warmup. +- Am Ende (oder bei `tradeTo`) offene Positionen → Exit `end_of_data` zum letzten Close. +- Determinismus: Pairs werden in fester Reihenfolge (PAIRS-Array) verarbeitet. + +- [ ] **Step 1: Failing Test** + +`src/server/backtest/runner.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import type { Candle, Pair } from '../types'; +import { runBacktest } from './runner'; +import { DEFAULT_RISK } from '../engine/sizing'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { H4 } from '../market/aggregate'; + +const M15 = 15 * 60 * 1000; +const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5 }; + +/** + * Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle + * (Close 110), dann Absturz unter den Stop. + * Jede 4h-Candle besteht aus 16 flachen 15m-Candles mit definiertem OHLC. + */ +function flat4h(bucketStart: number, o: number, h: number, l: number, cl: number): Candle[] { + const out: Candle[] = []; + for (let i = 0; i < 16; i++) { + // alle 15m-Candles tragen die 4h-Range, Close interpoliert linear o→cl + const c = o + ((cl - o) * (i + 1)) / 16; + out.push({ ts: bucketStart + i * M15, open: o, high: h, low: l, close: c, volume: 1 }); + } + return out; +} + +function series(): Candle[] { + const s: Candle[] = []; + let b = 0; + // 7 Plateau-Buckets: Closes 100, Highs 101 → Donchian-High 101, EMA ~100 + for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 101, 99, 100)); + // Breakout-Bucket: Close 110 > 101 (Donchian) und > EMA5 + s.push(...flat4h(b, 100, 111, 100, 110)); b += H4; + // Crash-Bucket: Low 80 reißt jeden Stop + s.push(...flat4h(b, 110, 110, 80, 85)); b += H4; + // Abschluss-Bucket, damit der Crash-Bucket als abgeschlossen gilt + s.push(...flat4h(b, 85, 86, 84, 85)); b += H4; + return s; +} + +test('Breakout → Entry auf 4h-Close, Crash → Stop-Exit auf 15m', () => { + const data = new Map([['BTC_USDT', series()]]); + const result = runBacktest(data, { + startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, + params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, + }); + expect(result.trades).toHaveLength(1); + const t = result.trades[0]; + expect(t.entryPrice).toBeCloseTo(110 * 1.0005); // 4h-Close + Slippage + expect(t.exitReason).toBe('trailing_stop'); + expect(t.pnl).toBeLessThan(0); + // Verlust ≈ 1R (Stop = Entry − 1×ATR), Fees machen ihn etwas größer + expect(t.r).toBeLessThan(-0.8); + expect(t.r).toBeGreaterThan(-1.6); +}); + +test('tradeFrom verhindert Entries im Warmup-Fenster', () => { + const data = new Map([['BTC_USDT', series()]]); + const result = runBacktest(data, { + startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, + params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, // nach Serien-Ende + }); + expect(result.trades).toHaveLength(0); +}); + +test('Determinismus: identischer Input → identisches Ergebnis', () => { + const data = new Map([['BTC_USDT', series()]]); + const cfg = { + startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, + params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, + }; + expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg))); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/backtest/runner.ts`: +```ts +import type { Candle, Pair } from '../types'; +import { PAIRS } from '../types'; +import { aggregate4h, H4 } from '../market/aggregate'; +import { computeIndicators, evaluateAt, type StrategyParams, type IndicatorSet } from '../strategy/donchian-trend'; +import { updateChandelier } from '../strategy/chandelier'; +import { sizePosition, type RiskConfig } from '../engine/sizing'; +import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio'; +import type { EquityPoint } from './metrics'; + +export interface BacktestConfig { + startCapital: number; + risk: RiskConfig; + exec: ExecConfig; + maxPositions: number; + params: StrategyParams; + tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup + tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt +} + +export interface BacktestResult { + trades: ClosedTrade[]; + equityCurve: EquityPoint[]; + finalEquity: number; +} + +interface PairContext { + pair: Pair; + c15: Candle[]; + c4h: Candle[]; + ind: IndicatorSet; + next4h: number; // Index der nächsten noch nicht verarbeiteten 4h-Candle +} + +export function runBacktest(candles15ByPair: Map, cfg: BacktestConfig): BacktestResult { + const portfolio = new Portfolio(cfg.startCapital, cfg.exec); + const lastClose = new Map(); + const equityCurve: EquityPoint[] = []; + + const contexts: PairContext[] = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => { + const c15 = candles15ByPair.get(pair)!; + const c4h = aggregate4h(c15); + return { pair, c15, c4h, ind: computeIndicators(c4h, cfg.params), next4h: 0 }; + }); + + // Gemergte 15m-Timeline (Pair-Reihenfolge stabil → deterministisch) + const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; + for (const ctx of contexts) { + for (const candle of ctx.c15) { + 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)); + + const byPair = new Map(contexts.map((c) => [c.pair, c])); + 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-Candles dieses Pairs verarbeiten (alles < aktueller Bucket) + while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { + const i = ctx.next4h++; + const bar = ctx.c4h[i]; + + // 1a) Trailing-Stop der offenen Position nachziehen + const pos = portfolio.positions.get(pair); + if (pos) { + const next = updateChandelier( + { highestHigh: pos.highestHigh, stop: pos.stop }, + bar.high, + ctx.ind.atr[i], + cfg.params.atrMultiplier, + ); + pos.highestHigh = next.highestHigh; + pos.stop = next.stop; + } + + // 1b) Entry-Evaluation + const barCloseTs = bar.ts + H4; + if ( + !portfolio.positions.has(pair) && + portfolio.positions.size < cfg.maxPositions && + barCloseTs >= cfg.tradeFrom && + barCloseTs < cfg.tradeTo + ) { + const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params); + if (ev.signal === 'long') { + const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; + const equity = portfolio.equity(lastClose); + const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk); + if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount); + } + } + } + + // 2) Stop-Check auf der 15m-Candle + const pos = portfolio.positions.get(pair); + if (pos && candle.low <= pos.stop) { + const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill + portfolio.close(pair, ts, exitPrice, 'trailing_stop'); + } + + 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: portfolio.equity(lastClose) }); + } + } + + // Offene Positionen glattstellen + for (const pair of [...portfolio.positions.keys()]) { + portfolio.close(pair, cfg.tradeTo, lastClose.get(pair)!, 'end_of_data'); + } + + return { + trades: portfolio.trades, + equityCurve, + finalEquity: portfolio.equity(lastClose), + }; +} +``` + +- [ ] **Step 4: Ausführen** — `~/.bun/bin/bun test runner` → 3 pass. Anschließend `~/.bun/bin/bun test` → alle bisherigen Tests grün. + +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Backtest-Runner (4h-Entries, 15m-Stop-Checks, deterministisch)"` + +--- + +### Task 12: Walk-Forward-Runner + Deploy-Gate + +**Files:** +- Create: `src/server/backtest/walkforward.ts`, `src/server/backtest/walkforward.test.ts` + +Semantik (Spec §5): +- Fenster: Train 120 Tage → Test 30 Tage, Schritt 30 Tage, solange Test komplett in den Daten liegt. +- Grid (18 Kombos): Donchian 20/40/55 × ATR-Mult 2/3/4 × Trend-EMA 100/200. +- Auswahl auf Train: höchster PF unter Kombos mit ≥ 5 Trades (Tie-Break: höherer TotalPnl); haben alle < 5 Trades → meiste Trades. +- OOS-Aggregat: alle Test-Trades kombiniert; OOS-Equity-Kurve = Fenster-Kurven multiplikativ verkettet. +- Gate: OOS-PF ≥ 1.2 · OOS-Trades ≥ 25 · OOS-MaxDD ≤ 25 % · kein Fenster mit PF < 0.5 bei ≥ 5 Trades · (Ø Train-PF der gewählten Kombos) ÷ OOS-PF < 2. + +- [ ] **Step 1: Failing Test** + +`src/server/backtest/walkforward.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { buildWindows, evaluateGate, PARAM_GRID } from './walkforward'; + +const DAY = 24 * 60 * 60 * 1000; + +test('Fensterung: Train 120d / Test 30d / Schritt 30d, kein Leak', () => { + const windows = buildWindows(0, 365 * DAY, 120, 30, 30); + expect(windows.length).toBe(8); // Tests bei Tag 120..150, 150..180, … 330..360 + for (const w of windows) { + expect(w.testFrom).toBe(w.trainTo); // Test beginnt exakt nach Train + expect(w.trainTo - w.trainFrom).toBe(120 * DAY); + expect(w.testTo - w.testFrom).toBe(30 * DAY); + expect(w.testTo).toBeLessThanOrEqual(365 * DAY); + } + expect(windows[1].trainFrom - windows[0].trainFrom).toBe(30 * DAY); +}); + +test('Grid hat 18 Kombinationen', () => { + expect(PARAM_GRID).toHaveLength(18); +}); + +test('Gate: alle Kriterien müssen bestehen', () => { + const good = { + oosProfitFactor: 1.5, oosTrades: 30, oosMaxDrawdownPct: 0.15, + worstWindow: { profitFactor: 0.9, trades: 6 }, avgTrainPf: 2.0, + }; + expect(evaluateGate(good).pass).toBe(true); + + expect(evaluateGate({ ...good, oosProfitFactor: 1.1 }).pass).toBe(false); + expect(evaluateGate({ ...good, oosTrades: 20 }).pass).toBe(false); + expect(evaluateGate({ ...good, oosMaxDrawdownPct: 0.3 }).pass).toBe(false); + expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.4, trades: 6 } }).pass).toBe(false); + expect(evaluateGate({ ...good, avgTrainPf: 3.1 }).pass).toBe(false); + // PF<0.5 zählt nur bei ≥5 Trades im Fenster + expect(evaluateGate({ ...good, worstWindow: { profitFactor: 0.2, trades: 3 } }).pass).toBe(true); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/backtest/walkforward.ts`: +```ts +import type { Candle, Pair } from '../types'; +import { runBacktest, type BacktestConfig } from './runner'; +import { computeMetrics, type Metrics, type EquityPoint } from './metrics'; +import type { StrategyParams } from '../strategy/donchian-trend'; +import type { ClosedTrade } from '../engine/portfolio'; + +const DAY = 24 * 60 * 60 * 1000; + +export interface Window { + trainFrom: number; + trainTo: number; + testFrom: number; + testTo: number; +} + +export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120, testDays = 30, stepDays = 30): Window[] { + const out: Window[] = []; + for (let start = dataFrom; start + (trainDays + testDays) * DAY <= dataTo; start += stepDays * DAY) { + const trainTo = start + trainDays * DAY; + out.push({ trainFrom: start, trainTo, testFrom: trainTo, testTo: trainTo + testDays * DAY }); + } + return out; +} + +export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) => + [2, 3, 4].flatMap((atrMultiplier) => + [100, 200].map((trendEmaPeriod) => ({ donchianPeriod, atrPeriod: 14, atrMultiplier, trendEmaPeriod })), + ), +); + +export interface WindowResult { + window: Window; + bestParams: StrategyParams; + trainMetrics: Metrics; + testMetrics: Metrics; + testTrades: ClosedTrade[]; + testEquityCurve: EquityPoint[]; +} + +export interface GateInput { + oosProfitFactor: number; + oosTrades: number; + oosMaxDrawdownPct: number; + worstWindow: { profitFactor: number; trades: number }; + avgTrainPf: number; +} + +export interface GateCheck { + name: string; + pass: boolean; + value: number; + threshold: number; +} + +export interface GateResult { + pass: boolean; + checks: GateCheck[]; +} + +export function evaluateGate(g: GateInput): GateResult { + const overfitRatio = g.oosProfitFactor > 0 ? g.avgTrainPf / g.oosProfitFactor : Infinity; + const windowFail = g.worstWindow.trades >= 5 && g.worstWindow.profitFactor < 0.5; + const checks: GateCheck[] = [ + { name: 'OOS-ProfitFactor >= 1.2', pass: g.oosProfitFactor >= 1.2, value: g.oosProfitFactor, threshold: 1.2 }, + { name: 'OOS-Trades >= 25', pass: g.oosTrades >= 25, value: g.oosTrades, threshold: 25 }, + { name: 'OOS-MaxDrawdown <= 25%', pass: g.oosMaxDrawdownPct <= 0.25, value: g.oosMaxDrawdownPct, threshold: 0.25 }, + { name: 'kein Fenster PF < 0.5 (bei >= 5 Trades)', pass: !windowFail, value: g.worstWindow.profitFactor, threshold: 0.5 }, + { name: 'Train-PF / OOS-PF < 2 (Overfitting)', pass: overfitRatio < 2, value: overfitRatio, threshold: 2 }, + ]; + return { pass: checks.every((c) => c.pass), checks }; +} + +export interface WalkForwardResult { + windows: WindowResult[]; + oosMetrics: Metrics; + oosEquityCurve: EquityPoint[]; + gate: GateResult; +} + +type BaseConfig = Omit; + +/** PF-Vergleich mit Infinity-Handling: Infinity schlägt alles, Tie-Break TotalPnl. */ +function better(a: Metrics, b: Metrics): boolean { + if (a.profitFactor !== b.profitFactor) return a.profitFactor > b.profitFactor; + return a.totalPnl > b.totalPnl; +} + +export function runWalkForward( + candles15ByPair: Map, + baseCfg: BaseConfig, + dataFrom: number, + dataTo: number, + onProgress?: (msg: string) => void, +): WalkForwardResult { + const windows = buildWindows(dataFrom, dataTo); + const results: WindowResult[] = []; + + for (const [wi, w] of windows.entries()) { + let bestParams = PARAM_GRID[0]; + let bestMetrics: Metrics | null = null; + let bestEligible = false; + + for (const params of PARAM_GRID) { + const r = runBacktest(candles15ByPair, { ...baseCfg, params, tradeFrom: w.trainFrom, tradeTo: w.trainTo }); + const m = computeMetrics(r.trades, r.equityCurve, baseCfg.startCapital); + const eligible = m.trades >= 5; + const wins = + bestMetrics === null || + (eligible && !bestEligible) || + (eligible === bestEligible && (eligible ? better(m, bestMetrics) : m.trades > bestMetrics.trades)); + if (wins) { + bestParams = params; + bestMetrics = m; + bestEligible = eligible; + } + } + + const test = runBacktest(candles15ByPair, { ...baseCfg, params: bestParams, tradeFrom: w.testFrom, tradeTo: w.testTo }); + const testMetrics = computeMetrics(test.trades, test.equityCurve, baseCfg.startCapital); + results.push({ + window: w, bestParams, trainMetrics: bestMetrics!, testMetrics, + testTrades: test.trades, testEquityCurve: test.equityCurve, + }); + onProgress?.( + `Fenster ${wi + 1}/${windows.length}: Train-PF ${bestMetrics!.profitFactor.toFixed(2)} ` + + `(${JSON.stringify(bestParams)}) → Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`, + ); + } + + // OOS-Aggregat: Trades kombiniert, Equity-Kurven multiplikativ verkettet + const oosTrades = results.flatMap((r) => r.testTrades); + const oosEquityCurve: EquityPoint[] = []; + let scale = 1; + for (const r of results) { + for (const p of r.testEquityCurve) { + oosEquityCurve.push({ ts: p.ts, equity: baseCfg.startCapital * scale * (p.equity / baseCfg.startCapital) }); + } + const last = r.testEquityCurve.at(-1); + if (last) scale *= last.equity / baseCfg.startCapital; + } + const oosMetrics = computeMetrics(oosTrades, oosEquityCurve, baseCfg.startCapital); + + const windowsWithTrades = results.filter((r) => r.testMetrics.trades > 0); + const worst = windowsWithTrades.reduce( + (acc, r) => (r.testMetrics.profitFactor < acc.profitFactor ? { profitFactor: r.testMetrics.profitFactor, trades: r.testMetrics.trades } : acc), + { profitFactor: Infinity, trades: 0 }, + ); + const finiteTrainPfs = results.map((r) => Math.min(r.trainMetrics.profitFactor, 10)); // Infinity kappen + const avgTrainPf = finiteTrainPfs.reduce((s, v) => s + v, 0) / Math.max(1, finiteTrainPfs.length); + + const gate = evaluateGate({ + oosProfitFactor: oosMetrics.profitFactor, + oosTrades: oosMetrics.trades, + oosMaxDrawdownPct: oosMetrics.maxDrawdownPct, + worstWindow: worst, + avgTrainPf, + }); + + return { windows: results, oosMetrics, oosEquityCurve, gate }; +} +``` + +- [ ] **Step 4: Ausführen** — `~/.bun/bin/bun test walkforward` → 3 pass; `~/.bun/bin/bun test` → alles grün. + +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Walk-Forward-Runner mit Grid-Search und Deploy-Gate"` + +--- + +### Task 13: DB-Schema, Migration, CandleStore + +**Files:** +- Create: `drizzle.config.ts`, `src/server/config.ts`, `src/server/db/schema.ts`, `src/server/db/client.ts`, `src/server/db/migrate.ts`, `src/server/market/candle-store.ts` + +Hinweis: Kein Unit-Test gegen die echte DB — die Verifikation passiert in Task 15 (Backfill + Coverage-Check). Die reine SQL-Schicht ist bewusst dünn. + +- [ ] **Step 1: Dateien anlegen** + +`src/server/config.ts`: +```ts +import { z } from 'zod'; + +const Env = z.object({ + DATABASE_URL: z.string().url(), +}); + +export const env = Env.parse(process.env); +``` + +`src/server/db/schema.ts`: +```ts +import { doublePrecision, jsonb, pgTable, primaryKey, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; + +export const candles = pgTable( + 'candles', + { + pair: varchar('pair', { length: 16 }).notNull(), + ts: timestamp('ts', { withTimezone: true }).notNull(), + open: doublePrecision('open').notNull(), + high: doublePrecision('high').notNull(), + low: doublePrecision('low').notNull(), + close: doublePrecision('close').notNull(), + volume: doublePrecision('volume').notNull(), + }, + (t) => [primaryKey({ columns: [t.pair, t.ts] })], +); + +export const backtestRuns = pgTable('backtest_runs', { + id: serial('id').primaryKey(), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + kind: text('kind').notNull(), // 'single' | 'walkforward' + config: jsonb('config').notNull(), + result: jsonb('result').notNull(), +}); +``` + +`drizzle.config.ts`: +```ts +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + dialect: 'postgresql', + schema: './src/server/db/schema.ts', + out: './drizzle', + dbCredentials: { url: process.env.DATABASE_URL! }, +}); +``` + +`src/server/db/client.ts`: +```ts +import { drizzle } from 'drizzle-orm/postgres-js'; +import postgres from 'postgres'; +import { env } from '../config'; +import * as schema from './schema'; + +export const sql = postgres(env.DATABASE_URL, { max: 5 }); +export const db = drizzle(sql, { schema }); +``` + +`src/server/db/migrate.ts`: +```ts +import { migrate } from 'drizzle-orm/postgres-js/migrator'; +import { db, sql } from './client'; + +await migrate(db, { migrationsFolder: './drizzle' }); +console.log('Migrations angewendet.'); +await sql.end(); +``` + +`src/server/market/candle-store.ts`: +```ts +import { and, asc, eq, gte, lt, max, min, count } from 'drizzle-orm'; +import { db } from '../db/client'; +import { candles } from '../db/schema'; +import type { Candle, Pair } from '../types'; + +export async function insertCandles(pair: Pair, items: Candle[]): Promise { + for (let i = 0; i < items.length; i += 1000) { + const chunk = items.slice(i, i + 1000).map((c) => ({ + pair, + ts: new Date(c.ts), + open: c.open, + high: c.high, + low: c.low, + close: c.close, + volume: c.volume, + })); + await db.insert(candles).values(chunk).onConflictDoNothing(); + } +} + +export async function getCandles(pair: Pair, from?: number, to?: number): Promise { + const conds = [eq(candles.pair, pair)]; + if (from !== undefined) conds.push(gte(candles.ts, new Date(from))); + if (to !== undefined) conds.push(lt(candles.ts, new Date(to))); + const rows = await db.select().from(candles).where(and(...conds)).orderBy(asc(candles.ts)); + return rows.map((r) => ({ ts: r.ts.getTime(), open: r.open, high: r.high, low: r.low, close: r.close, volume: r.volume })); +} + +export async function getCoverage(pair: Pair): Promise<{ from: Date | null; to: Date | null; count: number }> { + const [row] = await db + .select({ from: min(candles.ts), to: max(candles.ts), count: count() }) + .from(candles) + .where(eq(candles.pair, pair)); + return { from: row.from, to: row.to, count: Number(row.count) }; +} +``` + +- [ ] **Step 2: Typecheck + bestehende Tests** + +```bash +~/.bun/bin/bunx tsc --noEmit && ~/.bun/bin/bun test +``` +Erwartung: keine Typfehler, alle Tests grün. (`config.ts` wird von keinem Test importiert — Env-Parse läuft nur, wenn DB-Code geladen wird.) + +- [ ] **Step 3: Migration generieren** + +```bash +DATABASE_URL=postgres://x:x@localhost/x ~/.bun/bin/bunx drizzle-kit generate +``` +Erwartung: SQL-Datei unter `drizzle/0000_*.sql` mit `CREATE TABLE candles` + `backtest_runs`. (drizzle-kit generate braucht keine DB-Verbindung, nur die Env-Variable für die Config.) + +- [ ] **Step 4: Commit** — `git add -A && git commit -m "feat: DB-Schema (candles, backtest_runs), Migration, CandleStore"` + +--- + +### Task 14: Crypto.com-Client + Backfill-Script + +**Files:** +- Create: `src/server/market/cryptocom.ts`, `src/server/market/cryptocom.test.ts`, `src/server/scripts/backfill.ts` + +**Wichtig — Timestamp-Konvention prüfen:** Die API liefert pro Candle `t` (Unix ms). Das Script verifiziert beim Start, dass `t % 900000 === 0` (15-min-Raster) und behandelt `t` als **Start** der Candle. Die noch laufende aktuelle Candle (`t + 15min > now`) wird verworfen. Sollte `t` sich live als End-Timestamp herausstellen (erkennbar daran, dass die jüngste abgeschlossene Candle „in der Zukunft" liegt), wird beim Ingest 15 min subtrahiert — diese Entscheidung wird in Task 15 anhand echter Daten verifiziert und dokumentiert. + +- [ ] **Step 1: Failing Test (Response-Parsing, ohne Netz)** + +`src/server/market/cryptocom.test.ts`: +```ts +import { expect, test } from 'bun:test'; +import { parseCandlestickResponse } from './cryptocom'; + +test('parst Crypto.com-Candlestick-Response', () => { + const json = { + result: { + data: [ + { t: 900000, o: '1.0', h: '1.2', l: '0.9', c: '1.1', v: '1000' }, + { t: 1800000, o: '1.1', h: '1.3', l: '1.0', c: '1.2', v: '500' }, + ], + }, + }; + const out = parseCandlestickResponse(json); + expect(out).toHaveLength(2); + expect(out[0]).toEqual({ ts: 900000, open: 1, high: 1.2, low: 0.9, close: 1.1, volume: 1000 }); +}); + +test('leere/fehlende Daten → leeres Array', () => { + expect(parseCandlestickResponse({})).toEqual([]); + expect(parseCandlestickResponse({ result: {} })).toEqual([]); +}); +``` + +- [ ] **Step 2: Ausführen** → FAIL. + +- [ ] **Step 3: Implementieren** + +`src/server/market/cryptocom.ts`: +```ts +import type { Candle, Pair } from '../types'; + +const BASE = 'https://api.crypto.com/exchange/v1'; + +export function parseCandlestickResponse(json: any): Candle[] { + const data = json?.result?.data; + if (!Array.isArray(data)) return []; + return data.map((d: any) => ({ + ts: Number(d.t), + open: Number(d.o), + high: Number(d.h), + low: Number(d.l), + close: Number(d.c), + volume: Number(d.v), + })); +} + +export async function fetchCandles(pair: Pair, timeframe: string, count: number, endTs?: number): Promise { + const url = new URL(`${BASE}/public/get-candlestick`); + url.searchParams.set('instrument_name', pair); + url.searchParams.set('timeframe', timeframe); + url.searchParams.set('count', String(count)); + if (endTs !== undefined) url.searchParams.set('end_ts', String(endTs)); + + for (let attempt = 1; ; attempt++) { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status} für ${pair}`); + return parseCandlestickResponse(await res.json()); + } catch (err) { + if (attempt >= 3) throw err; + await Bun.sleep(1000 * 2 ** (attempt - 1)); + } + } +} +``` + +`src/server/scripts/backfill.ts`: +```ts +import { PAIRS } from '../types'; +import { fetchCandles } from '../market/cryptocom'; +import { insertCandles, getCoverage } from '../market/candle-store'; +import { sql } from '../db/client'; + +const M15 = 15 * 60 * 1000; +const TARGET_MONTHS = 14; +const since = Date.now() - TARGET_MONTHS * 30 * 24 * 60 * 60 * 1000; + +for (const pair of PAIRS) { + let endTs: number | undefined = undefined; + let total = 0; + for (;;) { + const batch = await fetchCandles(pair, '15m', 300, endTs); + if (batch.length === 0) break; + + // Sanity: 15-min-Raster + for (const c of batch) { + if (c.ts % M15 !== 0) throw new Error(`${pair}: Timestamp ${c.ts} nicht im 15m-Raster — Konvention prüfen!`); + } + // Noch laufende Candle verwerfen + const closed = batch.filter((c) => c.ts + M15 <= Date.now()); + await insertCandles(pair, closed); + total += closed.length; + + const oldest = Math.min(...batch.map((c) => c.ts)); + if (oldest <= since) break; + endTs = oldest - 1; + await Bun.sleep(200); // Rate-Limit-Schonung + } + const cov = await getCoverage(pair); + console.log(`${pair}: +${total} Candles, Coverage ${cov.from?.toISOString()} → ${cov.to?.toISOString()} (${cov.count})`); +} +await sql.end(); +``` + +- [ ] **Step 4: Tests + Typecheck** — `~/.bun/bin/bun test cryptocom && ~/.bun/bin/bunx tsc --noEmit` → grün. + +- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat: Crypto.com-Client und Backfill-Script"` + +--- + +### Task 15: Walk-Forward-CLI + +**Files:** +- Create: `src/server/scripts/walkforward.ts` + +- [ ] **Step 1: Script schreiben** + +`src/server/scripts/walkforward.ts`: +```ts +import { PAIRS, type Candle, type Pair } from '../types'; +import { getCandles, getCoverage } from '../market/candle-store'; +import { runWalkForward } from '../backtest/walkforward'; +import { DEFAULT_RISK } from '../engine/sizing'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { db, sql } from '../db/client'; +import { backtestRuns } from '../db/schema'; + +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()); // gemeinsamer Zeitraum aller Pairs + dataTo = Math.min(dataTo, cov.to.getTime()); + console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()} → ${cov.to.toISOString()})`); +} + +const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 }; +console.log(`\nWalk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`); + +const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg)); + +console.log('\n========== OOS-GESAMTERGEBNIS =========='); +const m = result.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 result.gate.checks) { + console.log(`${c.pass ? '✅' : '❌'} ${c.name}: ${Number.isFinite(c.value) ? c.value.toFixed(2) : c.value}`); +} +console.log(`\n→ GATE ${result.gate.pass ? 'BESTANDEN' : 'NICHT BESTANDEN'}`); + +// Persistenz (Equity-Kurven kompakt halten: nur Fenster-Metriken + OOS-Kurve) +await db.insert(backtestRuns).values({ + kind: 'walkforward', + config: baseCfg as any, + result: { + gate: result.gate, + oosMetrics: result.oosMetrics, + oosEquityCurve: result.oosEquityCurve, + windows: result.windows.map((w) => ({ + window: w.window, bestParams: w.bestParams, trainMetrics: w.trainMetrics, testMetrics: w.testMetrics, + })), + } as any, +}); +console.log('Run in backtest_runs gespeichert.'); +await sql.end(); +``` + +- [ ] **Step 2: Typecheck + alle Tests** — `~/.bun/bin/bunx tsc --noEmit && ~/.bun/bin/bun test` → grün. + +- [ ] **Step 3: Commit** — `git add -A && git commit -m "feat: Walk-Forward-CLI mit Gate-Report und Run-Persistenz"` + +--- + +### Task 16: Echtlauf — DB anlegen, Backfill, Gate-Entscheidung + +**Files:** +- Create: `.env` (NICHT committen — steht in .gitignore) + +- [ ] **Step 1: Datenbank anlegen** + +```bash +docker exec l8kogcggsc80sgcgk8kswww4 psql -U mika -d main -c "CREATE DATABASE tradekuns OWNER mika;" +``` +Erwartung: `CREATE DATABASE`. (Falls „already exists": ok, weiter.) + +- [ ] **Step 2: .env schreiben** + +```bash +source ~/.secrets/coolify-tokens.env +echo "DATABASE_URL=postgres://mika:${SHARED_POSTGRES_PASSWORD}@localhost:54320/tradekuns" > ~/trade-kuns/.env +``` +(Host-Port 54320 laut `~/shared-postgres/README.md`.) + +- [ ] **Step 3: Migration anwenden** + +```bash +cd ~/trade-kuns && ~/.bun/bin/bun run db:migrate +``` +Erwartung: „Migrations angewendet." Prüfen: `docker exec l8kogcggsc80sgcgk8kswww4 psql -U mika -d tradekuns -c '\dt'` zeigt `candles` und `backtest_runs`. + +- [ ] **Step 4: Backfill ausführen** + +```bash +cd ~/trade-kuns && ~/.bun/bin/bun run backfill +``` +Erwartung: pro Pair eine Coverage-Zeile, Ziel ≥ 12 Monate. **Verifizieren:** jüngste Candle ≤ 15 min alt? Timestamps im 15m-Raster (sonst bricht das Script mit Fehlermeldung ab → dann `t`-Konvention gemäß Task-14-Hinweis korrigieren)? Wie weit reicht die Historie je Pair zurück? Befund notieren. + +- [ ] **Step 5: Stichproben-Validierung der Daten** + +```bash +docker exec l8kogcggsc80sgcgk8kswww4 psql -U mika -d tradekuns -c \ + "SELECT pair, count(*), min(ts), max(ts), + count(*) FILTER (WHERE high < low) AS broken + FROM candles GROUP BY pair ORDER BY pair;" +``` +Erwartung: `broken = 0` überall; Lückenquote grob prüfen (count ≈ Tage × 96). + +- [ ] **Step 6: Walk-Forward laufen lassen** + +```bash +cd ~/trade-kuns && ~/.bun/bin/bun run walkforward 2>&1 | tee /tmp/walkforward-run1.log +``` +Erwartung: Fenster-Fortschritt, OOS-Report, Gate-Ergebnis, „Run gespeichert". + +- [ ] **Step 7: Ergebnis committen und berichten** + +```bash +git add -A && git commit -m "feat: Phase 1+2 komplett — Walk-Forward-Lauf auf echten Daten" +``` +**Dem User berichten:** Gate bestanden oder nicht, mit den konkreten Zahlen (OOS-PF, Trades, MaxDD, Fenster-Tabelle). Bei NICHT bestanden: welche Checks scheiterten und erste Hypothesen — **keine** eigenmächtigen Regel-Änderungen, das ist laut Spec eine bewusste Entscheidung mit dem User. + +--- + +## Verifikation gegen die Spec (Self-Review) + +- §2 Strategie-Regeln → Tasks 6 (Entry), 7 (Chandelier), 11 (Ausführungssemantik) ✓ +- §3 Risiko/Sizing/Fees/Slippage → Tasks 8, 9 ✓ +- §4.2/4.4 Module + DB (candles, backtest_runs) → Tasks 13 ✓ (übrige Tabellen sind Phase 3) +- §5 Backfill, Walk-Forward, Gate → Tasks 12, 14, 15, 16 ✓ +- §7 Tests (Indikatoren, Chandelier-monoton, Sizing, pessimistische Stops, Determinismus, Leak-frei) → in Tasks 3–12 enthalten ✓ +- Nicht in diesem Plan (bewusst): Live-Engine, API, Dashboard, Deploy — eigener Plan nach Gate-Entscheidung.