# 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.