Compare commits
10 Commits
0e1b477e27
...
29846e82a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 29846e82a7 | |||
| c5d71bba74 | |||
| 69a0a7bee3 | |||
| cd6553c170 | |||
| 29000a2bba | |||
| b7e81374f1 | |||
| 8e838c4a66 | |||
| cdf5b133a9 | |||
| c07a34e671 | |||
| 736db184ab |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
.git
|
||||
node_modules
|
||||
docs
|
||||
.env
|
||||
*.md
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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"]
|
||||
127
docs/specs/2026-06-10-live-paper-engine-design.md
Normal file
127
docs/specs/2026-06-10-live-paper-engine-design.md
Normal 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.3–4.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.
|
||||
45
docs/walkforward-ergebnisse-2026-06-09.md
Normal file
45
docs/walkforward-ergebnisse-2026-06-09.md
Normal 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.
|
||||
58
drizzle/0001_certain_omega_red.sql
Normal file
58
drizzle/0001_certain_omega_red.sql
Normal 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");
|
||||
466
drizzle/meta/0001_snapshot.json
Normal file
466
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
166
public/index.html
Normal 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
163
src/server/api/server.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
241
src/server/backtest/rotation.test.ts
Normal file
241
src/server/backtest/rotation.test.ts
Normal 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));
|
||||
});
|
||||
172
src/server/backtest/rotation.ts
Normal file
172
src/server/backtest/rotation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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*(1−slippage) = 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);
|
||||
});
|
||||
|
||||
@@ -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,15 +70,26 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
// 1a) Trailing-Stop der offenen Position nachziehen
|
||||
const pos = portfolio.positions.get(pair);
|
||||
if (pos) {
|
||||
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.highestHigh, stop: pos.stop },
|
||||
{ highestHigh: pos.trailExtreme, stop: pos.stop },
|
||||
bar.high,
|
||||
ctx.ind.atr[i],
|
||||
cfg.params.atrMultiplier,
|
||||
);
|
||||
pos.highestHigh = next.highestHigh;
|
||||
pos.trailExtreme = next.highestHigh;
|
||||
pos.stop = next.stop;
|
||||
}
|
||||
}
|
||||
|
||||
// 1b) Entry-Evaluation
|
||||
const barCloseTs = bar.ts + H4;
|
||||
@@ -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,10 +119,21 @@ 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) {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
13
src/server/index.ts
Normal 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);
|
||||
91
src/server/indicators/adx.test.ts
Normal file
91
src/server/indicators/adx.test.ts
Normal 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×period−1 ---
|
||||
|
||||
test('ADX: NaN vor Warmup (Index < 2×period−1)', () => {
|
||||
// period=3 → erste valide Stelle: Index 5 (= 2×3−1)
|
||||
// Starker Aufwärtstrend: high=close, low=close−0.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, |high−prevClose|, |low−prevClose|) = max(0.5, 1, 0.5) = 1
|
||||
// (ab Bar 1: high[i]=i+1, prevClose=i → |high−prevClose|=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 = close−0.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=close−0.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);
|
||||
}
|
||||
});
|
||||
44
src/server/indicators/adx.ts
Normal file
44
src/server/indicators/adx.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { Candle } from '../types';
|
||||
|
||||
/** Wilder ADX: +DM/−DM → Wilder-geglättet → DI± → DX → ADX. NaN vor Index 2×period−1. */
|
||||
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
226
src/server/live/engine.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/server/live/process-cycle.test.ts
Normal file
103
src/server/live/process-cycle.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
167
src/server/live/process-cycle.ts
Normal file
167
src/server/live/process-cycle.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
109
src/server/scripts/rotation-walkforward.ts
Normal file
109
src/server/scripts/rotation-walkforward.ts
Normal 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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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=close−0.5)
|
||||
// + 1 Breakout-Candle (index 27, close=2).
|
||||
// Params: atrPeriod=14 → ADX-Period=14 → erster valider ADX-Wert an Index 2×14−1=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();
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user