58 KiB
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.tsneben 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: 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:
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: 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:
import { expect, test } from 'bun:test';
import type { Candle } from '../types';
import { atr } from './atr';
function c(h: number, l: number, cl: number): Candle {
return { ts: 0, open: cl, high: h, low: l, close: cl, volume: 1 };
}
test('ATR: konstante Range ohne Gaps → ATR = Range', () => {
// High−Low = 2 überall, Close mittig → TR = 2 für alle Bars
const candles = Array.from({ length: 20 }, () => c(11, 9, 10));
const out = atr(candles, 14);
expect(out.slice(0, 13).every(Number.isNaN)).toBe(true);
expect(out[13]).toBeCloseTo(2);
expect(out[19]).toBeCloseTo(2);
});
test('ATR: Gap wird über True Range erfasst', () => {
// 14 ruhige Bars (TR=2), dann Gap: prevClose=10, neue Bar h=21 l=20 → TR = max(1, 11, 10) = 11
const candles = Array.from({ length: 14 }, () => c(11, 9, 10));
candles.push(c(21, 20, 20.5));
const out = atr(candles, 14);
// Wilder: (2*13 + 11) / 14
expect(out[14]).toBeCloseTo((2 * 13 + 11) / 14);
});
-
Step 2: Ausführen → FAIL.
-
Step 3: Implementieren
src/server/indicators/atr.ts:
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:
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: 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:
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: 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:
import { expect, test } from 'bun:test';
import { updateChandelier } from './chandelier';
test('Stop steigt mit neuem Hoch', () => {
// hh 100 → 110, ATR 2, Mult 3 → Stop 110−6 = 104
const r = updateChandelier({ highestHigh: 100, stop: 94 }, 110, 2, 3);
expect(r.highestHigh).toBe(110);
expect(r.stop).toBe(104);
});
test('Stop fällt NIE — auch wenn ATR explodiert', () => {
// hh bleibt 110, ATR springt auf 10 → Kandidat 80, aber Stop bleibt 104
const r = updateChandelier({ highestHigh: 110, stop: 104 }, 105, 10, 3);
expect(r.highestHigh).toBe(110);
expect(r.stop).toBe(104);
});
test('NaN-ATR lässt Stop unverändert', () => {
const r = updateChandelier({ highestHigh: 110, stop: 104 }, 120, NaN, 3);
expect(r.highestHigh).toBe(120);
expect(r.stop).toBe(104);
});
-
Step 2: Ausführen → FAIL.
-
Step 3: Implementieren
src/server/strategy/chandelier.ts:
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:
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: 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:
import { expect, test } from 'bun:test';
import { Portfolio, DEFAULT_EXEC } from './portfolio';
test('Entry: Slippage verteuert Fill, Fee reduziert Cash', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 1, 10); // ts, signalPrice, initialStop, qty, riskAmount
const pos = p.positions.get('BTC_USDT')!;
expect(pos.entryPrice).toBeCloseTo(100.05); // 100 * (1 + 0.0005)
// Cash: 1000 − 100.05 − 0.10005 (Fee 0.1%)
expect(p.cash).toBeCloseTo(1000 - 100.05 - 0.10005);
});
test('Exit: PnL netto inkl. beider Fees und Slippage, R-Multiple korrekt', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 1, 10);
const trade = p.close('BTC_USDT', 1, 110, 'trailing_stop');
// Exit-Fill: 110 * (1−0.0005) = 109.945; Proceeds−Fee = 109.945 * 0.999
const cost = 100.05 + 0.10005;
const net = 109.945 * 0.999 - cost;
expect(trade.pnl).toBeCloseTo(net);
expect(trade.r).toBeCloseTo(net / 10);
expect(p.positions.size).toBe(0);
});
test('Equity = Cash + Marktwert offener Positionen', () => {
const p = new Portfolio(1000, DEFAULT_EXEC);
p.open('BTC_USDT', 0, 100, 94, 2, 10);
expect(p.equity(new Map([['BTC_USDT', 105]]))).toBeCloseTo(p.cash + 2 * 105);
});
-
Step 2: Ausführen → FAIL.
-
Step 3: Implementieren
src/server/engine/portfolio.ts:
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:
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: 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/tradeTobegrenzen nur Entries und Auswertung — alle Candles davor sind Indikator-Warmup. -
Am Ende (oder bei
tradeTo) offene Positionen → Exitend_of_datazum 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: 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:
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: 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:
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: 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:
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: 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:
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
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 3–12 enthalten ✓
- Nicht in diesem Plan (bewusst): Live-Engine, API, Dashboard, Deploy — eigener Plan nach Gate-Entscheidung.