Files
trade-kuns/docs/plans/2026-06-09-phase1-backtest-gate.md

1736 lines
58 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<number>(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', () => {
// HighLow = 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<number>(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<number>(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 1106 = 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 * (10.0005) = 109.945; ProceedsFee = 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<Pair, Position>();
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<Pair, number>): 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<Pair, Candle[]>([['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<Pair, Candle[]>([['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<Pair, Candle[]>([['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<Pair, Candle[]>, cfg: BacktestConfig): BacktestResult {
const portfolio = new Portfolio(cfg.startCapital, cfg.exec);
const lastClose = new Map<Pair, number>();
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<Pair, PairContext>(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<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>;
/** 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<Pair, Candle[]>,
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<void> {
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<Candle[]> {
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<Candle[]> {
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<Pair, Candle[]>();
let dataFrom = 0;
let dataTo = Number.MAX_SAFE_INTEGER;
for (const pair of PAIRS) {
const cov = await getCoverage(pair);
if (!cov.from || !cov.to) throw new Error(`Keine Candles für ${pair} — erst 'bun run backfill' ausführen.`);
candles15ByPair.set(pair, await getCandles(pair));
dataFrom = Math.max(dataFrom, cov.from.getTime()); // 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 312 enthalten ✓
- Nicht in diesem Plan (bewusst): Live-Engine, API, Dashboard, Deploy — eigener Plan nach Gate-Entscheidung.