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

58 KiB
Raw Blame History

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:

{
  "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:

{
  "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:

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):

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
cd ~/trade-kuns && ~/.bun/bin/bun install && ~/.bun/bin/bun test

Erwartung: 1 pass.

  • Step 3: Commit
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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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: Commitgit 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:

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:

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:

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:

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:

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:

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
~/.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
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: Commitgit 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:

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:

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:

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: Commitgit 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:

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: Commitgit 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

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