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