Compare commits

..

10 Commits

Author SHA1 Message Date
29846e82a7 feat: Live-Paper-Engine — 5-min-Loop, API, Dashboard, Dockerfile
processCycle spiegelt Runner-Semantik exakt (Paritätstest gegen runBacktest),
Restart-Recovery über Cursor, DecisionLog mit Outcome-Backfill,
Bun.serve-API + statisches Dashboard, Deploy-Ziel trading.kuns.dev.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:44 +00:00
c5d71bba74 feat: Schema für Live-Engine (positions, paper_trades, decision_logs, bot_state, equity_snapshots)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:44 +00:00
69a0a7bee3 docs: Design Live-Paper-Engine (Phase 3, Paper-Probelauf der besten Variante)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 06:11:44 +00:00
cd6553c170 docs: Walk-Forward-Ergebnisse aller 7 Varianten (Gate nicht bestanden)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:11:25 +00:00
29000a2bba feat: Rotation-Walk-Forward-CLI (gemeinsame OOS-Aggregation extrahiert)
aggregateOos() aus runWalkForward herausgezogen und exportiert; beide
Walk-Forward-Varianten (Donchian + Rotation) nutzen dieselbe OOS-Logik.
Neues Script rotation-walkforward.ts mit identischem Report-Format und
Persistenz in backtest_runs (kind='rotation-walkforward').
package.json: "rotation"-Script ergänzt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:09:22 +00:00
b7e81374f1 feat: Momentum-Rotation-Backtest (dual momentum, weekly, fixe Parameter)
Neues Modul rotation.ts: wöchentliche Rotation über BTC/ETH/SOL/XRP_USDT
auf 4h-Basis, Long-Only, top-1 Pair mit positivem 30-Tage-Momentum.
4 TDD-Tests (Leader-Selektion, Flip/Rotation, Cash-Modus, Determinismus).
ClosedTrade.exitReason um 'rotation' erweitert.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:09:16 +00:00
8e838c4a66 feat: --fixed Flag — Walk-Forward ohne Grid-Suche (fixe Spec-Default-Parameter) 2026-06-09 22:03:31 +00:00
cdf5b133a9 feat: Backfill-Ziel auf 36 Monate erweitert 2026-06-09 21:48:23 +00:00
c07a34e671 feat: ADX-Trendstärke-Filter (fix 20, nicht im Grid) gegen Chop-Whipsaw
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:46:57 +00:00
736db184ab feat: Short-Seite im Runner + Walk-Forward-CLI (--shorts Flag)
- BacktestConfig.allowShort: boolean (required, explizit)
- Runner: Short-Entry (stop = close + mult×ATR), updateChandelierShort für Trail,
  Stop-Check auf High >= stop, Gap-Fill nach oben
- Bestehende Runner-Tests: allowShort: false ergänzt (Verhalten byte-identisch)
- Neuer E2E-Test: Short-Breakout → trailing_stop; Long-Only-Sanity-Check
- walkforward.ts script: --shorts Flag, Ausgabe "Shorts: AN/AUS"
- walkforward.test.ts: allowShort: false ergänzt

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 21:33:58 +00:00
30 changed files with 2524 additions and 68 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
node_modules
docs
.env
*.md

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM oven/bun:1.3
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY . .
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \
CMD bun -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["bun", "run", "start"]

View File

@@ -0,0 +1,127 @@
# trade-kuns — Live-Paper-Engine (Phase 3, Design)
**Datum:** 2026-06-10
**Status:** Umsetzung (User-Entscheidung: bewusster Paper-Probelauf)
**Basis:** Spec `2026-06-09-trade-kuns-design.md` §4.34.6, §6, §8
---
## 1. Kontext & Entscheidung
Das Walk-Forward-Gate wurde von keiner der 7 Varianten bestanden (siehe
`docs/walkforward-ergebnisse-2026-06-09.md`). Beste Variante: **long-only mit
fixen Spec-Default-Parametern** (Donchian 20 / ATR×3 / EMA 200 / ADX 20) —
OOS-PF 1.21, 249 Trades, MaxDD 16 %, Overfitting-Ratio 1.51; einziger
Fail: 11/32 Fenster mit PF < 0.5.
**Entscheidung (User, 2026-06-10):** Live-**Paper**-Engine bauen und diese
Variante im Paper-Probelauf validieren. Das Gate wird nicht aufgeweicht —
der Paper-Lauf *ist* die nächste Validierungsstufe. Kein echtes Geld, keine
Order-Ausführung, keine Schreib-API-Keys (Spec §10 unverändert).
## 2. Abweichungen von der Ursprungs-Spec
| Spec | Jetzt | Grund |
|---|---|---|
| Subdomain `trade.kuns.dev` | **`trading.kuns.dev`** | User-Vorgabe |
| Hono | **`Bun.serve`** | 6 GET-Routen, kein Framework nötig |
| Vue 3 + Vite Dashboard | **statische Single-Page** (Vanilla JS, Canvas-Chart) | kein Build-Step, kein Over-Engineering |
| Zitadel-JWT-Auth | **keine Auth** | API ist read-only (Paper-Daten, nichts Sensibles); Schreib-Endpoints (`toggle`/`reset`/`config`) entfallen in v1 |
| Port 8080 | 8080 | unverändert |
## 3. Architektur
Ein Bun-Prozess (`src/server/index.ts`): HTTP-Server + 5-min-Loop.
```
src/server/
live/
engine.ts LiveEngine: Zyklus-Orchestrierung, Recovery, Persistenz
process-cycle.ts pure Funktion: (candles, state) → actions (Entries/Exits/Decisions/Equity)
api/
server.ts Bun.serve: /health, /api/*, statisches Dashboard
db/schema.ts + positions, paper_trades, decision_logs, bot_state, equity_snapshots
public/index.html Dashboard
```
**Ein Code-Pfad-Prinzip bleibt:** `process-cycle.ts` nutzt exakt dieselben
puren Funktionen wie der Backtest-Runner (`computeIndicators`, `evaluateAt`,
`updateChandelier`, `sizePosition`, `Portfolio`) mit identischer Semantik:
4h-Entries (Close > Donchian-High(20) ∧ Close > EMA-200 ∧ ADX ≥ 20),
15m-Stop-Checks (Low ≤ Stop → Exit, Gap → Open als schlechterer Fill),
Chandelier-Update pro abgeschlossener 4h-Bar, Fees 0.1 % + Slippage 5 bps.
## 4. Zyklus (alle 5 Minuten)
1. **Fetch:** je Pair neueste 15m-Candles von Crypto.com (Lücke seit Cursor,
`end_ts`-Paginierung wie Backfill, max 300/Request), nur abgeschlossene
(`ts + 15m ≤ now`) → `candles` (Dedup via PK).
2. **Load:** je Pair letzte ~6500 15m-Candles (≈ 400 4h-Bars — Warmup für
EMA-200 + Donchian) aus DB.
3. **Process** (pure, deterministisch): alle 15m-Candles mit `ts > cursor`
chronologisch gemergt (Tie-Break PAIRS-Reihenfolge wie Runner):
- neue abgeschlossene 4h-Bars des Pairs: Chandelier-Update → Entry-Evaluation
(jede Evaluation → DecisionLog, inkl. Blockierungsgrund/Sizing-Block)
- 15m-Stop-Check der offenen Position
- Equity-Punkt einmal pro 4h-Bucket
4. **Persist** (eine Transaktion): Positions-Upsert/Delete, Trades,
DecisionLogs, Equity-Snapshots, `bot_state` (cash, cursor).
5. **Outcome-Backfill:** `decision_logs` mit NULL-Outcomes füllen, sobald
Candles 4h/24h/72h später vorliegen (Edge-Frühindikator, Spec §5.5).
**Initialisierung (erster Start):** `bot_state` mit 1000 USDT Cash,
Cursor = neueste abgeschlossene 15m-Candle. Keine historische Replay —
der erste mögliche Entry ist der nächste frische 4h-Close.
**Restart-Recovery (Spec §6):** Positionen + Cash + Cursor aus DB; verpasste
Candles werden nachgeholt und Stops rückwirkend geprüft — identisch zum
Normalzyklus, da der Prozess-Schritt nur vom Cursor abhängt.
**Fehler:** API-Fehler eines Pairs → Pair in diesem Zyklus überspringen,
andere laufen weiter; Fetch-Retry (3×, Backoff) existiert im Client.
DB-Fehler → Zyklus abbrechen, Status rot. Überlappende Zyklen durch
`running`-Flag verhindert. Letzter Zyklus-Status sichtbar in `/api/portfolio`.
## 5. Datenbank (neu)
- `positions` — pair (PK), side, qty, entry_ts, entry_price, entry_cost,
initial_stop, stop, trail_extreme, risk_amount
- `paper_trades` — id, pair, side, entry/exit (ts+price), qty, pnl, r, exit_reason
- `decision_logs` — id, pair, bar_ts (4h), signal, blocked_by, close, atr, adx,
donchian_high, trend_ema, price_after_4h/24h/72h (nullable), unique(pair, bar_ts)
- `bot_state` — id=1, cash, start_capital, cursor_ts, updated_at
- `equity_snapshots` — ts (PK, 4h-Bucket), equity, cash
## 6. API & Dashboard
| Route | Inhalt |
|---|---|
| `GET /health` | `{ok, lastCycle, cycleError}` — Coolify-Healthcheck |
| `GET /api/portfolio` | Equity, Cash, offene Positionen (inkl. Stop, unrealized PnL), Zyklus-Status |
| `GET /api/trades?limit` | abgeschlossene Trades, neueste zuerst |
| `GET /api/decisions?pair&limit` | DecisionLog inkl. Outcomes |
| `GET /api/stats` | PF, WinRate, MaxDD, avgR, Trade-Anzahl, Equity-Kurve |
| `GET /api/candles?pair&tf=15m|4h&limit` | Candles fürs Dashboard |
Dashboard: eine statische Seite, 30-s-Polling. KPI-Leiste (Equity, PnL, PF,
WinRate, MaxDD), Equity-Kurve (Canvas), Tabellen: offene Positionen, Trades,
letzte Decisions je Pair.
## 7. Tests
- `process-cycle`: Determinismus; Entry auf 4h-Close; Stop-Check inkl.
Gap-Fill; Cursor-Idempotenz (zweiter Lauf ohne neue Candles = no-op);
Restart-Äquivalenz (ein Lauf über N Candles ≡ zwei Läufe mit Cut dazwischen).
- Engine-Persistenz gegen Test-DB wird nicht automatisiert (kein CI mit DB);
manuelle Verifikation beim Deploy.
## 8. Deployment
- Dockerfile `oven/bun:1.3`, Start: Migrationen → Server, Port 8080,
Healthcheck `/health`.
- Coolify-App `trade-kuns`, Domain `https://trading.kuns.dev`, Netz `coolify`.
- `DATABASE_URL=postgres://mika:…@l8kogcggsc80sgcgk8kswww4:5432/tradekuns`
(shared-postgres über Coolify-Docker-Netz; vom Host aus weiterhin
`localhost:54320`).
- Backfill läuft weiter vom Host (`bun run backfill`) oder implizit über den
Lücken-Fetch des Loops.

View File

@@ -0,0 +1,45 @@
# Walk-Forward-Ergebnisse — 2026-06-09
Datenbasis: 103 799 15m-Candles je Pair (BTC/ETH/SOL/XRP_USDT), 2023-06-24 → 2026-06-09 (~3 Jahre,
Crypto.com-History-Limit). 32 OOS-Fenster (Train 120d → Test 30d, Schritt 30d). Fees 0.1 % + Slippage
0.05 % je Seite. Alle Läufe in DB `tradekuns.backtest_runs` persistiert.
## Getestete Varianten (chronologisch, jede als Antwort auf einen diagnostizierten Defekt)
| # | Variante | Daten | OOS-PF | Trades | MaxDD | Overfit-Ratio | Gate |
|---|----------|-------|-------:|-------:|------:|--------------:|------|
| 1 | Long-only, Grid-Suche | 14 Mon | 0.64 | 65 | 16.7 % | 3.45 | ❌ 3/5 Checks rot |
| 2 | + Shorts, Grid | 14 Mon | 1.13 | 148 | 24.7 % | 1.52 | ❌ PF + Fenster |
| 3 | + ADX-Filter (fix 20), Grid | 14 Mon | 1.12 | 117 | 16.9 % | 1.54 | ❌ PF + Fenster |
| 4 | Long-only, Grid (3 J) | 36 Mon | 1.25 | 214 | 13.8 % | 2.58 | ❌ Fenster + Ratio |
| 5 | Shorts+ADX, Grid (3 J) | 36 Mon | 1.11 | 405 | 20.1 % | 1.71 | ❌ PF + Fenster |
| 6 | **Long-only, FIXE Params** (Donchian 20 / ATR×3 / EMA 200 / ADX 20) | 36 Mon | **1.21** | **249** | **16.0 %** | **1.51** | ❌ **nur Fenster-Check** (4/5 ✅) |
| 7 | Momentum-Rotation (30d/weekly/top-1, fix) | 36 Mon | 0.48 | 61 | 55.2 % | 4.64 | ❌ 4/5 Checks rot |
## Kernbefunde
1. **Shorts verwässern auf 3 Jahren den Edge** (PF 1.25 → 1.11): Krypto-Long-Bias; Breakdowns
produzieren mehr Whipsaw als Breakouts. Auf den letzten 14 Monaten (Bärenphase) war es umgekehrt.
2. **Grid-Suche schadet:** Ratio 2.58 mit Grid vs. 1.51 mit fixen Parametern bei fast gleichem PF.
Die Parameterwahl auf Train-Fenstern pickt Rauschen.
3. **Beste Variante (#6)** hat echten, aber dünnen und klumpigen Edge: +17 % über ~2.4 Jahre OOS,
aber **11 von 32 Fenstern verlieren mit PF < 0.5** — darunter die beiden jüngsten
(Frühjahr 2026). Trendfolge ist naturgemäß klumpig, aber 34 % schlechte Monate inkl. der
aktuellsten ist ein substanzieller Befund, kein Artefakt des strengen Kriteriums.
4. **Momentum-Rotation (#7) ist in dieser Form unbrauchbar** — auch in-sample durchgehend rot,
d. h. kein Overfitting-Problem, sondern kein Edge (Voll-Equity ohne Stops + wöchentliches
Nachjagen des Leaders in 4 hochkorrelierten Assets).
## Methodische Notiz
Sieben Varianten gegen dieselben Daten testen erodiert die Aussagekraft des Gates
(Multiple-Testing). Jede Iteration war eine vorab benannte Antwort auf einen konkreten Defekt,
nicht freies Fishing — trotzdem gilt: Der eigentliche Beweis wäre ohnehin erst Live-Paper-Trading
mit dem DecisionLog-Edge-Monitoring. Das Gate-Kriterium „kein Fenster PF < 0.5" wurde für ~13
Fenster entworfen und skaliert nicht formal mit 32 Fenstern — bei 11/32 schlechten Fenstern ist
das aber akademisch: Auch ein skaliertes Kriterium (z. B. ≤ 15 % schlechte Fenster) würde reißen.
## Status
**Kein Deploy.** Phase 3 (Live-Engine) wird laut Spec erst gebaut, wenn eine Variante das Gate
besteht — oder der User das Gate bewusst neu definiert. Entscheidung offen.

View File

@@ -0,0 +1,58 @@
CREATE TABLE "bot_state" (
"id" integer PRIMARY KEY NOT NULL,
"cash" double precision NOT NULL,
"start_capital" double precision NOT NULL,
"cursor_ts" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "decision_logs" (
"id" serial PRIMARY KEY NOT NULL,
"pair" varchar(16) NOT NULL,
"bar_ts" timestamp with time zone NOT NULL,
"signal" text,
"blocked_by" text,
"close" double precision NOT NULL,
"atr" double precision,
"adx" double precision,
"donchian_high" double precision,
"trend_ema" double precision,
"price_after_4h" double precision,
"price_after_24h" double precision,
"price_after_72h" double precision
);
--> statement-breakpoint
CREATE TABLE "equity_snapshots" (
"ts" timestamp with time zone PRIMARY KEY NOT NULL,
"equity" double precision NOT NULL,
"cash" double precision NOT NULL
);
--> statement-breakpoint
CREATE TABLE "paper_trades" (
"id" serial PRIMARY KEY NOT NULL,
"pair" varchar(16) NOT NULL,
"side" text NOT NULL,
"entry_ts" timestamp with time zone NOT NULL,
"entry_price" double precision NOT NULL,
"exit_ts" timestamp with time zone NOT NULL,
"exit_price" double precision NOT NULL,
"qty" double precision NOT NULL,
"pnl" double precision NOT NULL,
"r" double precision NOT NULL,
"exit_reason" text NOT NULL
);
--> statement-breakpoint
CREATE TABLE "positions" (
"pair" varchar(16) PRIMARY KEY NOT NULL,
"side" text NOT NULL,
"qty" double precision NOT NULL,
"entry_ts" timestamp with time zone NOT NULL,
"entry_price" double precision NOT NULL,
"entry_cost" double precision NOT NULL,
"initial_stop" double precision NOT NULL,
"stop" double precision NOT NULL,
"trail_extreme" double precision NOT NULL,
"risk_amount" double precision NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "decision_logs_pair_bar_ts" ON "decision_logs" USING btree ("pair","bar_ts");

View File

@@ -0,0 +1,466 @@
{
"id": "38fbc5fc-4ef1-4dae-b408-21bcafa513b7",
"prevId": "00b411bc-669e-4667-881c-c9161fa42bb0",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.backtest_runs": {
"name": "backtest_runs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true
},
"config": {
"name": "config",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"result": {
"name": "result",
"type": "jsonb",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.bot_state": {
"name": "bot_state",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"cash": {
"name": "cash",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"start_capital": {
"name": "start_capital",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"cursor_ts": {
"name": "cursor_ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.candles": {
"name": "candles",
"schema": "",
"columns": {
"pair": {
"name": "pair",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"ts": {
"name": "ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"open": {
"name": "open",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"high": {
"name": "high",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"low": {
"name": "low",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"close": {
"name": "close",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"volume": {
"name": "volume",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"candles_pair_ts_pk": {
"name": "candles_pair_ts_pk",
"columns": [
"pair",
"ts"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.decision_logs": {
"name": "decision_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"pair": {
"name": "pair",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"bar_ts": {
"name": "bar_ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"signal": {
"name": "signal",
"type": "text",
"primaryKey": false,
"notNull": false
},
"blocked_by": {
"name": "blocked_by",
"type": "text",
"primaryKey": false,
"notNull": false
},
"close": {
"name": "close",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"atr": {
"name": "atr",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"adx": {
"name": "adx",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"donchian_high": {
"name": "donchian_high",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"trend_ema": {
"name": "trend_ema",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"price_after_4h": {
"name": "price_after_4h",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"price_after_24h": {
"name": "price_after_24h",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"price_after_72h": {
"name": "price_after_72h",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"decision_logs_pair_bar_ts": {
"name": "decision_logs_pair_bar_ts",
"columns": [
{
"expression": "pair",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "bar_ts",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.equity_snapshots": {
"name": "equity_snapshots",
"schema": "",
"columns": {
"ts": {
"name": "ts",
"type": "timestamp with time zone",
"primaryKey": true,
"notNull": true
},
"equity": {
"name": "equity",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"cash": {
"name": "cash",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.paper_trades": {
"name": "paper_trades",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"pair": {
"name": "pair",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true
},
"side": {
"name": "side",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entry_ts": {
"name": "entry_ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"entry_price": {
"name": "entry_price",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"exit_ts": {
"name": "exit_ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"exit_price": {
"name": "exit_price",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"qty": {
"name": "qty",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"pnl": {
"name": "pnl",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"r": {
"name": "r",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"exit_reason": {
"name": "exit_reason",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.positions": {
"name": "positions",
"schema": "",
"columns": {
"pair": {
"name": "pair",
"type": "varchar(16)",
"primaryKey": true,
"notNull": true
},
"side": {
"name": "side",
"type": "text",
"primaryKey": false,
"notNull": true
},
"qty": {
"name": "qty",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"entry_ts": {
"name": "entry_ts",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"entry_price": {
"name": "entry_price",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"entry_cost": {
"name": "entry_cost",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"initial_stop": {
"name": "initial_stop",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"stop": {
"name": "stop",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"trail_extreme": {
"name": "trail_extreme",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"risk_amount": {
"name": "risk_amount",
"type": "double precision",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1781038570957,
"tag": "0000_nifty_brood",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781071452889,
"tag": "0001_certain_omega_red",
"breakpoints": true
}
]
}

View File

@@ -3,9 +3,11 @@
"private": true,
"type": "module",
"scripts": {
"start": "bun run db:migrate && bun run src/server/index.ts",
"test": "bun test",
"backfill": "bun run src/server/scripts/backfill.ts",
"walkforward": "bun run src/server/scripts/walkforward.ts",
"rotation": "bun run src/server/scripts/rotation-walkforward.ts",
"db:generate": "bunx drizzle-kit generate",
"db:migrate": "bun run src/server/db/migrate.ts"
},

166
public/index.html Normal file
View File

@@ -0,0 +1,166 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>trade-kuns — Paper Trading</title>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --border: #21262d; --text: #e6edf3;
--muted: #8b949e; --green: #3fb950; --red: #f85149; --accent: #58a6ff;
--mono: ui-monospace, 'JetBrains Mono', 'Fira Code', monospace;
}
* { box-sizing: border-box; margin: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; padding: 24px; max-width: 1200px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
h1 { font-size: 20px; font-weight: 600; }
h1 small { color: var(--muted); font-weight: 400; font-size: 13px; }
#status { margin-left: auto; font-size: 12px; color: var(--muted); }
#status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; }
.kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.kpi .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.kpi .value { font-size: 20px; font-weight: 600; font-family: var(--mono); margin-top: 2px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.panel h2 { font-size: 14px; font-weight: 600; margin-bottom: 10px; color: var(--muted); }
canvas { width: 100%; height: 220px; display: block; }
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
th { text-align: right; color: var(--muted); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--border); }
td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
tr:last-child td { border-bottom: none; }
.pos { color: var(--green); } .neg { color: var(--red); }
.empty { color: var(--muted); padding: 12px 8px; font-style: italic; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; background: var(--border); color: var(--muted); }
.tag.long { background: rgba(63,185,80,.15); color: var(--green); }
@media (max-width: 700px) { body { padding: 12px; } td, th { padding: 5px 4px; } }
</style>
</head>
<body>
<header>
<h1>trade-kuns <small>Donchian-Trendfolge · Paper · BTC ETH SOL XRP</small></h1>
<div id="status"><span class="dot" style="background:var(--muted)"></span>lade…</div>
</header>
<div class="kpis" id="kpis"></div>
<div class="panel"><h2>Equity-Kurve (4h)</h2><canvas id="chart" height="220"></canvas></div>
<div class="panel"><h2>Offene Positionen</h2><div id="positions"></div></div>
<div class="panel"><h2>Abgeschlossene Trades</h2><div id="trades"></div></div>
<div class="panel"><h2>Letzte Entscheidungen (4h-Bars)</h2><div id="decisions"></div></div>
<script>
const fmt = (n, d = 2) => n == null || Number.isNaN(n) ? '' : n.toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtTs = (ts) => ts == null ? '' : new Date(ts).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
const cls = (n) => n > 0 ? 'pos' : n < 0 ? 'neg' : '';
const sign = (n, d = 2) => (n > 0 ? '+' : '') + fmt(n, d);
function kpi(label, value, klass = '') {
return `<div class="kpi"><div class="label">${label}</div><div class="value ${klass}">${value}</div></div>`;
}
function table(headers, rows, emptyMsg) {
if (!rows.length) return `<div class="empty">${emptyMsg}</div>`;
return `<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
}
function drawChart(curve, startCapital) {
const cv = document.getElementById('chart');
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = 220;
cv.width = w * dpr; cv.height = h * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (curve.length < 2) {
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
ctx.fillText('Noch keine Equity-Historie — der erste 4h-Bucket kommt.', 10, h / 2);
return;
}
const pad = { l: 54, r: 8, t: 10, b: 22 };
const xs = curve.map(p => p.ts), ys = curve.map(p => p.equity);
const xmin = xs[0], xmax = xs[xs.length - 1];
let ymin = Math.min(...ys, startCapital), ymax = Math.max(...ys, startCapital);
const yspan = (ymax - ymin) || 1; ymin -= yspan * .08; ymax += yspan * .08;
const X = t => pad.l + (t - xmin) / (xmax - xmin) * (w - pad.l - pad.r);
const Y = v => h - pad.b - (v - ymin) / (ymax - ymin) * (h - pad.t - pad.b);
ctx.strokeStyle = '#21262d'; ctx.fillStyle = '#8b949e'; ctx.font = '11px system-ui';
for (let i = 0; i <= 4; i++) {
const v = ymin + (ymax - ymin) * i / 4, y = Y(v);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
ctx.fillText(fmt(v, 0), 6, y + 4);
}
ctx.strokeStyle = '#8b949e'; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(pad.l, Y(startCapital)); ctx.lineTo(w - pad.r, Y(startCapital)); ctx.stroke();
ctx.setLineDash([]);
const last = ys[ys.length - 1];
ctx.strokeStyle = last >= startCapital ? '#3fb950' : '#f85149'; ctx.lineWidth = 1.8;
ctx.beginPath();
curve.forEach((p, i) => i ? ctx.lineTo(X(p.ts), Y(p.equity)) : ctx.moveTo(X(p.ts), Y(p.equity)));
ctx.stroke();
ctx.fillStyle = '#8b949e';
ctx.fillText(fmtTs(xmin), pad.l, h - 6);
const endLabel = fmtTs(xmax);
ctx.fillText(endLabel, w - pad.r - ctx.measureText(endLabel).width, h - 6);
}
async function refresh() {
try {
const [pf, stats, trades, decisions] = await Promise.all([
fetch('/api/portfolio').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
fetch('/api/trades?limit=30').then(r => r.json()),
fetch('/api/decisions?limit=12').then(r => r.json()),
]);
const pnl = pf.equity - pf.startCapital;
const pnlPct = pf.startCapital ? pnl / pf.startCapital * 100 : 0;
document.getElementById('kpis').innerHTML =
kpi('Equity', fmt(pf.equity) + ' $') +
kpi('PnL gesamt', `${sign(pnl)} $ (${sign(pnlPct, 1)} %)`, cls(pnl)) +
kpi('Cash', fmt(pf.cash) + ' $') +
kpi('Trades', stats.trades) +
kpi('Profit Factor', stats.trades ? fmt(stats.profitFactor) : '') +
kpi('Win Rate', stats.trades ? fmt(stats.winRate * 100, 0) + ' %' : '') +
kpi('Max DD', fmt(stats.maxDrawdownPct * 100, 1) + ' %') +
kpi('BTC Buy&Hold', stats.btcBuyHoldPct == null ? '' : sign(stats.btcBuyHoldPct, 1) + ' %', cls(stats.btcBuyHoldPct));
drawChart(stats.equityCurve || [], stats.startCapital);
document.getElementById('positions').innerHTML = table(
['Pair', 'Entry', 'Entry-Preis', 'Letzter', 'Stop', 'Wert $', 'PnL $'],
pf.positions.map(p => `<tr><td><span class="tag long">${p.pair}</span></td><td>${fmtTs(p.entryTs)}</td><td>${fmt(p.entryPrice, 4)}</td><td>${fmt(p.lastPrice, 4)}</td><td>${fmt(p.stop, 4)}</td><td>${fmt(p.value)}</td><td class="${cls(p.unrealizedPnl)}">${sign(p.unrealizedPnl)}</td></tr>`),
'Keine offene Position — warten auf Donchian-Breakout. Das ist Normalbetrieb.');
document.getElementById('trades').innerHTML = table(
['Pair', 'Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'R', 'Grund'],
trades.map(t => `<tr><td><span class="tag long">${t.pair}</span></td><td>${fmtTs(new Date(t.entryTs).getTime())}</td><td>${fmtTs(new Date(t.exitTs).getTime())}</td><td>${fmt(t.entryPrice, 4)}</td><td>${fmt(t.exitPrice, 4)}</td><td class="${cls(t.pnl)}">${sign(t.pnl)}</td><td class="${cls(t.r)}">${sign(t.r, 2)}</td><td>${t.exitReason}</td></tr>`),
'Noch keine abgeschlossenen Trades.');
document.getElementById('decisions').innerHTML = table(
['Pair', '4h-Bar', 'Close', 'Donchian-High', 'EMA-200', 'ADX', 'Ergebnis'],
decisions.map(d => {
const res = d.signal === 'long' && !d.blockedBy ? '<span class="tag long">ENTRY</span>'
: d.signal === 'long' ? `<span class="tag">long, ${d.blockedBy}</span>`
: `<span class="tag">${d.blockedBy ?? ''}</span>`;
return `<tr><td>${d.pair}</td><td>${fmtTs(new Date(d.barTs).getTime())}</td><td>${fmt(d.close, 4)}</td><td>${fmt(d.donchianHigh, 4)}</td><td>${fmt(d.trendEma, 4)}</td><td>${fmt(d.adx, 1)}</td><td style="text-align:left">${res}</td></tr>`;
}),
'Noch keine Entscheidungen — die erste 4h-Bar schließt demnächst.');
const eng = pf.engine || {};
const ok = eng.lastCycleOk !== false;
document.getElementById('status').innerHTML =
`<span class="dot" style="background:${ok ? 'var(--green)' : 'var(--red)'}"></span>` +
(eng.lastCycleAt ? `Zyklus ${fmtTs(eng.lastCycleAt)}${ok ? '' : ' — FEHLER: ' + (eng.lastError ?? '?')}` : 'erster Zyklus läuft…');
} catch (e) {
document.getElementById('status').innerHTML = `<span class="dot" style="background:var(--red)"></span>API nicht erreichbar`;
}
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>

163
src/server/api/server.ts Normal file
View File

@@ -0,0 +1,163 @@
import { and, desc, eq, gte } from 'drizzle-orm';
import { db } from '../db/client';
import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema';
import { aggregate4h } from '../market/aggregate';
import { computeMetrics, type EquityPoint } from '../backtest/metrics';
import type { ClosedTrade } from '../engine/portfolio';
import type { Pair } from '../types';
import { PAIRS } from '../types';
import type { LiveEngine } from '../live/engine';
function json(data: unknown, status = 200): Response {
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
}
function clampLimit(url: URL, def: number, max: number): number {
const n = Number(url.searchParams.get('limit') ?? def);
return Number.isFinite(n) ? Math.min(Math.max(1, Math.floor(n)), max) : def;
}
async function latestCloses(): Promise<Map<Pair, number>> {
const map = new Map<Pair, number>();
for (const pair of PAIRS) {
const [row] = await db
.select({ close: candles.close })
.from(candles)
.where(eq(candles.pair, pair))
.orderBy(desc(candles.ts))
.limit(1);
if (row) map.set(pair, row.close);
}
return map;
}
async function getPortfolio(engine: LiveEngine) {
const [state] = await db.select().from(botState).where(eq(botState.id, 1));
const posRows = await db.select().from(positions);
const closes = await latestCloses();
let equity = state?.cash ?? 0;
const pos = posRows.map((p) => {
const last = closes.get(p.pair as Pair) ?? p.entryPrice;
const value = p.qty * last;
equity += value;
return {
pair: p.pair,
side: p.side,
qty: p.qty,
entryTs: p.entryTs.getTime(),
entryPrice: p.entryPrice,
stop: p.stop,
initialStop: p.initialStop,
lastPrice: last,
value,
unrealizedPnl: value - p.entryCost,
riskAmount: p.riskAmount,
};
});
return {
equity,
cash: state?.cash ?? 0,
startCapital: state?.startCapital ?? 0,
cursorTs: state?.cursorTs.getTime() ?? null,
positions: pos,
engine: engine.status,
};
}
async function getStats() {
const [state] = await db.select().from(botState).where(eq(botState.id, 1));
const tradeRows = await db.select().from(paperTrades);
const trades: ClosedTrade[] = tradeRows.map((t) => ({
pair: t.pair as Pair,
entryTs: t.entryTs.getTime(),
entryPrice: t.entryPrice,
exitTs: t.exitTs.getTime(),
exitPrice: t.exitPrice,
qty: t.qty,
pnl: t.pnl,
r: t.r,
exitReason: t.exitReason as ClosedTrade['exitReason'],
side: t.side as 'long' | 'short',
}));
const curveRows = await db.select().from(equitySnapshots).orderBy(equitySnapshots.ts);
const curve: EquityPoint[] = curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity }));
const start = state?.startCapital ?? 1000;
const metrics = computeMetrics(trades, curve, start);
// Buy&Hold-BTC über denselben Zeitraum als Benchmark
let btcBuyHoldPct: number | null = null;
if (curve.length > 1) {
const [first] = await db
.select({ close: candles.close })
.from(candles)
.where(and(eq(candles.pair, 'BTC_USDT'), gte(candles.ts, new Date(curve[0].ts))))
.orderBy(candles.ts)
.limit(1);
const [last] = await db
.select({ close: candles.close })
.from(candles)
.where(eq(candles.pair, 'BTC_USDT'))
.orderBy(desc(candles.ts))
.limit(1);
if (first && last) btcBuyHoldPct = (last.close / first.close - 1) * 100;
}
return { ...metrics, startCapital: start, equityCurve: curve, btcBuyHoldPct };
}
export function createServer(engine: LiveEngine, port: number) {
const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url));
return Bun.serve({
port,
hostname: '0.0.0.0',
async fetch(req) {
const url = new URL(req.url);
try {
switch (url.pathname) {
case '/':
return new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } });
case '/health': {
const ok = engine.status.lastCycleOk;
return json({ ok, lastCycleAt: engine.status.lastCycleAt, error: engine.status.lastError }, ok ? 200 : 503);
}
case '/api/portfolio':
return json(await getPortfolio(engine));
case '/api/trades': {
const limit = clampLimit(url, 100, 500);
const rows = await db.select().from(paperTrades).orderBy(desc(paperTrades.exitTs)).limit(limit);
return json(rows);
}
case '/api/decisions': {
const limit = clampLimit(url, 50, 500);
const pair = url.searchParams.get('pair');
const where = pair ? eq(decisionLogs.pair, pair) : undefined;
const rows = await db.select().from(decisionLogs).where(where).orderBy(desc(decisionLogs.barTs)).limit(limit);
return json(rows);
}
case '/api/stats':
return json(await getStats());
case '/api/candles': {
const pair = url.searchParams.get('pair') ?? 'BTC_USDT';
if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400);
const tf = url.searchParams.get('tf') ?? '4h';
const limit = clampLimit(url, 500, 2000);
const raw = tf === '4h' ? limit * 16 : limit;
const rows = await db
.select()
.from(candles)
.where(eq(candles.pair, pair))
.orderBy(desc(candles.ts))
.limit(raw);
const c15 = rows.reverse().map((r) => ({ ts: r.ts.getTime(), open: r.open, high: r.high, low: r.low, close: r.close, volume: r.volume }));
return json(tf === '4h' ? aggregate4h(c15).slice(-limit) : c15);
}
default:
return json({ error: 'not found' }, 404);
}
} catch (err) {
console.error('API-Fehler:', url.pathname, err);
return json({ error: 'internal error' }, 500);
}
},
});
}

View File

@@ -0,0 +1,241 @@
import { expect, test } from 'bun:test';
import type { Candle, Pair } from '../types';
import { runRotationBacktest, type RotationConfig } from './rotation';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { H4 } from '../market/aggregate';
const M15 = 15 * 60 * 1000;
const WEEK = 7 * 24 * 3600 * 1000;
/**
* Synthetische 15m-Serie für einen 4h-Bucket.
* Alle 16 Candles tragen denselben OHLC; Close ist konstant = cl.
* ts = Bucket-Start (muss ein exakter H4-Vielfaches sein).
*/
function flat4h(bucketStart: number, cl: number): Candle[] {
const out: Candle[] = [];
for (let i = 0; i < 16; i++) {
out.push({ ts: bucketStart + i * M15, open: cl, high: cl, low: cl, close: cl, volume: 1 });
}
return out;
}
/**
* Baut eine 15m-Candleserie für ein Pair aus einer Liste von (bucketStart, close)-Paaren.
* Reihenfolge muss aufsteigend nach bucketStart sein.
*/
function buildSeries(bars: { ts: number; cl: number }[]): Candle[] {
return bars.flatMap(({ ts, cl }) => flat4h(ts, cl));
}
/**
* Basis-Konfiguration: lookback = 3 Bars (statt 180), damit Tests mit wenigen Bars funktionieren.
* tradeFrom = 0, tradeTo = sehr groß.
*
* Hinweis: Mit lookbackBars=3 gilt momentum = close[i] / close[i-3] 1.
*/
const BASE_CFG: RotationConfig = {
startCapital: 1000,
exec: DEFAULT_EXEC,
lookbackBars: 3,
tradeFrom: 0,
tradeTo: Number.MAX_SAFE_INTEGER,
};
// --------------------------------------------------------------------------
// Test (a): Pair A stark steigend, Pair B flach/fallend
// → System hält A; keine B-Trades
// --------------------------------------------------------------------------
test('(a) starkes A vs. flaches B → hält A, keine B-Trades', () => {
// 7 Bars Warmup (lookback=3 → ab Bar 3 kann Momentum berechnet werden)
// Bar 0..5 auf einem beliebigen H4-Raster (wir beginnen bei Ts=0)
// Rebalance-Trigger: erster Bar im Window = Bar 0 (firstBarInWindow).
// Momentum auf Bar 3: A[3]/A[0] - 1 = 150/100-1 = 0.5 > 0 → A gewinnt.
//
// A steigt: 100, 110, 130, 150, 200, 250, 300
// B flach: 100, 100, 100, 100, 100, 100, 100
//
// Woche 1-Trigger: erster Bar (Bar 0, ts=H4, weekBucket=0 wenn H4<WEEK).
// Bar 0 liegt bei ts=H4 (Close-ts), kein Momentum da idx=0 < lookbackBars=3.
// → Cash.
//
// Nächster Trigger: erster Bar nächster Woche.
// WEEK = 7×24×3600×1000 = 604800000 ms; H4 = 14400000 ms → 42 Bars/Woche.
// Wir bauen kleine Zeitreihe: Bars an Positionen 0,1,2,... × H4.
// Bar-Close-Ts von Bar i = (i+1)*H4 (da bucketStart=i*H4, Close-ts=bucketStart+H4).
// weekBucket(barCloseTs) = Math.floor(barCloseTs / WEEK).
// weekBucket ändert sich wenn barCloseTs überquert ein WEEK-Vielfaches.
// Für einfache Tests bauen wir Bars in 2 Wochen-Epochen direkt auf.
// Epoche 0: Bars 0..41 (weekBucket=0), Epoche 1: Bars 42..83.
// Warmup: Bars 0..2 (idx 0,1,2 sind < lookbackBars=3).
// Erster Trigger: Bar 0 (firstBarInWindow) → idx=0 < 3 → Cash.
// Woche-1-Trigger: Bar 42 (erster Bar mit weekBucket=1) → idx=42 >= 3.
// Momentum A: close[42]/close[39] - 1; wir geben A monoton steigende Preise.
const numBars = 50; // 50 Bars reichen für 2 Wochen (42 Bars/Woche)
const barsA: { ts: number; cl: number }[] = [];
const barsB: { ts: number; cl: number }[] = [];
for (let i = 0; i < numBars; i++) {
const ts = i * H4;
barsA.push({ ts, cl: 100 + i * 5 }); // A: 100, 105, 110, … (stark steigend)
barsB.push({ ts, cl: 100 }); // B: konstant flach
}
const data = new Map<Pair, Candle[]>([
['BTC_USDT', buildSeries(barsA)],
['ETH_USDT', buildSeries(barsB)],
]);
const result = runRotationBacktest(data, BASE_CFG);
// Muss mindestens 1 Trade haben (A wird geöffnet und am Ende geschlossen)
expect(result.trades.length).toBeGreaterThanOrEqual(1);
// Alle Trades müssen BTC_USDT (A) sein — kein Trade auf ETH_USDT (B)
const btcTrades = result.trades.filter((t) => t.pair === 'BTC_USDT');
const ethTrades = result.trades.filter((t) => t.pair === 'ETH_USDT');
expect(btcTrades.length).toBeGreaterThanOrEqual(1);
expect(ethTrades.length).toBe(0);
// Alle Trades sind Long
for (const t of result.trades) {
expect(t.side).toBe('long');
}
// A steigt → PnL positiv (abzüglich Gebühren)
const totalPnl = result.trades.reduce((s, t) => s + t.pnl, 0);
expect(totalPnl).toBeGreaterThan(0);
});
// --------------------------------------------------------------------------
// Test (b): Leader-Flip: A steigt zunächst, B beschleunigt → Rotation A→B
// --------------------------------------------------------------------------
test('(b) Leader-Flip: Rotation von A nach B erkennbar', () => {
// Strategie:
// Phase 1 (Bars 0..44, Woche 0): A steigt, B flach → A ist Leader.
// Phase 2 (Bars 45..86, Woche 1): A stagniert/fällt, B steigt stark → B wird Leader.
//
// lookbackBars=3: Momentum = close[i]/close[i-3] - 1.
// Trigger bei Bar 42 (weekBucket=1): prüfen close[42]/close[39] für A und B.
//
// Damit Bar 42 (weekBucket=1) B besser macht als A:
// A-Preis ab Bar 39: konstant 200 (kein Wachstum → mom_A = 200/200-1 = 0)
// B-Preis ab Bar 39: 100, 110, 120, 130 → mom_B = 130/100-1 = 0.3 > 0
//
// Phase 1 (Bars 0..41, weekBucket=0):
// A steigt: 100+i*3; B: 100 konstant
// Trigger bei Bar 0 (firstBar): idx=0 < lookback=3 → Cash (ok, kein Trade)
//
// Woche-1-Trigger bei Bar 42 (erster Bar mit barCloseTs >= 1*WEEK):
// barCloseTs von Bar 42 = 43*H4 = 620800000 ms > WEEK=604800000 → weekBucket=1 ✓
// mom_A = A[42]/A[39]-1 = A[42]/A[39]-1
// A[39]=100+39*3=217, A[40]=220, A[41]=223, A[42]=226 → mom_A=226/217-1≈0.041
// B[39]=100, B[40]=110, B[41]=120, B[42]=130 → mom_B=130/100-1=0.3
// → B gewinnt → Rotation A→B falls A zuvor gehalten wurde
//
// Damit A vorher gehalten wird, brauchen wir einen früheren Trigger mit A-Gewinn.
// Erster Bar in Window (Bar 0): idx=0 < lookback → kein Momentum → Cash.
// Das bedeutet beim ersten Trigger kein Trade. A wird erst beim nächsten
// Wochen-Trigger eröffnet (Bar 42) — aber dann gewinnt B schon.
//
// → Wir brauchen mindestens 3 Wochen-Epochen:
// Epoche 0 (Bars 0..41): erster Trigger → idx < 3 → Cash
// Epoche 1 (Bars 42..83): 2. Trigger (weekBucket=1) → A hat mom>0, B=0 → A eröffnet
// Epoche 2 (Bars 84..125): 3. Trigger (weekBucket=2) → B hat mom>A → Rotation A→B
//
// Preisdesign:
// Epoche 0 (Bars 0..41): A: 100+i*3, B: 100
// Epoche 1 (Bars 42..83): A: 100+i*3 (steigt weiter), B: 100 (konstant)
// → Trigger bei Bar 42: mom_A = A[42]/A[39]-1 > 0, mom_B = 0 → A gewinnt
// Epoche 2 (Bars 84..125): A: konstant 352 (=100+84*3), B: 100+j*10 (schnell steigend)
// → Trigger bei Bar 84 (weekBucket=2): mom_A = 352/352-1=0, mom_B = B[84]/B[81]-1
// B[81]=810, B[82]=820, B[83]=830, B[84]=840 → mom_B = 840/810-1 ≈ 0.037 > 0 → B gewinnt
// ABER: B muss bereits ab früh steigen damit die Lookback-Werte stimmen.
// Einfacher Ansatz: B steigt erst ab Bar 80, A stagniert ab Bar 80.
const numBars = 130;
const barsA: { ts: number; cl: number }[] = [];
const barsB: { ts: number; cl: number }[] = [];
for (let i = 0; i < numBars; i++) {
const ts = i * H4;
if (i < 80) {
barsA.push({ ts, cl: 100 + i * 3 }); // A steigt
barsB.push({ ts, cl: 100 }); // B flach
} else {
barsA.push({ ts, cl: 100 + 80 * 3 }); // A stagniert bei 340
barsB.push({ ts, cl: 100 + (i - 80) * 10 }); // B steigt stark
}
}
// Bar 84 (weekBucket=2, Trigger): mom_A = 340/340-1=0; mom_B = B[84]/B[81]-1
// B[81]=10, B[82]=20, B[83]=30, B[84]=40 → mom_B=40/10-1=3 > 0 → B gewinnt
const data = new Map<Pair, Candle[]>([
['BTC_USDT', buildSeries(barsA)],
['ETH_USDT', buildSeries(barsB)],
]);
const result = runRotationBacktest(data, BASE_CFG);
// Muss BTC-Trade UND ETH-Trade geben
const btcTrades = result.trades.filter((t) => t.pair === 'BTC_USDT');
const ethTrades = result.trades.filter((t) => t.pair === 'ETH_USDT');
expect(btcTrades.length).toBeGreaterThanOrEqual(1);
expect(ethTrades.length).toBeGreaterThanOrEqual(1);
// BTC muss vor ETH geschlossen worden sein (zeitliche Abfolge)
// Letzter BTC-Exit vor erstem ETH-Entry
const lastBtcExit = Math.max(...btcTrades.map((t) => t.exitTs));
const firstEthEntry = Math.min(...ethTrades.map((t) => t.entryTs));
expect(lastBtcExit).toBeLessThanOrEqual(firstEthEntry);
// BTC-Exit via 'rotation' (nicht end_of_data)
const btcRotation = btcTrades.some((t) => t.exitReason === 'rotation');
expect(btcRotation).toBe(true);
});
// --------------------------------------------------------------------------
// Test (c): Alle Pairs mit negativem Momentum → Cash, keine Trades
// --------------------------------------------------------------------------
test('(c) alle Pairs fallend → kein Trade (Cash)', () => {
// Alle Pairs fallen monoton: Momentum < 0 bei jedem Trigger.
// lookbackBars=3: close[i]/close[i-3]-1 < 0 → kein Leader → Cash.
const numBars = 90;
const bars: { ts: number; cl: number }[] = [];
for (let i = 0; i < numBars; i++) {
bars.push({ ts: i * H4, cl: 200 - i * 2 }); // fallend: 200, 198, 196, …
}
const data = new Map<Pair, Candle[]>([
['BTC_USDT', buildSeries(bars)],
['ETH_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 1 })))],
['SOL_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 2 })))],
['XRP_USDT', buildSeries(bars.map((b) => ({ ts: b.ts, cl: b.cl - 3 })))],
]);
const result = runRotationBacktest(data, BASE_CFG);
expect(result.trades).toHaveLength(0);
// Equity = startCapital (keine Trades, nur Cash)
expect(result.finalEquity).toBeCloseTo(1000, 1);
});
// --------------------------------------------------------------------------
// Test (d): Determinismus — identischer Input → identisches JSON
// --------------------------------------------------------------------------
test('(d) Determinismus: identischer Input → identisches JSON-Ergebnis', () => {
const numBars = 60;
const barsA = Array.from({ length: numBars }, (_, i) => ({ ts: i * H4, cl: 100 + i * 2 }));
const barsB = Array.from({ length: numBars }, (_, i) => ({ ts: i * H4, cl: 100 }));
const makeData = (): Map<Pair, Candle[]> =>
new Map<Pair, Candle[]>([
['BTC_USDT', buildSeries(barsA)],
['ETH_USDT', buildSeries(barsB)],
]);
const cfg: RotationConfig = { ...BASE_CFG };
const r1 = runRotationBacktest(makeData(), cfg);
const r2 = runRotationBacktest(makeData(), cfg);
expect(JSON.stringify(r1)).toBe(JSON.stringify(r2));
});

View File

@@ -0,0 +1,172 @@
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { aggregate4h, H4 } from '../market/aggregate';
import { Portfolio, type ExecConfig } from '../engine/portfolio';
import { computeMetrics, type EquityPoint } from './metrics';
import type { BacktestResult } from './runner';
export interface RotationConfig {
startCapital: number;
exec: ExecConfig;
/** Anzahl abgeschlossener 4h-Bars für die Momentum-Berechnung. 180 = 30 Tage × 6 Bars/Tag. */
lookbackBars: number;
tradeFrom: number; // ms inklusiv — Rotations-Entscheide erst ab hier
tradeTo: number; // ms exklusiv — danach Zwangsglattstellung
}
const WEEK = 7 * 24 * 3600 * 1000;
/**
* Momentum-Rotation-Backtest: Dual Momentum, wöchentliche Rotation, Long-Only.
*
* Entscheidungslogik:
* - Rebalance beim ersten abgeschlossenen 4h-Bar jeder Epochen-Woche ODER
* beim allerersten Bar im Handelsfenster.
* - Momentum = close_now / close_180bars_ago 1 (30-Tage-Lookback auf 4h).
* - Leader = Pair mit höchstem positivem Momentum; sonst Cash.
* - Rotation: schließe aktuellen Leader, öffne neuen Leader am Close dieses Bars.
*
* Keine Stops, kein TP — Exit nur via Rotation oder end_of_data.
*/
export function runRotationBacktest(
candles15ByPair: Map<Pair, Candle[]>,
cfg: RotationConfig,
): BacktestResult {
const portfolio = new Portfolio(cfg.startCapital, cfg.exec);
// lastClose: für Equity-Berechnung und Momentum. Wird pro Bar aktualisiert.
const lastClose = new Map<Pair, number>();
const equityCurve: EquityPoint[] = [];
// --- 4h-Aggregation pro Pair ---
// c4h[pair][i].ts = Bucket-Start (barCloseTs = ts + H4)
const c4hByPair = new Map<Pair, Candle[]>();
for (const pair of PAIRS) {
const c15 = candles15ByPair.get(pair);
if (c15 && c15.length > 0) {
c4hByPair.set(pair, aggregate4h(c15));
}
}
// --- Unified Timeline: alle abgeschlossenen 4h-Bar-Close-Timestamps ---
// Wir iterieren über die Bar-Close-Timestamps (= ts + H4) aller Pairs,
// sortiert aufsteigend. Bei gleichem Timestamp: PAIRS-Reihenfolge.
// Pro Timestamp sammeln wir {pair → {close, index}} für diesen Bar.
const allBarCloseTs = new Set<number>();
for (const [, bars] of c4hByPair) {
for (const b of bars) allBarCloseTs.add(b.ts + H4);
}
const sortedTs = Array.from(allBarCloseTs).sort((a, b) => a - b);
// Für schnellen Lookup: barCloseTs → per-pair {close, index}
// Aufgebaut inkrementell während der Iteration (kein Lookahead).
// next4hIndex[pair] = nächster noch nicht verarbeiteter Index in c4hByPair.get(pair)
const next4hIndex = new Map<Pair, number>();
for (const pair of PAIRS) next4hIndex.set(pair, 0);
// Letzter verarbeiteter Epochen-Wochen-Bucket (für Rebalance-Trigger)
let lastWeekBucket = -1;
// Merkt ob wir bereits mindestens einen Bar im Handelsfenster gesehen haben
let firstBarInWindow = true;
// Aktuell gehaltenes Pair (null = Cash)
let heldPair: Pair | null = null;
for (const barCloseTs of sortedTs) {
// Verarbeite alle Bars, deren Close-Timestamp == barCloseTs
// Aktualisiere lastClose für alle Pairs, die in diesem Timestamp einen Bar haben.
// Für Momentum: wir brauchen den Index dieses Bars (für Lookback).
const barIndexByPair = new Map<Pair, number>();
for (const pair of PAIRS) {
const bars = c4hByPair.get(pair);
if (!bars) continue;
let idx = next4hIndex.get(pair)!;
// Verarbeite alle Bars mit barCloseTs = ts + H4 == barCloseTs, d.h. ts == barCloseTs - H4
if (idx < bars.length && bars[idx].ts + H4 === barCloseTs) {
lastClose.set(pair, bars[idx].close);
barIndexByPair.set(pair, idx);
next4hIndex.set(pair, idx + 1);
}
// Wenn kein Bar für dieses Pair: lastClose bleibt auf dem letzten bekannten Wert,
// barIndexByPair hat keinen Eintrag → Pair ist bei Momentum ausgeschlossen.
}
// Equity-Punkt (nur im Handelsfenster)
if (barCloseTs >= cfg.tradeFrom && barCloseTs < cfg.tradeTo) {
equityCurve.push({ ts: barCloseTs, equity: portfolio.equity(lastClose) });
}
// Rebalance-Entscheid: nur innerhalb des Handelsfensters
if (barCloseTs < cfg.tradeFrom || barCloseTs >= cfg.tradeTo) continue;
const weekBucket = Math.floor(barCloseTs / WEEK);
const isNewWeek = weekBucket !== lastWeekBucket;
const isFirstBar = firstBarInWindow;
if (!isFirstBar && !isNewWeek) continue; // kein Rebalance-Trigger
lastWeekBucket = weekBucket;
firstBarInWindow = false;
// --- Momentum berechnen ---
// Nur Pairs, die in diesem barCloseTs einen Bar haben UND deren Index >= lookbackBars.
// Kein Lookahead: wir lesen nur bars[idx] (aktuell) und bars[idx - lookbackBars] (Vergangenheit).
let bestPair: Pair | null = null;
let bestMom = 0; // 0 = Schwelle (positiver Momentum erforderlich)
for (const pair of PAIRS) {
const idx = barIndexByPair.get(pair);
if (idx === undefined) continue; // Pair hat keinen Bar an diesem Timestamp
if (idx < cfg.lookbackBars) continue; // unzureichende Historie
const bars = c4hByPair.get(pair)!;
const closeNow = bars[idx].close;
const close30dAgo = bars[idx - cfg.lookbackBars].close;
// Momentum = closeNow / close30dAgo 1
const mom = closeNow / close30dAgo - 1;
if (mom > bestMom) {
bestMom = mom;
bestPair = pair;
}
}
// Ziel: bestPair (oder null = Cash)
const target = bestPair; // null wenn alle Momenta <= 0
if (target === heldPair) continue; // kein Wechsel nötig
// --- Rotation ausführen ---
const fillPrice = (pair: Pair) => lastClose.get(pair) ?? 0;
// Schließe aktuelle Position
if (heldPair !== null && portfolio.positions.has(heldPair)) {
portfolio.close(heldPair, barCloseTs, fillPrice(heldPair), 'rotation');
}
heldPair = null;
// Öffne neue Position
if (target !== null) {
const price = fillPrice(target);
if (price > 0) {
const equity = portfolio.equity(lastClose);
// qty = cash × 0.995 / fill (Anpassung für Gebühren beim Entry)
// riskAmount = 1% des Eigenkapitals (nur für R-Multiple-Berechnung)
const qty = (portfolio.cash * 0.995) / (price * (1 + cfg.exec.slippage));
const riskAmount = equity * 0.01;
// initialStop=0: kein Stop; Portfolio.open benötigt den Parameter,
// aber Rotation-Exits passieren nur via rotation/end_of_data.
portfolio.open(target, barCloseTs, price, 0, qty, riskAmount, 'long');
heldPair = target;
}
}
}
// Offene Positionen am Ende glattstellen
if (heldPair !== null && portfolio.positions.has(heldPair)) {
portfolio.close(heldPair, cfg.tradeTo, lastClose.get(heldPair) ?? 0, 'end_of_data');
}
return {
trades: portfolio.trades,
equityCurve,
finalEquity: portfolio.equity(lastClose),
};
}

View File

@@ -6,7 +6,8 @@ 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 };
// adxThreshold: 0 — neutralisiert den ADX-Filter, damit Breakout-Tests unverändert bleiben
const P = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 1, trendEmaPeriod: 5, adxThreshold: 0 };
/**
* Synthetische 15m-Serie: Plateau (4h-Closes ~100), dann Breakout-4h-Candle
@@ -41,7 +42,7 @@ 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,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
});
expect(result.trades).toHaveLength(1);
const t = result.trades[0];
@@ -57,7 +58,7 @@ 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
params: P, tradeFrom: 100 * H4, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false, // nach Serien-Ende
});
expect(result.trades).toHaveLength(0);
});
@@ -73,7 +74,7 @@ test('Stop-Order ist ab Entry aktiv: Low der Entry-Candle unter Stop → soforti
const data = new Map<Pair, Candle[]>([['BTC_USDT', s]]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
});
expect(result.trades).toHaveLength(1);
expect(result.trades[0].exitReason).toBe('trailing_stop');
@@ -101,7 +102,7 @@ test('maxPositions: bei gleichzeitigen Signalen gewinnt die PAIRS-Reihenfolge',
]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 1,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
});
// beide Pairs haben identische Serien → beide signalisieren; nur BTC (erster in PAIRS) darf
expect(result.trades).toHaveLength(1);
@@ -112,7 +113,61 @@ 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,
params: P, tradeFrom: 0, tradeTo: Number.MAX_SAFE_INTEGER, allowShort: false,
};
expect(JSON.stringify(runBacktest(data, cfg))).toBe(JSON.stringify(runBacktest(data, cfg)));
});
/**
* Synthetische Short-Serie:
* - 7 Plateau-Buckets @ 100 (lows=100, highs=100): donchianLow=100, EMA5=100
* → close(100) NICHT < donchianLow(100) → kein Short-Signal auf dem Plateau
* - Breakdown-Bucket: close=85 < donchianLow(100) UND < EMA5(95) → Short-Signal
* - Rallye-Bucket: High 130 reißt den Short-Stop (≈85 + 1×ATR)
* - tradeTo = Breakdown-barCloseTs + H4: blockiert Long-Entry nach dem Short-Exit
*/
function shortSeries(): { candles: Candle[]; breakdownBarCloseTs: number } {
const s: Candle[] = [];
let b = 0;
// 7 Plateau-Buckets mit exakt flachen Candles (low=100=close=high)
for (let i = 0; i < 7; i++, b += H4) s.push(...flat4h(b, 100, 100, 100, 100));
const breakdownBucketStart = b;
// Breakdown: close=85 < donchianLow(100) AND < EMA5(~95)
s.push(...flat4h(b, 100, 100, 84, 85)); b += H4;
// Rallye: High 130 reißt Short-Stop (Stop ≈ 85+ATR)
s.push(...flat4h(b, 85, 130, 85, 120)); b += H4;
// Abschluss-Bucket (damit Rallye als abgeschlossen gilt)
s.push(...flat4h(b, 120, 121, 119, 120)); b += H4;
// tradeTo: Entry-Zeitpunkt des Breakdown = breakdownBucketStart + H4
// +H4 dahinter blockiert die Rallye von einem Long-Entry
return { candles: s, breakdownBarCloseTs: breakdownBucketStart + H4 };
}
test('Short-Breakout → Short-Entry auf 4h-Close, Rallye → Stop-Exit auf 15m', () => {
const { candles, breakdownBarCloseTs } = shortSeries();
// tradeTo = breakdownBarCloseTs + H4: Short-Entry (barCloseTs=breakdownBarCloseTs) ist erlaubt
// (breakdownBarCloseTs < tradeTo), aber der nächste Bar (barCloseTs=breakdownBarCloseTs+H4=tradeTo)
// ist blockiert → kein Long-Entry nach dem Short-Exit
const tradeTo = breakdownBarCloseTs + H4;
const data = new Map<Pair, Candle[]>([['BTC_USDT', candles]]);
const result = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo, allowShort: true,
});
expect(result.trades).toHaveLength(1);
const t = result.trades[0];
// Entry: Short bei Close 85, Fill = 85*(1slippage) = 85*0.9995
expect(t.entryPrice).toBeCloseTo(85 * 0.9995);
expect(t.exitReason).toBe('trailing_stop');
expect(t.side).toBe('short');
// Verlustbringender Short (Preis stieg stark) → pnl < 0
expect(t.pnl).toBeLessThan(0);
// Sanity: gleiche Daten mit allowShort=false → kein Trade
// (kein Long-Signal: nach Plateau breakout close=85 ist weit unter EMA → blocked)
const resultLongOnly = runBacktest(data, {
startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4,
params: P, tradeFrom: 0, tradeTo, allowShort: false,
});
expect(resultLongOnly.trades).toHaveLength(0);
});

View File

@@ -2,7 +2,7 @@ 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 { updateChandelier, updateChandelierShort } from '../strategy/chandelier';
import { sizePosition, type RiskConfig } from '../engine/sizing';
import { Portfolio, type ExecConfig, type ClosedTrade } from '../engine/portfolio';
import type { EquityPoint } from './metrics';
@@ -15,6 +15,7 @@ export interface BacktestConfig {
params: StrategyParams;
tradeFrom: number; // ms inklusiv — Entries erst ab hier; Candles davor = Warmup
tradeTo: number; // ms exklusiv — danach wird zwangsglattgestellt
allowShort: boolean;
}
export interface BacktestResult {
@@ -69,14 +70,25 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
// 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;
if (pos.side === 'short') {
const next = updateChandelierShort(
{ lowestLow: pos.trailExtreme, stop: pos.stop },
bar.low,
ctx.ind.atr[i],
cfg.params.atrMultiplier,
);
pos.trailExtreme = next.lowestLow;
pos.stop = next.stop;
} else {
const next = updateChandelier(
{ highestHigh: pos.trailExtreme, stop: pos.stop },
bar.high,
ctx.ind.atr[i],
cfg.params.atrMultiplier,
);
pos.trailExtreme = next.highestHigh;
pos.stop = next.stop;
}
}
// 1b) Entry-Evaluation
@@ -87,12 +99,17 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
barCloseTs >= cfg.tradeFrom &&
barCloseTs < cfg.tradeTo
) {
const ev = evaluateAt(ctx.c4h, ctx.ind, i);
const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params, cfg.allowShort);
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);
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'long');
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'long');
} else if (ev.signal === 'short') {
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, 'short');
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'short');
}
}
}
@@ -102,9 +119,20 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
// ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv).
// Pessimistisch-realistisch, nicht "wegoptimieren".
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');
if (pos) {
if (pos.side === 'short') {
// Short: Stop wird getriggert wenn High >= Stop (Deckungskauf)
if (candle.high >= pos.stop) {
const exitPrice = candle.open > pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill (höherer Preis)
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
}
} else {
// Long: Stop wird getriggert wenn Low <= Stop
if (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);

View File

@@ -105,7 +105,7 @@ test('runWalkForward: OOS-Leak-Test mit synthetischen Daten', () => {
const result = runWalkForward(
data,
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 },
{ startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort: false },
dataFrom,
dataTo,
);

View File

@@ -29,7 +29,13 @@ export function buildWindows(dataFrom: number, dataTo: number, trainDays = 120,
export const PARAM_GRID: StrategyParams[] = [20, 40, 55].flatMap((donchianPeriod) =>
[2, 3, 4].flatMap((atrMultiplier) =>
[100, 200].map((trendEmaPeriod) => ({ donchianPeriod, atrPeriod: 14, atrMultiplier, trendEmaPeriod })),
[100, 200].map((trendEmaPeriod) => ({
donchianPeriod,
atrPeriod: 14,
atrMultiplier,
trendEmaPeriod,
adxThreshold: 20, // fix, nicht im Grid: zusätzlicher Freiheitsgrad würde das Gate aushöhlen
})),
),
);
@@ -94,6 +100,42 @@ export interface WalkForwardResult {
type BaseConfig = Omit<BacktestConfig, 'params' | 'tradeFrom' | 'tradeTo'>;
/**
* OOS-Aggregat aus einer Liste von WindowResults (oder strukturgleichen Objekten):
* Trades kombiniert, Equity-Kurven multiplikativ verkettet, Gate berechnet.
* Wiederverwendbar für alle Walk-Forward-Varianten (Donchian, Rotation, …).
*/
export function aggregateOos(
results: { trainMetrics: Metrics; testMetrics: Metrics; testTrades: ClosedTrade[]; testEquityCurve: EquityPoint[] }[],
startCapital: number,
): { oosTrades: ClosedTrade[]; oosEquityCurve: EquityPoint[]; oosMetrics: Metrics; gate: GateResult } {
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: startCapital * scale * (p.equity / startCapital) });
}
const last = r.testEquityCurve.at(-1);
if (last) scale *= last.equity / startCapital;
}
const oosMetrics = computeMetrics(oosTrades, oosEquityCurve, startCapital);
const worst = pickWorstEligibleWindow(results.map((r) => r.testMetrics));
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 { oosTrades, oosEquityCurve, oosMetrics, gate };
}
/** 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;
@@ -106,16 +148,17 @@ export function runWalkForward(
dataFrom: number,
dataTo: number,
onProgress?: (msg: string) => void,
grid: StrategyParams[] = PARAM_GRID,
): WalkForwardResult {
const windows = buildWindows(dataFrom, dataTo);
const results: WindowResult[] = [];
for (const [wi, w] of windows.entries()) {
let bestParams = PARAM_GRID[0];
let bestParams = grid[0];
let bestMetrics: Metrics | null = null;
let bestEligible = false;
for (const params of PARAM_GRID) {
for (const params of 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;
@@ -142,30 +185,7 @@ export function runWalkForward(
);
}
// 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 worst = pickWorstEligibleWindow(results.map((r) => r.testMetrics));
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,
});
const { oosTrades, oosEquityCurve, oosMetrics, gate } = aggregateOos(results, baseCfg.startCapital);
return { windows: results, oosMetrics, oosEquityCurve, gate };
}

View File

@@ -2,6 +2,7 @@ import { z } from 'zod';
const Env = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(8080),
});
export const env = Env.parse(process.env);

View File

@@ -1,4 +1,4 @@
import { doublePrecision, jsonb, pgTable, primaryKey, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core';
import { doublePrecision, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, uniqueIndex, varchar } from 'drizzle-orm/pg-core';
export const candles = pgTable(
'candles',
@@ -14,6 +14,67 @@ export const candles = pgTable(
(t) => [primaryKey({ columns: [t.pair, t.ts] })],
);
export const positions = pgTable('positions', {
pair: varchar('pair', { length: 16 }).primaryKey(),
side: text('side').notNull(), // 'long' | 'short'
qty: doublePrecision('qty').notNull(),
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
entryPrice: doublePrecision('entry_price').notNull(),
entryCost: doublePrecision('entry_cost').notNull(),
initialStop: doublePrecision('initial_stop').notNull(),
stop: doublePrecision('stop').notNull(),
trailExtreme: doublePrecision('trail_extreme').notNull(),
riskAmount: doublePrecision('risk_amount').notNull(),
});
export const paperTrades = pgTable('paper_trades', {
id: serial('id').primaryKey(),
pair: varchar('pair', { length: 16 }).notNull(),
side: text('side').notNull(),
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
entryPrice: doublePrecision('entry_price').notNull(),
exitTs: timestamp('exit_ts', { withTimezone: true }).notNull(),
exitPrice: doublePrecision('exit_price').notNull(),
qty: doublePrecision('qty').notNull(),
pnl: doublePrecision('pnl').notNull(),
r: doublePrecision('r').notNull(),
exitReason: text('exit_reason').notNull(),
});
export const decisionLogs = pgTable(
'decision_logs',
{
id: serial('id').primaryKey(),
pair: varchar('pair', { length: 16 }).notNull(),
barTs: timestamp('bar_ts', { withTimezone: true }).notNull(), // Start der 4h-Bar
signal: text('signal'), // 'long' | null
blockedBy: text('blocked_by'), // Evaluation.blockedBy | 'position_open' | 'max_positions' | Sizing-Block
close: doublePrecision('close').notNull(),
atr: doublePrecision('atr'),
adx: doublePrecision('adx'),
donchianHigh: doublePrecision('donchian_high'),
trendEma: doublePrecision('trend_ema'),
priceAfter4h: doublePrecision('price_after_4h'),
priceAfter24h: doublePrecision('price_after_24h'),
priceAfter72h: doublePrecision('price_after_72h'),
},
(t) => [uniqueIndex('decision_logs_pair_bar_ts').on(t.pair, t.barTs)],
);
export const botState = pgTable('bot_state', {
id: integer('id').primaryKey(), // immer 1
cash: doublePrecision('cash').notNull(),
startCapital: doublePrecision('start_capital').notNull(),
cursorTs: timestamp('cursor_ts', { withTimezone: true }).notNull(), // letzte verarbeitete 15m-Candle
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
});
export const equitySnapshots = pgTable('equity_snapshots', {
ts: timestamp('ts', { withTimezone: true }).primaryKey(), // 4h-Bucket
equity: doublePrecision('equity').notNull(),
cash: doublePrecision('cash').notNull(),
});
export const backtestRuns = pgTable('backtest_runs', {
id: serial('id').primaryKey(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),

View File

@@ -31,7 +31,7 @@ export interface ClosedTrade {
qty: number;
pnl: number;
r: number;
exitReason: 'trailing_stop' | 'end_of_data';
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation';
side: 'long' | 'short';
}

13
src/server/index.ts Normal file
View File

@@ -0,0 +1,13 @@
import { env } from './config';
import { LiveEngine } from './live/engine';
import { createServer } from './api/server';
const CYCLE_MS = 5 * 60 * 1000;
const engine = new LiveEngine();
await engine.init();
createServer(engine, env.PORT);
console.log(`trade-kuns Live-Paper-Engine läuft auf :${env.PORT}`);
void engine.runCycle();
setInterval(() => void engine.runCycle(), CYCLE_MS);

View File

@@ -0,0 +1,91 @@
import { expect, test } from 'bun:test';
import type { Candle } from '../types';
import { adx } from './adx';
function c(h: number, l: number, cl: number): Candle {
return { ts: 0, open: cl, high: h, low: l, close: cl, volume: 1 };
}
// --- (a) NaN vor Index 2×period1 ---
test('ADX: NaN vor Warmup (Index < 2×period1)', () => {
// period=3 → erste valide Stelle: Index 5 (= 2×31)
// Starker Aufwärtstrend: high=close, low=close0.5, +1 pro Bar
const candles: Candle[] = [];
for (let i = 0; i < 10; i++) candles.push(c(i + 1, i + 0.5, i + 1));
const result = adx(candles, 3);
// Alle Indizes < 5 müssen NaN sein
for (let i = 0; i < 5; i++) {
expect(Number.isNaN(result[i])).toBe(true);
}
// Index 5 muss ein valider Wert sein
expect(Number.isNaN(result[5])).toBe(false);
});
test('ADX: zu kurze Serie → alles NaN', () => {
// n=5 < 2×3=6 → alles NaN
const candles: Candle[] = [];
for (let i = 0; i < 5; i++) candles.push(c(i + 1, i + 0.5, i + 1));
const result = adx(candles, 3);
expect(result.every(Number.isNaN)).toBe(true);
});
// --- (b) Starker Aufwärtstrend → ADX hoch (> 50) ---
//
// Serie: high = close = i+1, low = i+0.5 → stetig +1/Bar.
// Jede Bar: up = 1 (high-Diff), down = -1 (<0) → plusDM=1, minusDM=0.
// TR = max(0.5, |highprevClose|, |lowprevClose|) = max(0.5, 1, 0.5) = 1
// (ab Bar 1: high[i]=i+1, prevClose=i → |highprevClose|=1)
// period=3: sTR=3, sPlus=3, sMinus=0 → DI+=100, DI=0 → DX=100.
// Wilder-Glättung hält DX=100 (reine Aufwärtsbewegung bleibt konstant).
// ADX[5] = (DX[3]+DX[4]+DX[5])/3 = 100. Alle späteren ebenfalls 100.
test('ADX: starker Aufwärtstrend → ADX > 50 nach Warmup', () => {
const candles: Candle[] = [];
// close = i+1, high = close, low = close0.5
// Achtung: Bar 0 hat kein prevClose → TR[0]=high[0]low[0]=0.5 (wird nicht in InitSum genutzt)
for (let i = 0; i < 20; i++) candles.push(c(i + 1, i + 0.5, i + 1));
const result = adx(candles, 3);
// Ab Index 5 (erster valider Wert) bis Ende: ADX muss > 50 sein
for (let i = 5; i < result.length; i++) {
expect(result[i]).toBeGreaterThan(50);
}
});
// --- (c) Völlig flache Candles → ADX = 0 (nicht NaN) ---
//
// H=L=C=100 für alle Bars.
// TR=0, plusDM=0, minusDM=0 → sTR=0 → computeDx gibt 0 zurück.
// DX=0 → ADX=0.
test('ADX: flache Candles → ADX = 0 (nicht NaN)', () => {
const candles: Candle[] = Array.from({ length: 20 }, () => c(100, 100, 100));
const result = adx(candles, 3);
// Indizes < 5: NaN
for (let i = 0; i < 5; i++) expect(Number.isNaN(result[i])).toBe(true);
// Ab Index 5: exakt 0
for (let i = 5; i < result.length; i++) expect(result[i]).toBe(0);
});
// --- (d) Zickzack (gleich große Auf/Ab-Bewegungen) → ADX < 25 nach Warmup ---
//
// Serie: close alterniert 0,1,0,1,0,1,...; high=close+0.5, low=close0.5.
// UpBar (close steigt): up=1, down=1<0 → plusDM=1, minusDM=0.
// DownBar (close fällt): up=1<0, down=1 → plusDM=0, minusDM=1.
// Kein klarer Trend → DX bleibt moderat, ADX konvergiert deutlich unter 25
// bei ausreichend langer Serie (ab Index ~12 stabil).
// Mit 30 Candles und period=3 hat ADX 25 Schritte nach dem Warmup zum Einpendeln.
test('ADX: Zickzack-Markt → ADX < 25 nach ausreichend Warmup', () => {
const candles: Candle[] = [];
for (let i = 0; i < 30; i++) {
const cl = i % 2 === 0 ? 0 : 1;
candles.push(c(cl + 0.5, cl - 0.5, cl));
}
const result = adx(candles, 3);
// Prüfe späte Indizes (ab 12), damit Einpendeln abgeschlossen ist
for (let i = 12; i < result.length; i++) {
expect(Number.isNaN(result[i])).toBe(false);
expect(result[i]).toBeLessThan(25);
}
});

View File

@@ -0,0 +1,44 @@
import type { Candle } from '../types';
/** Wilder ADX: +DM/DM → Wilder-geglättet → DI± → DX → ADX. NaN vor Index 2×period1. */
export function adx(candles: Candle[], period: number): number[] {
const n = candles.length;
const out = new Array<number>(n).fill(NaN);
if (n < 2 * period) return out;
const plusDM = [0];
const minusDM = [0];
const tr = [candles[0].high - candles[0].low];
for (let i = 1; i < n; i++) {
const up = candles[i].high - candles[i - 1].high;
const down = candles[i - 1].low - candles[i].low;
plusDM.push(up > down && up > 0 ? up : 0);
minusDM.push(down > up && down > 0 ? down : 0);
tr.push(Math.max(
candles[i].high - candles[i].low,
Math.abs(candles[i].high - candles[i - 1].close),
Math.abs(candles[i].low - candles[i - 1].close),
));
}
let sTR = 0, sPlus = 0, sMinus = 0;
for (let i = 1; i <= period; i++) { sTR += tr[i]; sPlus += plusDM[i]; sMinus += minusDM[i]; }
const dx = new Array<number>(n).fill(NaN);
const computeDx = () => {
if (sTR === 0) return 0; // völlig flacher Markt
const plusDI = (100 * sPlus) / sTR;
const minusDI = (100 * sMinus) / sTR;
const sum = plusDI + minusDI;
return sum === 0 ? 0 : (100 * Math.abs(plusDI - minusDI)) / sum;
};
dx[period] = computeDx();
for (let i = period + 1; i < n; i++) {
sTR = sTR - sTR / period + tr[i];
sPlus = sPlus - sPlus / period + plusDM[i];
sMinus = sMinus - sMinus / period + minusDM[i];
dx[i] = computeDx();
}
let sum = 0;
for (let i = period; i < 2 * period; i++) sum += dx[i];
out[2 * period - 1] = sum / period;
for (let i = 2 * period; i < n; i++) out[i] = (out[i - 1] * (period - 1) + dx[i]) / period;
return out;
}

226
src/server/live/engine.ts Normal file
View File

@@ -0,0 +1,226 @@
import { and, eq, isNull, lte, sql as dsql } from 'drizzle-orm';
import { db } from '../db/client';
import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema';
import { fetchCandles } from '../market/cryptocom';
import { getCandles, insertCandles } from '../market/candle-store';
import { H4 } from '../market/aggregate';
import { DEFAULT_PARAMS } from '../strategy/donchian-trend';
import { DEFAULT_RISK } from '../engine/sizing';
import { DEFAULT_EXEC, type Position } from '../engine/portfolio';
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { processCycle, type CycleConfig, type CycleResult, type LiveState } from './process-cycle';
const M15 = 15 * 60 * 1000;
const START_CAPITAL = 1000;
/** Warmup: 600 4h-Bars (~100 Tage) — EMA-200 braucht 200 + Konvergenz-Puffer. */
const WARMUP_4H_BARS = 600;
export const CYCLE_CONFIG: CycleConfig = {
risk: DEFAULT_RISK,
exec: DEFAULT_EXEC,
params: DEFAULT_PARAMS,
maxPositions: 4,
};
export interface EngineStatus {
lastCycleAt: number | null;
lastCycleOk: boolean;
lastError: string | null;
pairErrors: Partial<Record<Pair, string>>;
cursorTs: number | null;
}
export class LiveEngine {
status: EngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, pairErrors: {}, cursorTs: null };
private cycling = false;
/** Legt bot_state beim allerersten Start an: 1000 USDT, Cursor = jüngste abgeschlossene 15m-Candle. */
async init(): Promise<void> {
const [row] = await db.select().from(botState).where(eq(botState.id, 1));
if (row) {
this.status.cursorTs = row.cursorTs.getTime();
return;
}
const now = Date.now();
const cursor = Math.floor(now / M15) * M15 - M15; // letzte sicher abgeschlossene 15m-Candle
await db.insert(botState).values({
id: 1,
cash: START_CAPITAL,
startCapital: START_CAPITAL,
cursorTs: new Date(cursor),
});
this.status.cursorTs = cursor;
}
async runCycle(): Promise<void> {
if (this.cycling) return;
this.cycling = true;
try {
const state = await this.loadState();
await this.fetchGaps(state.cursorTs);
const candles15 = await this.loadCandles(state.cursorTs);
const result = processCycle(candles15, state, CYCLE_CONFIG);
await this.persist(state, result);
await this.backfillOutcomes();
this.status.lastCycleAt = Date.now();
this.status.lastCycleOk = true;
this.status.lastError = null;
this.status.cursorTs = result.cursorTs;
} catch (err) {
this.status.lastCycleAt = Date.now();
this.status.lastCycleOk = false;
this.status.lastError = err instanceof Error ? err.message : String(err);
console.error('Zyklus fehlgeschlagen:', err);
} finally {
this.cycling = false;
}
}
private async loadState(): Promise<LiveState> {
const [row] = await db.select().from(botState).where(eq(botState.id, 1));
if (!row) throw new Error('bot_state fehlt — init() nicht gelaufen?');
const posRows = await db.select().from(positions);
const pos: Position[] = posRows.map((p) => ({
pair: p.pair as Pair,
qty: p.qty,
entryTs: p.entryTs.getTime(),
entryPrice: p.entryPrice,
entryCost: p.entryCost,
initialStop: p.initialStop,
stop: p.stop,
trailExtreme: p.trailExtreme,
riskAmount: p.riskAmount,
side: p.side as 'long' | 'short',
}));
return { cash: row.cash, positions: pos, cursorTs: row.cursorTs.getTime() };
}
/** Holt fehlende 15m-Candles seit Cursor je Pair; Pair-Fehler überspringen den Rest nicht. */
private async fetchGaps(cursorTs: number): Promise<void> {
const now = Date.now();
this.status.pairErrors = {};
for (const pair of PAIRS) {
try {
const fresh: Candle[] = [];
let endTs: number | undefined;
// rückwärts paginieren bis der Cursor abgedeckt ist (Normalfall: 1 Request)
for (let i = 0; i < 40; i++) {
const batch = await fetchCandles(pair, '15m', 300, endTs);
if (batch.length === 0) break;
fresh.push(...batch);
const oldest = Math.min(...batch.map((c) => c.ts));
if (oldest <= cursorTs) break;
endTs = oldest - 1;
}
const closed = fresh.filter((c) => c.ts + M15 <= now && c.ts > cursorTs);
if (closed.length > 0) await insertCandles(pair, closed);
} catch (err) {
this.status.pairErrors[pair] = err instanceof Error ? err.message : String(err);
}
}
}
private async loadCandles(cursorTs: number): Promise<Map<Pair, Candle[]>> {
const from = Math.floor(cursorTs / H4) * H4 - WARMUP_4H_BARS * H4;
const map = new Map<Pair, Candle[]>();
for (const pair of PAIRS) {
map.set(pair, await getCandles(pair, from));
}
return map;
}
private async persist(prev: LiveState, result: CycleResult): Promise<void> {
await db.transaction(async (tx) => {
const keep = new Set(result.positions.map((p) => p.pair));
for (const p of prev.positions) {
if (!keep.has(p.pair)) await tx.delete(positions).where(eq(positions.pair, p.pair));
}
for (const p of result.positions) {
const row = {
pair: p.pair,
side: p.side,
qty: p.qty,
entryTs: new Date(p.entryTs),
entryPrice: p.entryPrice,
entryCost: p.entryCost,
initialStop: p.initialStop,
stop: p.stop,
trailExtreme: p.trailExtreme,
riskAmount: p.riskAmount,
};
await tx.insert(positions).values(row).onConflictDoUpdate({ target: positions.pair, set: row });
}
if (result.closedTrades.length > 0) {
await tx.insert(paperTrades).values(
result.closedTrades.map((t) => ({
pair: t.pair,
side: t.side,
entryTs: new Date(t.entryTs),
entryPrice: t.entryPrice,
exitTs: new Date(t.exitTs),
exitPrice: t.exitPrice,
qty: t.qty,
pnl: t.pnl,
r: t.r,
exitReason: t.exitReason,
})),
);
}
if (result.decisions.length > 0) {
await tx
.insert(decisionLogs)
.values(
result.decisions.map((d) => ({
pair: d.pair,
barTs: new Date(d.barTs),
signal: d.signal,
blockedBy: d.blockedBy,
close: d.close,
atr: Number.isNaN(d.atr) ? null : d.atr,
adx: Number.isNaN(d.adx) ? null : d.adx,
donchianHigh: Number.isNaN(d.donchianHigh) ? null : d.donchianHigh,
trendEma: Number.isNaN(d.trendEma) ? null : d.trendEma,
})),
)
.onConflictDoNothing();
}
for (const s of result.equitySnapshots) {
const row = { ts: new Date(s.ts), equity: s.equity, cash: s.cash };
await tx.insert(equitySnapshots).values(row).onConflictDoUpdate({ target: equitySnapshots.ts, set: row });
}
await tx
.update(botState)
.set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() })
.where(eq(botState.id, 1));
});
}
/** Füllt price_after_4h/24h/72h in decision_logs, sobald die Candles vorliegen. */
private async backfillOutcomes(): Promise<void> {
const now = Date.now();
const horizons = [
{ col: decisionLogs.priceAfter4h, key: 'priceAfter4h' as const, ms: 4 * 60 * 60 * 1000 },
{ col: decisionLogs.priceAfter24h, key: 'priceAfter24h' as const, ms: 24 * 60 * 60 * 1000 },
{ col: decisionLogs.priceAfter72h, key: 'priceAfter72h' as const, ms: 72 * 60 * 60 * 1000 },
];
for (const h of horizons) {
const due = await db
.select({ id: decisionLogs.id, pair: decisionLogs.pair, barTs: decisionLogs.barTs })
.from(decisionLogs)
.where(and(isNull(h.col), lte(decisionLogs.barTs, new Date(now - H4 - h.ms))))
.limit(200);
for (const d of due) {
// Entscheidung fällt am Bar-Close (barTs + 4h); Ziel = 15m-Close bei +Horizont
const target = d.barTs.getTime() + H4 + h.ms - M15;
const [c] = await db
.select({ close: candles.close })
.from(candles)
.where(and(eq(candles.pair, d.pair), lte(candles.ts, new Date(target))))
.orderBy(dsql`${candles.ts} desc`)
.limit(1);
if (c) await db.update(decisionLogs).set({ [h.key]: c.close }).where(eq(decisionLogs.id, d.id));
}
}
}
}

View File

@@ -0,0 +1,103 @@
import { describe, expect, test } from 'bun:test';
import type { Candle, Pair } from '../types';
import { DEFAULT_PARAMS } from '../strategy/donchian-trend';
import { DEFAULT_RISK } from '../engine/sizing';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { runBacktest } from '../backtest/runner';
import { processCycle, type CycleConfig, type LiveState } from './process-cycle';
const M15 = 15 * 60 * 1000;
const CFG: CycleConfig = { risk: DEFAULT_RISK, exec: DEFAULT_EXEC, params: DEFAULT_PARAMS, maxPositions: 4 };
/**
* Synthetische 15m-Serie: langer Aufwärtstrend (löst Donchian-Breakout + EMA + ADX aus),
* dann scharfer Absturz (löst Trailing-Stop aus), dann erneuter Anstieg.
*/
function syntheticCandles(): Candle[] {
const out: Candle[] = [];
const t0 = Date.UTC(2025, 0, 1);
let price = 100;
const bars = 16 * 320; // 320 4h-Bars
for (let k = 0; k < bars; k++) {
const phase = Math.floor(k / 16);
let drift: number;
if (phase < 240) drift = 0.05; // Warmup + Trend aufwärts
else if (phase < 260) drift = -1.5; // Crash → Stop
else drift = 0.08; // Erholung
const open = price;
price = Math.max(10, price + drift + 0.3 * Math.sin(k / 7));
const close = price;
out.push({
ts: t0 + k * M15,
open,
high: Math.max(open, close) + 0.2,
low: Math.min(open, close) - 0.2,
close,
volume: 1,
});
}
return out;
}
const PAIR: Pair = 'BTC_USDT';
function freshState(candles: Candle[]): LiveState {
return { cash: 1000, positions: [], cursorTs: candles[0].ts - 1 };
}
describe('processCycle', () => {
test('erzeugt Trades und Decisions auf der synthetischen Serie', () => {
const c15 = syntheticCandles();
const res = processCycle(new Map([[PAIR, c15]]), freshState(c15), CFG);
expect(res.closedTrades.length).toBeGreaterThan(0);
expect(res.decisions.length).toBeGreaterThan(100);
expect(res.cursorTs).toBe(c15[c15.length - 1].ts);
});
test('Parität mit Backtest-Runner (identische Trades)', () => {
const c15 = syntheticCandles();
const map = new Map([[PAIR, c15]]);
const live = processCycle(map, freshState(c15), CFG);
const bt = runBacktest(map, {
startCapital: 1000,
risk: DEFAULT_RISK,
exec: DEFAULT_EXEC,
maxPositions: 4,
params: DEFAULT_PARAMS,
tradeFrom: 0,
tradeTo: c15[c15.length - 1].ts + M15,
allowShort: false,
});
const btStops = bt.trades.filter((t) => t.exitReason === 'trailing_stop');
expect(live.closedTrades).toEqual(btStops);
});
test('Split-Äquivalenz: ein Lauf ≡ zwei Läufe mit Cut dazwischen', () => {
const c15 = syntheticCandles();
const map = new Map([[PAIR, c15]]);
const full = processCycle(map, freshState(c15), CFG);
const cut = c15[Math.floor(c15.length * 0.8)].ts;
const firstHalf = new Map([[PAIR, c15.filter((c) => c.ts <= cut)]]);
const r1 = processCycle(firstHalf, freshState(c15), CFG);
const r2 = processCycle(map, { cash: r1.cash, positions: r1.positions, cursorTs: r1.cursorTs }, CFG);
expect(r2.cursorTs).toBe(full.cursorTs);
expect(r2.cash).toBeCloseTo(full.cash, 8);
expect(r2.positions).toEqual(full.positions);
expect([...r1.closedTrades, ...r2.closedTrades]).toEqual(full.closedTrades);
expect([...r1.decisions, ...r2.decisions]).toEqual(full.decisions);
});
test('Idempotenz: zweiter Lauf ohne neue Candles ist ein No-op', () => {
const c15 = syntheticCandles();
const map = new Map([[PAIR, c15]]);
const r1 = processCycle(map, freshState(c15), CFG);
const r2 = processCycle(map, { cash: r1.cash, positions: r1.positions, cursorTs: r1.cursorTs }, CFG);
expect(r2.closedTrades).toEqual([]);
expect(r2.decisions).toEqual([]);
expect(r2.cash).toBe(r1.cash);
expect(r2.cursorTs).toBe(r1.cursorTs);
expect(r2.positions).toEqual(r1.positions);
});
});

View File

@@ -0,0 +1,167 @@
import type { Candle, Pair } from '../types';
import { PAIRS } from '../types';
import { aggregate4h, H4 } from '../market/aggregate';
import { computeIndicators, evaluateAt, type StrategyParams } from '../strategy/donchian-trend';
import { updateChandelier } from '../strategy/chandelier';
import { sizePosition, type RiskConfig } from '../engine/sizing';
import { Portfolio, type ClosedTrade, type ExecConfig, type Position } from '../engine/portfolio';
export interface LiveState {
cash: number;
positions: Position[];
cursorTs: number; // ts der letzten verarbeiteten 15m-Candle
}
export interface CycleConfig {
risk: RiskConfig;
exec: ExecConfig;
params: StrategyParams;
maxPositions: number;
}
export interface Decision {
pair: Pair;
barTs: number; // Start der bewerteten 4h-Bar
signal: 'long' | null;
blockedBy: string | null;
close: number;
atr: number;
adx: number;
donchianHigh: number;
trendEma: number;
}
export interface EquitySnapshot {
ts: number; // 4h-Bucket
equity: number;
cash: number;
}
export interface CycleResult {
cash: number;
positions: Position[];
cursorTs: number;
closedTrades: ClosedTrade[];
decisions: Decision[];
equitySnapshots: EquitySnapshot[];
equity: number;
}
/**
* Verarbeitet alle 15m-Candles mit ts > cursor — identische Semantik wie der
* Backtest-Runner: 4h-Bars eines Pairs werden verarbeitet, sobald dessen erste
* 15m-Candle eines späteren Buckets eintrifft (Chandelier-Update → Entry-Eval),
* danach 15m-Stop-Check. Pure Funktion: gleicher Input → gleiches Ergebnis.
*
* candles15ByPair muss Warmup-Historie VOR dem Cursor enthalten (≥ trendEmaPeriod
* 4h-Bars), sonst blockiert insufficient_data.
*/
export function processCycle(
candles15ByPair: Map<Pair, Candle[]>,
state: LiveState,
cfg: CycleConfig,
): CycleResult {
const portfolio = new Portfolio(state.cash, cfg.exec);
for (const pos of state.positions) portfolio.positions.set(pos.pair, { ...pos });
const decisions: Decision[] = [];
const equitySnapshots: EquitySnapshot[] = [];
const lastClose = new Map<Pair, number>();
const cursorBucket = Math.floor(state.cursorTs / H4) * H4;
const contexts = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => {
const c15 = candles15ByPair.get(pair)!;
const c4h = aggregate4h(c15);
// 4h-Bars vor dem Cursor-Bucket gelten als in früheren Zyklen verarbeitet
let next4h = 0;
while (next4h < c4h.length && c4h[next4h].ts < cursorBucket) next4h++;
// lastClose mit der letzten Candle ≤ Cursor seeden (für Equity offener Positionen)
for (const c of c15) {
if (c.ts > state.cursorTs) break;
lastClose.set(pair, c.close);
}
return { pair, c4h, ind: computeIndicators(c4h, cfg.params), next4h };
});
const byPair = new Map(contexts.map((c) => [c.pair, c]));
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
for (const ctx of contexts) {
for (const candle of candles15ByPair.get(ctx.pair)!) {
if (candle.ts > state.cursorTs) 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));
let cursorTs = state.cursorTs;
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-Bars dieses Pairs
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
const i = ctx.next4h++;
const bar = ctx.c4h[i];
const pos = portfolio.positions.get(pair);
if (pos) {
const next = updateChandelier(
{ highestHigh: pos.trailExtreme, stop: pos.stop },
bar.high,
ctx.ind.atr[i],
cfg.params.atrMultiplier,
);
pos.trailExtreme = next.highestHigh;
pos.stop = next.stop;
}
const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params, false);
const signal = ev.signal === 'long' ? 'long' : null;
let blockedBy: string | null = ev.blockedBy;
if (portfolio.positions.has(pair)) {
blockedBy = 'position_open';
} else if (signal) {
if (portfolio.positions.size >= cfg.maxPositions) {
blockedBy = 'max_positions';
} else {
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, 'long');
blockedBy = s.blockedBy;
if (!s.blockedBy) portfolio.open(pair, bar.ts + H4, ev.close, initialStop, s.qty, s.riskAmount, 'long');
}
}
decisions.push({
pair, barTs: bar.ts, signal, blockedBy,
close: ev.close, atr: ev.atr, adx: ev.adx, donchianHigh: ev.donchianHigh, trendEma: ev.trendEma,
});
}
// 2) Stop-Check auf der 15m-Candle (auch auf der Entry-Candle, wie im Runner)
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);
cursorTs = Math.max(cursorTs, ts);
// 3) Equity-Punkt einmal pro 4h-Bucket
if (bucket !== lastEquityBucket) {
lastEquityBucket = bucket;
equitySnapshots.push({ ts: bucket, equity: portfolio.equity(lastClose), cash: portfolio.cash });
}
}
return {
cash: portfolio.cash,
positions: [...portfolio.positions.values()],
cursorTs,
closedTrades: portfolio.trades,
decisions,
equitySnapshots,
equity: portfolio.equity(lastClose),
};
}

View File

@@ -4,7 +4,7 @@ import { insertCandles, getCoverage } from '../market/candle-store';
import { sql } from '../db/client';
const M15 = 15 * 60 * 1000;
const TARGET_MONTHS = 14;
const TARGET_MONTHS = 36;
const since = Date.now() - TARGET_MONTHS * 30 * 24 * 60 * 60 * 1000;
for (const pair of PAIRS) {

View File

@@ -0,0 +1,109 @@
import { PAIRS, type Candle, type Pair } from '../types';
import { getCandles, getCoverage } from '../market/candle-store';
import { buildWindows, aggregateOos } from '../backtest/walkforward';
import { runRotationBacktest, type RotationConfig } from '../backtest/rotation';
import { computeMetrics } from '../backtest/metrics';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { db, sql } from '../db/client';
import { backtestRuns } from '../db/schema';
// --- Feste A-priori-Parameter (kein Grid, kein Overfitting möglich) ---
const LOOKBACK_BARS = 180; // 30 Tage × 6 Bars/Tag auf 4h-TF
const START_CAPITAL = 1000;
const EXEC = DEFAULT_EXEC;
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());
dataTo = Math.min(dataTo, cov.to.getTime());
console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()}${cov.to.toISOString()})`);
}
console.log(`\nMomentum-Rotation (fix: lookback 30d, weekly, top-1, long-only)`);
console.log(`Walk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`);
const windows = buildWindows(dataFrom, dataTo);
type WindowResult = {
window: { trainFrom: number; trainTo: number; testFrom: number; testTo: number };
trainMetrics: ReturnType<typeof computeMetrics>;
testMetrics: ReturnType<typeof computeMetrics>;
testTrades: import('../engine/portfolio').ClosedTrade[];
testEquityCurve: import('../backtest/metrics').EquityPoint[];
};
const results: WindowResult[] = [];
for (const [wi, w] of windows.entries()) {
const trainCfg: RotationConfig = {
startCapital: START_CAPITAL,
exec: EXEC,
lookbackBars: LOOKBACK_BARS,
tradeFrom: w.trainFrom,
tradeTo: w.trainTo,
};
const testCfg: RotationConfig = {
startCapital: START_CAPITAL,
exec: EXEC,
lookbackBars: LOOKBACK_BARS,
tradeFrom: w.testFrom,
tradeTo: w.testTo,
};
const trainResult = runRotationBacktest(candles15ByPair, trainCfg);
const trainMetrics = computeMetrics(trainResult.trades, trainResult.equityCurve, START_CAPITAL);
const testResult = runRotationBacktest(candles15ByPair, testCfg);
const testMetrics = computeMetrics(testResult.trades, testResult.equityCurve, START_CAPITAL);
results.push({
window: w,
trainMetrics,
testMetrics,
testTrades: testResult.trades,
testEquityCurve: testResult.equityCurve,
});
console.log(
`Fenster ${wi + 1}/${windows.length}: Train-PF ${trainMetrics.profitFactor.toFixed(2)} ` +
`→ Test-PF ${testMetrics.profitFactor.toFixed(2)} bei ${testMetrics.trades} Trades`,
);
}
// OOS-Aggregat (gemeinsame Logik aus walkforward.ts)
const { oosMetrics, oosEquityCurve, gate } = aggregateOos(results, START_CAPITAL);
console.log('\n========== OOS-GESAMTERGEBNIS ==========');
const m = 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 gate.checks) {
console.log(`${c.pass ? '✅' : '❌'} ${c.name}: ${Number.isFinite(c.value) ? c.value.toFixed(2) : c.value}`);
}
console.log(`\n→ GATE ${gate.pass ? 'BESTANDEN' : 'NICHT BESTANDEN'}`);
// Persistenz
await db.insert(backtestRuns).values({
kind: 'rotation-walkforward',
config: { startCapital: START_CAPITAL, exec: EXEC, lookbackBars: LOOKBACK_BARS } as any,
result: {
gate,
oosMetrics,
oosEquityCurve,
windows: results.map((r) => ({
window: r.window,
trainMetrics: r.trainMetrics,
testMetrics: r.testMetrics,
})),
} as any,
});
console.log('Run in backtest_runs gespeichert.');
await sql.end();

View File

@@ -1,6 +1,7 @@
import { PAIRS, type Candle, type Pair } from '../types';
import { getCandles, getCoverage } from '../market/candle-store';
import { runWalkForward } from '../backtest/walkforward';
import { DEFAULT_PARAMS } from '../strategy/donchian-trend';
import { DEFAULT_RISK } from '../engine/sizing';
import { DEFAULT_EXEC } from '../engine/portfolio';
import { db, sql } from '../db/client';
@@ -19,10 +20,16 @@ for (const pair of PAIRS) {
console.log(`${pair}: ${cov.count} Candles (${cov.from.toISOString()}${cov.to.toISOString()})`);
}
const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4 };
const allowShort = process.argv.includes('--shorts');
// --fixed: keine Grid-Suche — fixe A-priori-Parameter (Spec-Defaults). Kein Overfitting
// durch Parameterwahl möglich; Train-Metriken dienen nur noch der Ratio-Diagnose.
const fixed = process.argv.includes('--fixed');
const grid = fixed ? [DEFAULT_PARAMS] : undefined;
const baseCfg = { startCapital: 1000, risk: DEFAULT_RISK, exec: DEFAULT_EXEC, maxPositions: 4, allowShort };
console.log(`Shorts: ${allowShort ? 'AN' : 'AUS'} | Parameter: ${fixed ? 'FIX (Spec-Defaults)' : 'Grid-Suche'}`);
console.log(`\nWalk-Forward über ${((dataTo - dataFrom) / 86400000).toFixed(0)} Tage…\n`);
const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg));
const result = runWalkForward(candles15ByPair, baseCfg, dataFrom, dataTo, (msg) => console.log(msg), grid);
console.log('\n========== OOS-GESAMTERGEBNIS ==========');
const m = result.oosMetrics;

View File

@@ -2,7 +2,8 @@ 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 };
// adxThreshold: 0 — neutralisiert den ADX-Filter in allen bestehenden Tests
const P: StrategyParams = { donchianPeriod: 3, atrPeriod: 3, atrMultiplier: 3, trendEmaPeriod: 5, adxThreshold: 0 };
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 };
@@ -19,17 +20,18 @@ function breakoutSeries(): Candle[] {
test('Long-Signal bei Donchian-Breakout über Trend-EMA', () => {
const c4h = breakoutSeries();
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1);
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);
expect(Number.isNaN(ev.adx)).toBe(false); // ADX[7]=100 (starker Trend)
});
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);
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P);
expect(ev.signal).toBeNull();
expect(ev.blockedBy).toBe('below_donchian');
});
@@ -46,19 +48,19 @@ test('blockiert unter Trend-EMA (Bärenmarkt-Filter)', () => {
s.push(c(75, 76, 74, 75, 6));
// Index 7: Donchian[7] = max(76,76,76) = 76, EMA5 ≈ 80, Close 77 > Donchian aber < EMA
s.push(c(77, 80, 76, 77, 7));
const ev = evaluateAt(s, computeIndicators(s, P), s.length - 1);
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);
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 });
expect(DEFAULT_PARAMS).toEqual({ donchianPeriod: 20, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 200, adxThreshold: 20 });
});
/** Abwärtstrend, letzte Candle bricht unter das 3er-Tief aus. */
@@ -73,7 +75,7 @@ function breakdownSeries(): Candle[] {
test('Short-Signal bei Donchian-Breakdown unter Trend-EMA (allowShort=true)', () => {
const c4h = breakdownSeries();
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, true);
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P, true);
expect(ev.signal).toBe('short');
expect(ev.blockedBy).toBeNull();
expect(ev.donchianLow).toBe(13); // min(low[4..6]) = min(15,14,13) = 13
@@ -81,7 +83,7 @@ test('Short-Signal bei Donchian-Breakdown unter Trend-EMA (allowShort=true)', ()
test('Gleiche Daten mit allowShort=false → kein Short, blockiert', () => {
const c4h = breakdownSeries();
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, false);
const ev = evaluateAt(c4h, computeIndicators(c4h, P), c4h.length - 1, P, false);
expect(ev.signal).toBeNull();
// close=11 <= donchianHigh (whatever it is) → below_donchian
expect(ev.blockedBy).toBe('below_donchian');
@@ -122,9 +124,58 @@ test('Breakdown aber Close > EMA → kein Short (Aufwärtstrend-Filter)', () =>
// 1 Candle: donchianLow[6] = min(low[3..5]) = 49, EMA5 ≈ 37 < 49
// Close 45: < donchianLow(49) aber > EMA5(≈37) → kein Short
t.push(c(45, 50, 44, 45, 6));
const ev = evaluateAt(t, computeIndicators(t, P), t.length - 1, true);
const ev = evaluateAt(t, computeIndicators(t, P), t.length - 1, P, true);
// close(45) < donchianLow(49) aber close(45) > trendEma(≈37) → kein Short
expect(ev.signal).toBeNull();
expect(ev.donchianLow).toBe(49);
expect(ev.trendEma).toBeLessThan(45); // EMA ist unter Close
});
// --- ADX-Filter-Tests ---
//
// Serie: 27 Zickzack-Candles (close ∈ {0,1}, high=close+0.5, low=close0.5)
// + 1 Breakout-Candle (index 27, close=2).
// Params: atrPeriod=14 → ADX-Period=14 → erster valider ADX-Wert an Index 2×141=27.
// ADX[27]=4.07 (deutlich unter 20): Zickzack-Historie hat keinen klaren Trend.
// donchianPeriod=3 → donchianHigh[27]=max(high[24..26])=max(0.5,1.5,0.5)=1.5.
// close=2 > donchianHigh=1.5 ✓ und close=2 > EMA5≈0.93 ✓ → Breakout vorhanden,
// aber ADX=4.07 < threshold=20 → blockedBy='weak_trend'.
function adxFilterSeries(): Candle[] {
const s: Candle[] = [];
// 27 Zickzack-Candles → ADX settled ~4 an Index 27 (dominiert von keiner Richtung)
// c(open, high, low, close, ts)
for (let i = 0; i < 27; i++) {
const cl = i % 2 === 0 ? 0 : 1;
s.push(c(cl, cl + 0.5, cl - 0.5, cl, i));
}
// Breakout-Candle: close=2 > donchianHigh(1.5) und > EMA5(0.93)
s.push(c(2, 2.5, 1.5, 2, 27));
return s;
}
// Hilfsfunktion mit dem passenden Param-Satz für ADX-Filter-Tests
const P_ADX: StrategyParams = { donchianPeriod: 3, atrPeriod: 14, atrMultiplier: 3, trendEmaPeriod: 5, adxThreshold: 20 };
test('ADX unter Schwelle → Breakout blockiert (weak_trend)', () => {
// ADX[27]≈4.07 < adxThreshold=20 → kein Long-Signal trotz Donchian-Breakout
const c4h = adxFilterSeries();
const ind = computeIndicators(c4h, P_ADX);
const ev = evaluateAt(c4h, ind, c4h.length - 1, P_ADX);
// Sanity: Breakout-Bedingungen sind erfüllt (ohne ADX-Filter wäre es 'long')
expect(ev.close).toBeGreaterThan(ev.donchianHigh);
expect(ev.close).toBeGreaterThan(ev.trendEma);
expect(ev.adx).toBeLessThan(20); // ADX≈4.07
expect(ev.signal).toBeNull();
expect(ev.blockedBy).toBe('weak_trend');
});
test('ADX-Filter deaktiviert (threshold=0) → gleicher Breakout ergibt Long-Signal', () => {
// Gleiche Serie, adxThreshold=0 → ADX-Filter immer passiert → 'long'
const c4h = adxFilterSeries();
const P_ZERO: StrategyParams = { ...P_ADX, adxThreshold: 0 };
const ind = computeIndicators(c4h, P_ZERO);
const ev = evaluateAt(c4h, ind, c4h.length - 1, P_ZERO);
expect(ev.signal).toBe('long');
expect(ev.blockedBy).toBeNull();
});

View File

@@ -1,6 +1,7 @@
import type { Candle } from '../types';
import { ema } from '../indicators/ema';
import { atr } from '../indicators/atr';
import { adx } from '../indicators/adx';
import { donchianHigh, donchianLow } from '../indicators/donchian';
export interface StrategyParams {
@@ -8,6 +9,7 @@ export interface StrategyParams {
atrPeriod: number;
atrMultiplier: number;
trendEmaPeriod: number;
adxThreshold: number;
}
export const DEFAULT_PARAMS: StrategyParams = {
@@ -15,6 +17,7 @@ export const DEFAULT_PARAMS: StrategyParams = {
atrPeriod: 14,
atrMultiplier: 3,
trendEmaPeriod: 200,
adxThreshold: 20,
};
export interface IndicatorSet {
@@ -22,13 +25,15 @@ export interface IndicatorSet {
donchianHigh: number[];
donchianLow: number[];
atr: number[];
adx: number[];
}
export interface Evaluation {
signal: 'long' | 'short' | null;
blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | null;
blockedBy: 'insufficient_data' | 'below_donchian' | 'below_trend_ema' | 'weak_trend' | null;
close: number;
atr: number;
adx: number;
donchianHigh: number;
donchianLow: number;
trendEma: number;
@@ -41,14 +46,21 @@ export function computeIndicators(c4h: Candle[], p: StrategyParams): IndicatorSe
donchianHigh: donchianHigh(c4h, p.donchianPeriod),
donchianLow: donchianLow(c4h, p.donchianPeriod),
atr: atr(c4h, p.atrPeriod),
adx: adx(c4h, p.atrPeriod),
};
}
/** Bewertet die (abgeschlossene) 4h-Candle an Index i. */
export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowShort = false): Evaluation {
export function evaluateAt(
c4h: Candle[],
ind: IndicatorSet,
i: number,
p: { adxThreshold: number },
allowShort = false,
): Evaluation {
const close = c4h[i]?.close ?? NaN;
const base = {
close, atr: ind.atr[i], donchianHigh: ind.donchianHigh[i],
close, atr: ind.atr[i], adx: ind.adx[i], donchianHigh: ind.donchianHigh[i],
donchianLow: ind.donchianLow[i], trendEma: ind.trendEma[i],
};
if (
@@ -57,17 +69,20 @@ export function evaluateAt(c4h: Candle[], ind: IndicatorSet, i: number, allowSho
Number.isNaN(ind.trendEma[i]) ||
Number.isNaN(ind.donchianHigh[i]) ||
Number.isNaN(ind.donchianLow[i]) ||
Number.isNaN(ind.atr[i])
Number.isNaN(ind.atr[i]) ||
Number.isNaN(ind.adx[i])
) {
return { signal: null, blockedBy: 'insufficient_data', ...base };
}
if (close > ind.donchianHigh[i] && close > ind.trendEma[i]) {
if (ind.adx[i] < p.adxThreshold) return { signal: null, blockedBy: 'weak_trend', ...base };
return { signal: 'long', blockedBy: null, ...base };
}
if (allowShort && close < ind.donchianLow[i] && close < ind.trendEma[i]) {
if (ind.adx[i] < p.adxThreshold) return { signal: null, blockedBy: 'weak_trend', ...base };
return { signal: 'short', blockedBy: null, ...base };
}
// Blocked reasons from the long perspective
// Blocked reasons from the long perspective (no breakout — ADX irrelevant here)
if (close <= ind.donchianHigh[i]) return { signal: null, blockedBy: 'below_donchian', ...base };
return { signal: null, blockedBy: 'below_trend_ema', ...base };
}