Compare commits
48 Commits
0e1b477e27
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 828ab274d6 | |||
| 5c72e4269c | |||
| bd6334e9bd | |||
| 08169081d0 | |||
| b03c03ee62 | |||
| 0696565840 | |||
| c3114db05e | |||
| 5f634452c3 | |||
| 52cd31bf42 | |||
| ed2fdf6c0a | |||
| 89b041eb6e | |||
| f8cb424719 | |||
| a13bca596c | |||
| b617740a40 | |||
| 22c84187b2 | |||
| 204c0541a7 | |||
| a297d83849 | |||
| 7f1589a7df | |||
| 6c59164e6b | |||
| a2e4362444 | |||
| f0d4b6d566 | |||
| 71d07659e3 | |||
| 917bcad8c3 | |||
| e926fa0988 | |||
| 315f6ddf00 | |||
| 0b5a9448b5 | |||
| 13cf694673 | |||
| 43b5ed10bd | |||
| 713f4efc3c | |||
| 896a29bd04 | |||
| 7b8ef01e83 | |||
| 2bd566ce5e | |||
| 021049b259 | |||
| f754b91acd | |||
| 3d16b76f23 | |||
| b5dd953afc | |||
| 2dcab7f24d | |||
| b272b07044 | |||
| 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
|
||||
21
CLAUDE.md
Normal file
21
CLAUDE.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# trade-kuns
|
||||
|
||||
Drei Paper-Engines in einem Prozess (**paper-only — keine Order-Ausführung**):
|
||||
1. **Trend-Bot** (BTC/ETH/SOL/XRP_USDT): Donchian-20-Breakout auf 4h, EMA-200 + ADX-20-Filter, Chandelier-Trailing-Stop (3×ATR), long-only, fixe Parameter. State: `bot_state` id=1.
|
||||
2. **GridBot** (nur XRP): No-Stop-ATR-Grid — 8 Levels, Spacing 3×ATR(4h), nie Verlust-Verkäufe, verlustfreies Re-Center bei leerem Grid außerhalb der Range. State: `bot_state` id=2, `grid_state`/`grid_lots`. Spec/Validierung: `docs/walkforward-grid-2026-06-10.md`.
|
||||
3. **Trump-Copy** (TRUMP_PAIRS inkl. LINK/AAVE/ONDO/ENA/SUI/SEI): Event-Copy — on-chain Käufe der WLFI-Watchlist (`signals/watchlist.ts`, öffentl. ETH-RPC) + Truth-Social-Erwähnungen (trumpstruth-RSS), Buy am nächsten 15m-Open, Zeit-Exit nach 24h (Event-Study-Entscheid), kein Stop. State: `bot_state` id=3, `trump_events`/`trump_positions`/`trump_signal_state`. Spec: `docs/specs/2026-06-12-trump-copy-strategy-design.md` · Study: `docs/event-study-trump-2026-06-12.md`. Backfill: `bun run trump:backfill` (Achtung: History danach als consumed markieren!) · Study: `bun run trump:study`.
|
||||
|
||||
## Stack & Befehle
|
||||
- Bun 1.3 + TypeScript, Drizzle (Postgres), Zod. Tests collocated (`*.test.ts`).
|
||||
- `bun test` · `bun run start` (Migration + Server) · `bun run backfill` · `bun run walkforward --fixed` · `bun run db:generate` / `db:migrate`
|
||||
- Env: `DATABASE_URL` (lokal: `localhost:54320/tradekuns`, siehe `.env`), `PORT` (default 8080)
|
||||
|
||||
## Architektur
|
||||
- **Ein Code-Pfad:** `live/process-cycle.ts` (Live) und `backtest/runner.ts` (Backtest) nutzen dieselben puren Funktionen (`strategy/`, `engine/`, `indicators/`) mit identischer Semantik — Paritätstest in `process-cycle.test.ts` erzwingt das. Änderungen an Entry/Exit/Sizing immer in den geteilten Modulen, nie in Runner oder Live-Loop einzeln.
|
||||
- Live-State lebt in der DB (`bot_state.cursor_ts` = letzte verarbeitete 15m-Candle); der 5-min-Loop ist cursor-idempotent, Restart-Recovery gratis.
|
||||
- Specs: `docs/specs/` · Walk-Forward-Ergebnisse: `docs/walkforward-ergebnisse-2026-06-09.md` (Gate nicht bestanden → bewusster Paper-Probelauf, User-Entscheidung 2026-06-10)
|
||||
|
||||
## Deployment
|
||||
- **https://trading.kuns.dev** (Alias: trade.kuns.dev) · Coolify-App-UUID `j7xbvfezctcxmeuon6gh6v5z` · Port 8080
|
||||
- Redeploy: `redeploy j7xbvfezctcxmeuon6gh6v5z "msg"`
|
||||
- DB im Container: `l8kogcggsc80sgcgk8kswww4:5432` (shared-postgres über Coolify-Netz; `connect_to_docker_network=true`)
|
||||
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"]
|
||||
51
docs/event-study-trump-2026-06-12.md
Normal file
51
docs/event-study-trump-2026-06-12.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Event-Study Trump-Copy — 2026-06-12
|
||||
|
||||
## Quelle: onchain
|
||||
|
||||
| Horizont | n | Mean | Median | Hit-Rate |
|
||||
|---|---|---|---|---|
|
||||
| 24h | 83 | 2.10% | 0.09% | 55% |
|
||||
| 48h | 83 | 2.38% | -0.98% | 34% |
|
||||
| 60h | 83 | 0.11% | -3.54% | 30% |
|
||||
| 72h | 83 | 0.01% | -4.91% | 36% |
|
||||
| 120h | 83 | -0.45% | -3.51% | 25% |
|
||||
|
||||
## Quelle: truth
|
||||
|
||||
| Horizont | n | Mean | Median | Hit-Rate |
|
||||
|---|---|---|---|---|
|
||||
| 24h | 1 | -2.19% | -2.19% | 0% |
|
||||
| 48h | 1 | -3.13% | -3.13% | 0% |
|
||||
| 60h | 1 | -3.23% | -3.23% | 0% |
|
||||
| 72h | 1 | -3.53% | -3.53% | 0% |
|
||||
| 120h | 1 | -3.33% | -3.33% | 0% |
|
||||
|
||||
## Baselines (unbedingter Mean-Forward-Return, gleiche Kosten)
|
||||
|
||||
- ETH_USDT: 24h -0.26% · 48h -0.21% · 60h -0.19% · 72h -0.17% · 120h -0.08%
|
||||
- AAVE_USDT: 24h -0.19% · 48h -0.07% · 60h -0.02% · 72h 0.03% · 120h 0.22%
|
||||
- LINK_USDT: 24h -0.18% · 48h -0.06% · 60h -0.01% · 72h 0.05% · 120h 0.28%
|
||||
- ENA_USDT: 24h -0.38% · 48h -0.49% · 60h -0.55% · 72h -0.61% · 120h -0.85%
|
||||
- ONDO_USDT: 24h -0.25% · 48h -0.24% · 60h -0.23% · 72h -0.22% · 120h -0.17%
|
||||
- BTC_USDT: 24h -0.20% · 48h -0.11% · 60h -0.06% · 72h -0.02% · 120h 0.17%
|
||||
## Cluster-Dedupe (Engine-Realität: max. 1 Position je Pair)
|
||||
|
||||
Die 83 on-chain Events kommen in Kaufwellen (z. B. 20.01.2025: >30 Transfers an einem Tag).
|
||||
Die Engine kann je Pair nur eine Position halten — relevante Statistik ist daher
|
||||
„erstes Event je Instrument, solange keine Position offen wäre" (Dedupe = Haltedauer):
|
||||
|
||||
| Haltedauer | n | Mean | Median | Hit-Rate |
|
||||
|---|---|---|---|---|
|
||||
| 24h | 20 | +3.10% | +1.08% | 65% |
|
||||
| 48h | 18 | +3.03% | +0.92% | 50% |
|
||||
| 60h | 18 | +1.29% | −0.83% | 39% |
|
||||
| 72h | 18 | +0.21% | −2.53% | 39% |
|
||||
|
||||
## Entscheid
|
||||
|
||||
**holdHours = 24.** Bester Mean-minus-Baseline-Horizont in beiden Auswertungen, und als
|
||||
einziger Horizont auch in Median und Hit-Rate robust positiv (kein reiner Ausreißer-Effekt).
|
||||
Die ursprüngliche „2–3 Tage"-Intuition ist empirisch unterlegen: ab 60h ist der Median klar
|
||||
negativ. Truth-Quelle: n=1 — keine Aussage möglich, läuft als getaggter Beifang im Paper-Betrieb mit.
|
||||
Vorbehalte: n=20 ist klein, Zeitraum Dez 2024–Jan 2026 von wenigen Markt-Phasen geprägt —
|
||||
indikativ, nicht signifikant. Der Paper-Lauf ist der eigentliche Test (Spec §6).
|
||||
1599
docs/plans/2026-06-12-trump-copy-strategy.md
Normal file
1599
docs/plans/2026-06-12-trump-copy-strategy.md
Normal file
File diff suppressed because it is too large
Load Diff
38
docs/specs/2026-06-10-grid-bot-design.md
Normal file
38
docs/specs/2026-06-10-grid-bot-design.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# trade-kuns — ATR-GridBot mit Regime-Filter (Design)
|
||||
|
||||
**Datum:** 2026-06-10
|
||||
**Status:** Umsetzung Phase Backtest (User-Entscheidung: Walk-Forward-Gate vor Paper-Lauf)
|
||||
**Lehren aus krypto-kuns-GridBot v1:** Spacing zu eng → Fees fraßen Profit · Level-Chasing/Rebalance-Flapping · kein Regime-Bewusstsein (kaufte in fallende Messer)
|
||||
|
||||
## 1. Strategie (long-only Spot, je Pair unabhängig)
|
||||
|
||||
**Aktivierung** (auf 4h-Close, kein aktives Grid):
|
||||
- ADX(14, 4h) < `adxMax` (Seitwärtsregime — komplementär zum Trend-Bot, der ADX ≥ 20 verlangt)
|
||||
- Center `C` = Close, Spacing `S` = `spacingAtrMult` × ATR(14, 4h) — **eingefroren** für die Grid-Lebensdauer (kein Chasing)
|
||||
- `N` Buy-Levels bei C − k·S (k = 1…N), Budget je Level = (Equity / 4 Pairs) / N; unter 10 USDT keine Aktivierung
|
||||
|
||||
**Fills** (auf 15m):
|
||||
- Sells zuerst, dann Buys — ein im selben 15m-Bar gekaufter Lot kann nicht im selben Bar verkaufen (pessimistisch, keine Intrabar-Reihenfolge-Annahme)
|
||||
- Buy: Low ≤ Level-Preis und Level frei → Fill zum Level-Preis (+ Slippage + Fee)
|
||||
- Take-Profit: High ≥ Level-Preis + S → Verkauf genau ein Spacing über dem Einstand; Level wird wieder frei (Re-Buy beim nächsten Dip)
|
||||
|
||||
**Deaktivierung** (auf 4h-Close, alle Lots werden zum Close glattgestellt, `grid_stop`):
|
||||
- Range-Breakdown: Close < C − (N+1)·S (harter Stop des gesamten Grids)
|
||||
- Range-Ausbruch oben: Close > C + (N+1)·S (Re-Center beim nächsten Aktivierungs-Check)
|
||||
- Trend setzt ein: ADX ≥ `adxMax` + 5 (Hysterese gegen Flapping)
|
||||
|
||||
Danach ist Re-Aktivierung an jedem späteren 4h-Close möglich (neues Center).
|
||||
|
||||
**Fixe A-priori-Parameter** (keine Grid-Suche — die hat beim Trend-Bot 2.58× Overfitting erzeugt):
|
||||
`spacingAtrMult` 1.0 · `gridLevels` 4 · `adxMax` 20 · ATR/ADX-Periode 14 · Fees 0.1 % + 5 bps Slippage je Seite (4h-ATR ≈ 1–3 % → Spacing schlägt die ~0.3 % Round-Trip-Kosten strukturell)
|
||||
|
||||
## 2. Umsetzung
|
||||
|
||||
- `src/server/backtest/grid.ts` — `runGridBacktest(candles15ByPair, cfg): BacktestResult`. Eigener Lot-Ledger (mehrere Lots je Pair — `Portfolio` kann nur eine Position je Pair), identische Fee/Slippage-Mathematik, R-Multiple gegen Grid-Stop-Distanz. 15m-Timeline + 4h-Erkennung wie `runner.ts`.
|
||||
- `exitReason` erweitert um `'grid_tp' | 'grid_stop'`.
|
||||
- `src/server/scripts/grid-walkforward.ts` — wie `rotation-walkforward.ts`: fixe Params, `buildWindows` + `aggregateOos` + Gate, Persistenz in `backtest_runs` (kind `grid-walkforward`).
|
||||
- **Gate unverändert** (§5 der Haupt-Spec). Besteht das Grid das Gate, folgt die Paper-Integration als zweite Engine; fällt es durch, ist das ein legitimes Ergebnis.
|
||||
|
||||
## 3. Tests
|
||||
|
||||
Aktivierung nur bei ADX < adxMax · Fill bei Low-Touch · TP bei High-Touch (nicht im Entry-Bar) · Breakdown liquidiert alles · Oszillation zwischen zwei Levels ist nach Fees profitabel bei S = 1×ATR · Determinismus.
|
||||
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.
|
||||
52
docs/specs/2026-06-12-trump-copy-strategy-design.md
Normal file
52
docs/specs/2026-06-12-trump-copy-strategy-design.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# trade-kuns — Trump-Copy-Strategie (Design)
|
||||
|
||||
**Datum:** 2026-06-12
|
||||
**Status:** Entwurf — Umsetzung in zwei Phasen: (1) Event-Backfill + Event-Study light, (2) dritte Paper-Engine
|
||||
**Hypothese:** Öffentlich sichtbare „Trump kauft"-Ereignisse (on-chain Wallet-Käufe, Coin-Erwähnungen auf Truth Social) erzeugen kurzfristigen Kaufdruck. Nachkaufen + fixe Haltedauer (~2–3 Tage) + Market-Sell fängt den Move ein. Kein Walk-Forward-Gate (zu wenig Events für Statistik), aber Event-Study vor Paper-Lauf — bewusste User-Entscheidung 2026-06-12, analog zum GridBot-Probelauf.
|
||||
|
||||
## 1. Signalquellen (beide ohne Account, am 2026-06-12 verifiziert)
|
||||
|
||||
**On-chain (Quelle `onchain`, hartes Kauf-Signal):**
|
||||
- Kuratierte Watchlist Trump-assoziierter Ethereum-Wallets (World Liberty Financial etc.) in `src/server/signals/watchlist.ts`. Aufnahme-Kriterium: öffentlich dokumentierte Attribution (Etherscan/Arkham-Label oder Presse mit Tx-Beleg); jede Adresse mit Quellen-Kommentar. Befüllung ist Implementierungs-Recherche.
|
||||
- Erkennung: `eth_getLogs` (ERC-20 `Transfer`, `to` ∈ Watchlist) über öffentlichen RPC — primär `ethereum-rpc.publicnode.com`, Fallback-Liste in Config. Block-Cursor in DB, Chunks à max. 5 000 Blöcke (Limit öffentlicher RPCs).
|
||||
- **Spam-Schutz:** Jeder kann beliebige Tokens an die Wallets senden. Nur Tokens aus dem kuratierten Mapping (§2) zählen, zusätzlich Mindest-Notional **50 000 USD** (Menge × letzter 4h-Close des gemappten Instruments).
|
||||
|
||||
**Truth Social (Quelle `truth`, weiches Hype-Signal):**
|
||||
- RSS-Feed `https://trumpstruth.org/feed` (Archiv-Mirror, 100 neueste Posts, live). Polling im 5-min-Loop mit `If-Modified-Since`.
|
||||
- Signal: Post-Text matcht Ticker oder Coin-Name aus dem Mapping (case-insensitive, Wortgrenzen; „Bitcoin"/„BTC", „Ethereum"/„ETH" …). Kein Sentiment-NLP — Erwähnung = Event. Dedupe: max. ein Truth-Event je Coin pro 72 h.
|
||||
- Events tragen ihre Quelle, damit die Auswertung später `onchain` vs. `truth` getrennt beurteilen kann.
|
||||
|
||||
## 2. Token-Mapping (kuratierte Config)
|
||||
|
||||
`token → Crypto.com-Instrument`: WBTC→BTC_USDT · WETH/ETH→ETH_USDT · LINK→LINK_USDT · AAVE→AAVE_USDT · ONDO→ONDO_USDT · ENA→ENA_USDT · SUI→SUI_USDT · SEI→SEI_USDT · XRP→XRP_USDT · SOL→SOL_USDT. Nicht handelbar (z. B. TRX, MOVE — kein USDT-Paar, verifiziert 2026-06-12): Event wird mit `instrument = null` persistiert (Auswertung), erzeugt aber keinen Trade. Erwähnungs-Keywords je Token stehen am Mapping-Eintrag.
|
||||
|
||||
## 3. Datenmodell
|
||||
|
||||
Tabelle `trump_events`: `id` · `source` (`onchain`|`truth`) · `token` · `instrument` (nullable) · `event_ts` · `ref` (Tx-Hash bzw. Post-URL) · `notional_usd` (nullable, nur onchain) · `consumed_at` (nullable) · `created_at`. Unique `(source, ref, token)` — Idempotenz bei Restart/Re-Scan. Poller-Cursor (letzter gescannter Block, letzter RSS-Timestamp) in `bot_state` id=3-State.
|
||||
|
||||
## 4. Strategie (pur, `src/server/strategy/trump.ts`)
|
||||
|
||||
- **Entry:** Unverbrauchtes Event mit handelbarem Instrument, `event_ts` ≤ aktuelle 15m-Candle → Buy zum nächsten 15m-Open (+ Fee 0.1 % + 5 bps Slippage wie bestehende Engines). Sizing: **20 % des Engine-Equity** je Position, max. **5 offene Positionen**, max. **1 je Instrument**. Event wird beim Verarbeiten als `consumed` markiert — auch wenn kein Slot/Instrument belegt war (verfällt, kein Nachholen; deterministisch).
|
||||
- **Exit:** Zeit-Exit nach `holdHours` (Default **60 h**; finaler Wert kommt aus der Event-Study §6) zum ersten 15m-Close ≥ Entry + holdHours. **Kein Stop, kein Take-Profit** — konsequent „kaufen, halten, verkaufen".
|
||||
- Startkapital 10 000 USDT (paper), getrennt von Engine 1/2.
|
||||
|
||||
## 5. Integration (Ein-Code-Pfad wie bisher)
|
||||
|
||||
- Dritte Engine: `bot_state` id=3, gleicher 5-min-Loop, cursor-idempotent. Zyklus: (a) Poller schreiben neue Events in `trump_events`, (b) `process-cycle` verarbeitet 15m-Candles + Events durch die pure Strategie. Poller-Fehler (RPC/RSS down, Timeout) sind non-fatal: loggen, nächster Zyklus versucht erneut — Events gehen dank Block-Cursor/Dedupe nicht verloren.
|
||||
- Backtest-Runner `src/server/backtest/trump.ts` replayed historische Events + Candles durch dieselben puren Funktionen; Paritätstest Live ↔ Backtest wie bei den anderen Engines.
|
||||
- Candle-Backfill für neue Instrumente (LINK, AAVE, ONDO, ENA, SUI, SEI) über bestehenden `backfill`-Pfad.
|
||||
- Dashboard: dritter Tab „Trump" — Event-Liste (Quelle, Coin, Zeit, Link auf Tx/Post) + Trades/Equity wie gehabt.
|
||||
|
||||
## 6. Phase 1: Backfill + Event-Study light (vor Paper-Start)
|
||||
|
||||
- `src/server/scripts/trump-backfill.ts`: rekonstruiert on-chain Events historisch (getLogs in Chunks ab Wallet-Erstellung); Truth-Historie best effort über trumpstruth-Archivseiten — Lücken sind akzeptiert und werden im Ergebnis ausgewiesen.
|
||||
- `src/server/scripts/trump-event-study.ts`: je Event Forward-Return nach 24/48/60/72/120 h (inkl. Fees/Slippage) gegen Baseline (gleicher Coin, gleicher Zeitraum, zufällige Entries). Getrennt nach Quelle. Ergebnis: `docs/event-study-trump-<datum>.md`.
|
||||
- **Entscheidung danach:** `holdHours` aus dem besten Horizont; ist der Mittelwert beider Quellen nach Kosten negativ, geht die Engine trotzdem als bewusster Paper-Probelauf live (User-Entscheidung), aber das Ergebnis steht im Doc.
|
||||
|
||||
## 7. Risiken
|
||||
|
||||
Öffentliche RPCs drosseln (→ Fallback-Liste, Chunking) · trumpstruth ist ein Dritt-Mirror und kann verschwinden (→ Quelle isoliert, Engine läuft mit onchain weiter) · wenige historische Events → Event-Study nur indikativ, der Paper-Lauf ist der eigentliche Test · Watchlist-Kuration ist manuell (neue Trump-Wallets werden nicht automatisch erkannt) · Hype-Signal kann strukturell zu spät sein (Markt reagiert in Minuten, wir auf 15m-Open — bewusst akzeptiert, misst die Study mit).
|
||||
|
||||
## 8. Tests
|
||||
|
||||
getLogs-Decoding + Watchlist-Filter + Min-Notional · RSS-Parsing + Keyword-Match (Wortgrenzen, kein „SEI" in „seitwärts") · Dedupe (unique ref, 72h-Fenster) · Entry am nächsten 15m-Open, Exit exakt nach holdHours · Slot-/Instrument-Limits · Event-Verfall ohne freien Slot · Paritätstest Live ↔ Backtest · Determinismus.
|
||||
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.
|
||||
77
docs/walkforward-grid-2026-06-10.md
Normal file
77
docs/walkforward-grid-2026-06-10.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Walk-Forward-Ergebnisse: ATR-GridBot — 2026-06-10
|
||||
|
||||
**Daten:** 103 799 15m-Candles/Pair, 2023-06-24 → 2026-06-09 (3 Jahre), 32 Fenster (Train 120d / Test 30d).
|
||||
**Strategie:** ATR-Grid mit ADX-Regime-Filter, long-only (Design: `docs/specs/2026-06-10-grid-bot-design.md`).
|
||||
**Methodik:** fixe A-priori-Parameter je Variante, kein Grid-Search. CLI: `bun run grid [--spacing X --levels N --adx Y]`. Runs in `backtest_runs` (kind `grid-walkforward`).
|
||||
|
||||
| Variante | OOS-PF | Trades | WinRate | MaxDD | Overfit-Ratio | Gate |
|
||||
|---|---|---|---|---|---|---|
|
||||
| A: spacing 1.0×ATR, 4 Levels, ADX<20 | 0.87 | 1002 | 64.6 % | 18.4 % | 1.42 | ❌ PF + Fenster |
|
||||
| B: spacing 1.5×ATR | 1.03 | 514 | 56.0 % | 10.6 % | 1.53 | ❌ PF + Fenster |
|
||||
| C: ADX<15 (strenger) | 0.94 | 425 | 63.3 % | 6.5 % | 1.24 | ❌ PF + Fenster |
|
||||
|
||||
## Nachtrag: kürzere Timeframes (User-Frage „wird das auf kürzeren Einheiten besser?")
|
||||
|
||||
Entscheidungs-Timeframe parametrisiert (`--tf` Minuten; ATR/ADX/Aktivierung auf 1h bzw. 15m statt 4h):
|
||||
|
||||
| Variante | OOS-PF | Trades | WinRate | MaxDD | Gate |
|
||||
|---|---|---|---|---|---|
|
||||
| D: tf 1h, spacing 1.0×ATR | 0.59 | 4410 | 63.9 % | 57.1 % | ❌ |
|
||||
| E: tf 1h, spacing 1.5×ATR | 0.64 | 2246 | 54.3 % | 37.6 % | ❌ |
|
||||
| F: tf 15m, spacing 1.0×ATR | 0.29 | 19 783 | 47.1 % | **97.2 %** | ❌ |
|
||||
| G: tf 15m, spacing 2.0×ATR | 0.47 | 6259 | 44.7 % | 67.8 % | ❌ |
|
||||
|
||||
**Monoton schlechter, je kürzer der Timeframe** — exakt die Fee-Mathematik: ATR(1h) ≈ ⅓, ATR(15m) ≈ ⅙ von ATR(4h) → das Spacing schrumpft auf die Größenordnung der 0.3 % Round-Trip-Kosten, jeder TP verdient fast nichts, die Breakdown-Verluste bleiben gleich groß. tf 15m ist Totalverlust (MaxDD 97 %). Das war auch der Todesmechanismus des krypto-kuns-v1-Bots (1–15-min-Signale).
|
||||
|
||||
## Nachtrag 2: XRP-Datenanalyse + No-Stop-Grid (User-Ziel „good working GridBot")
|
||||
|
||||
**XRP-Historie (3 Jahre, 4h):** Nur **1 von 106** rollierenden 30d-Fenstern ist eine enge
|
||||
Range (<15 % Spanne) — XRP „ranged" im klassischen Sinn praktisch nie. Es oszilliert
|
||||
aber in **breiten Bändern** über lange Strecken (2023-Q4–2024-Q3: 0.38–0.75;
|
||||
2025: 1.6–3.7). 25 % der Fenster fallen >10 %, schlimmste 30d: −32 % (Jan 2026).
|
||||
ATR%(4h) median 1.4–3 %. → Engmaschige Grids mit hartem Stop sterben an den Rändern;
|
||||
wenn Grid, dann **breit + ohne Verlust-Verkäufe**.
|
||||
|
||||
**No-Stop-Variante** (`--no-stop`: Lots werden nie mit Verlust verkauft, nur TP;
|
||||
leeres Grid re-centert verlustfrei; `--pair` für Einzel-Pair):
|
||||
|
||||
| Variante | OOS-PF | Trades | WinRate | MaxDD | Gate |
|
||||
|---|---|---|---|---|---|
|
||||
| H: no-stop, 2×ATR, 6 Levels, XRP only | 2.00 | 195 | 80.0 % | 31.6 % | ❌ DD + Fenster + Ratio |
|
||||
| I: no-stop, 2×ATR, 6 Levels, 4 Pairs | 1.33 | 719 | 77.6 % | 31.9 % | ❌ DD + Fenster + Ratio |
|
||||
| J: no-stop, 3×ATR, 8 Levels, XRP only | **2.74** | 104 | 77.9 % | **17.5 %** | ❌ Fenster + Ratio |
|
||||
|
||||
**Volldurchlauf ohne Fenster-Artefakte** (J, 3 Jahre am Stück): **+49 %** (1000 → 1491),
|
||||
MaxDD **10.2 %**, PF 4.18, 89 % WinRate — aber nur 27 Trades. Buy&Hold XRP: +130 %
|
||||
bei −71 % Drawdown. Risk-adjusted schlägt das Grid Buy&Hold deutlich.
|
||||
|
||||
**Ehrliche Einordnung:**
|
||||
- Erste Grid-Variante mit echtem Edge-Signal — aber Params J wurden nach Sichtung
|
||||
von H/I gewählt (Selektionsrisiko), und 27 Trades/3J sind statistisch dünn.
|
||||
- Der Worst-Window-Fail ist teils Artefakt (30d-Fenster zwangsliquidieren Inventar
|
||||
am Fensterende), teils echt: Crash-Monate erwischen das Inventar voll.
|
||||
- **Strukturelles Tail-Risiko:** Ein No-Stop-Grid verkauft nie mit Verlust — bei
|
||||
einem Absturz ohne Erholung (2018-Stil, −95 %) hält es Bags bis zum Ende. Voll
|
||||
gefüllt liegt ~100 % des Pair-Budgets im Asset. Die 10 % MaxDD der letzten
|
||||
3 Jahre unterschätzen dieses Risiko systematisch.
|
||||
|
||||
**Status:** Gate formal nicht bestanden (Fenster + Ratio). Paper-Probelauf als
|
||||
zweite Engine wäre — wie beim Trend-Bot — eine bewusste User-Entscheidung.
|
||||
|
||||
## Befund
|
||||
|
||||
Klassische Grid-Pathologie, durch Regime-Filter abgemildert, aber nicht behoben:
|
||||
hohe WinRate (viele kleine TP-Gewinne à 1 Spacing), doch die `grid_stop`-Verluste
|
||||
bei Range-Breakdowns (−5 Spacings über N Lots) fressen alles. Breiteres Spacing
|
||||
(B) hebt den PF Richtung Break-even (1.03 ≈ Fees zurückverdient, mehr nicht),
|
||||
strengerer Filter (C) senkt nur den Drawdown. Schlechtestes Fenster durchgängig
|
||||
PF ≈ 0.1 — Crash-Monate treffen das Grid voll.
|
||||
|
||||
**Schlussfolgerung:** Mean-Reversion-Grids haben auf Krypto-4h über 3 Jahre
|
||||
keinen handelbaren Edge nach Fees — konsistent mit dem v1-GridBot-Erlebnis
|
||||
(krypto-kuns) und spiegelbildlich zum Trendfolge-Befund (dünner Edge, weil
|
||||
Krypto eben trendet/crasht statt sauber zu ranged).
|
||||
|
||||
**Entscheidung:** Gate nicht bestanden → **kein Paper-Deploy des GridBots.**
|
||||
Das Gate wird nicht aufgeweicht. Der laufende Paper-Probelauf des Trend-Bots
|
||||
(trading.kuns.dev) bleibt das Live-Experiment.
|
||||
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");
|
||||
25
drizzle/0002_burly_joystick.sql
Normal file
25
drizzle/0002_burly_joystick.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
CREATE TABLE "grid_lots" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"pair" varchar(16) NOT NULL,
|
||||
"level_idx" integer 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,
|
||||
"risk_amount" double precision NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "grid_state" (
|
||||
"pair" varchar(16) PRIMARY KEY NOT NULL,
|
||||
"center" double precision NOT NULL,
|
||||
"spacing" double precision NOT NULL,
|
||||
"lower_bound" double precision NOT NULL,
|
||||
"upper_bound" double precision NOT NULL,
|
||||
"budget_per_level" double precision NOT NULL,
|
||||
"activated_ts" timestamp with time zone NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "equity_snapshots" ADD COLUMN "bot" text DEFAULT 'trend' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "equity_snapshots" DROP CONSTRAINT "equity_snapshots_pkey";--> statement-breakpoint
|
||||
ALTER TABLE "equity_snapshots" ADD CONSTRAINT "equity_snapshots_bot_ts_pk" PRIMARY KEY("bot","ts");--> statement-breakpoint
|
||||
ALTER TABLE "paper_trades" ADD COLUMN "bot" text DEFAULT 'trend' NOT NULL;
|
||||
30
drizzle/0003_kind_sheva_callister.sql
Normal file
30
drizzle/0003_kind_sheva_callister.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE "trump_events" (
|
||||
"id" serial PRIMARY KEY NOT NULL,
|
||||
"source" text NOT NULL,
|
||||
"token" text NOT NULL,
|
||||
"instrument" varchar(16),
|
||||
"event_ts" timestamp with time zone NOT NULL,
|
||||
"ref" text NOT NULL,
|
||||
"notional_usd" double precision,
|
||||
"consumed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "trump_positions" (
|
||||
"pair" varchar(16) PRIMARY KEY 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,
|
||||
"risk_amount" double precision NOT NULL,
|
||||
"exit_due_ts" timestamp with time zone NOT NULL,
|
||||
"event_id" integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "trump_signal_state" (
|
||||
"id" integer PRIMARY KEY NOT NULL,
|
||||
"last_block" integer NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trump_events_source_ref_token" ON "trump_events" USING btree ("source","ref","token");
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
604
drizzle/meta/0002_snapshot.json
Normal file
604
drizzle/meta/0002_snapshot.json
Normal file
@@ -0,0 +1,604 @@
|
||||
{
|
||||
"id": "2e351b16-12b9-4c9a-a777-29cd5cf06dd4",
|
||||
"prevId": "38fbc5fc-4ef1-4dae-b408-21bcafa513b7",
|
||||
"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": {
|
||||
"bot": {
|
||||
"name": "bot",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'trend'"
|
||||
},
|
||||
"ts": {
|
||||
"name": "ts",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"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": {
|
||||
"equity_snapshots_bot_ts_pk": {
|
||||
"name": "equity_snapshots_bot_ts_pk",
|
||||
"columns": [
|
||||
"bot",
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grid_lots": {
|
||||
"name": "grid_lots",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"pair": {
|
||||
"name": "pair",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"level_idx": {
|
||||
"name": "level_idx",
|
||||
"type": "integer",
|
||||
"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
|
||||
},
|
||||
"risk_amount": {
|
||||
"name": "risk_amount",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grid_state": {
|
||||
"name": "grid_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"pair": {
|
||||
"name": "pair",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"center": {
|
||||
"name": "center",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"spacing": {
|
||||
"name": "spacing",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lower_bound": {
|
||||
"name": "lower_bound",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"upper_bound": {
|
||||
"name": "upper_bound",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"budget_per_level": {
|
||||
"name": "budget_per_level",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"activated_ts": {
|
||||
"name": "activated_ts",
|
||||
"type": "timestamp with time zone",
|
||||
"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
|
||||
},
|
||||
"bot": {
|
||||
"name": "bot",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'trend'"
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
793
drizzle/meta/0003_snapshot.json
Normal file
793
drizzle/meta/0003_snapshot.json
Normal file
@@ -0,0 +1,793 @@
|
||||
{
|
||||
"id": "5990c015-8158-412e-8e40-28370581d6d8",
|
||||
"prevId": "2e351b16-12b9-4c9a-a777-29cd5cf06dd4",
|
||||
"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": {
|
||||
"bot": {
|
||||
"name": "bot",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'trend'"
|
||||
},
|
||||
"ts": {
|
||||
"name": "ts",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"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": {
|
||||
"equity_snapshots_bot_ts_pk": {
|
||||
"name": "equity_snapshots_bot_ts_pk",
|
||||
"columns": [
|
||||
"bot",
|
||||
"ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grid_lots": {
|
||||
"name": "grid_lots",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"pair": {
|
||||
"name": "pair",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"level_idx": {
|
||||
"name": "level_idx",
|
||||
"type": "integer",
|
||||
"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
|
||||
},
|
||||
"risk_amount": {
|
||||
"name": "risk_amount",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.grid_state": {
|
||||
"name": "grid_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"pair": {
|
||||
"name": "pair",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"center": {
|
||||
"name": "center",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"spacing": {
|
||||
"name": "spacing",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"lower_bound": {
|
||||
"name": "lower_bound",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"upper_bound": {
|
||||
"name": "upper_bound",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"budget_per_level": {
|
||||
"name": "budget_per_level",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"activated_ts": {
|
||||
"name": "activated_ts",
|
||||
"type": "timestamp with time zone",
|
||||
"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
|
||||
},
|
||||
"bot": {
|
||||
"name": "bot",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'trend'"
|
||||
},
|
||||
"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
|
||||
},
|
||||
"public.trump_events": {
|
||||
"name": "trump_events",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"instrument": {
|
||||
"name": "instrument",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"event_ts": {
|
||||
"name": "event_ts",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ref": {
|
||||
"name": "ref",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"notional_usd": {
|
||||
"name": "notional_usd",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"consumed_at": {
|
||||
"name": "consumed_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"trump_events_source_ref_token": {
|
||||
"name": "trump_events_source_ref_token",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "source",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "ref",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "token",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.trump_positions": {
|
||||
"name": "trump_positions",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"pair": {
|
||||
"name": "pair",
|
||||
"type": "varchar(16)",
|
||||
"primaryKey": true,
|
||||
"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
|
||||
},
|
||||
"risk_amount": {
|
||||
"name": "risk_amount",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"exit_due_ts": {
|
||||
"name": "exit_due_ts",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"event_id": {
|
||||
"name": "event_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.trump_signal_state": {
|
||||
"name": "trump_signal_state",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"last_block": {
|
||||
"name": "last_block",
|
||||
"type": "integer",
|
||||
"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
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,27 @@
|
||||
"when": 1781038570957,
|
||||
"tag": "0000_nifty_brood",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1781071452889,
|
||||
"tag": "0001_certain_omega_red",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1781076227862,
|
||||
"tag": "0002_burly_joystick",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1781250542971,
|
||||
"tag": "0003_kind_sheva_callister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,11 +3,16 @@
|
||||
"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",
|
||||
"grid": "bun run src/server/scripts/grid-walkforward.ts",
|
||||
"db:generate": "bunx drizzle-kit generate",
|
||||
"db:migrate": "bun run src/server/db/migrate.ts"
|
||||
"db:migrate": "bun run src/server/db/migrate.ts",
|
||||
"trump:backfill": "bun run src/server/scripts/trump-backfill.ts",
|
||||
"trump:study": "bun run src/server/scripts/trump-event-study.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"drizzle-orm": "^0.44.0",
|
||||
|
||||
572
public/index.html
Normal file
572
public/index.html
Normal file
@@ -0,0 +1,572 @@
|
||||
<!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: 16px; 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; }
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 20px; }
|
||||
.tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font: 600 14px system-ui; padding: 8px 16px; cursor: pointer; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--text); border-bottom-color: var(--accent); }
|
||||
.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); }
|
||||
.panel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.panel-head h2 { margin-bottom: 0; }
|
||||
.pair-btns { margin-left: auto; display: flex; gap: 4px; }
|
||||
.pair-btns button { background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 600 11px var(--mono); padding: 3px 9px; border-radius: 6px; cursor: pointer; }
|
||||
.pair-btns button.active { color: var(--text); border-color: var(--accent); }
|
||||
.legend { font-size: 11px; color: var(--muted); }
|
||||
.legend .b { color: var(--green); } .legend .s { color: var(--red); }
|
||||
canvas { width: 100%; height: 220px; display: block; }
|
||||
canvas.price { height: 280px; cursor: crosshair; }
|
||||
.chart-wrap { position: relative; }
|
||||
.tooltip { position: absolute; pointer-events: none; background: #1c2128; border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; font: 12px/1.6 var(--mono); white-space: nowrap; z-index: 10; display: none; box-shadow: 0 4px 12px rgba(0,0,0,.5); }
|
||||
.tooltip .t-title { font-weight: 700; margin-bottom: 2px; }
|
||||
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>Paper · Trend (BTC ETH SOL XRP) + Grid (XRP) + Trump</small></h1>
|
||||
<div id="status"><span class="dot" style="background:var(--muted)"></span>lade…</div>
|
||||
</header>
|
||||
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="trading">Trading</button>
|
||||
<button class="tab" data-tab="grid">GridBot</button>
|
||||
<button class="tab" data-tab="trump">Trump</button>
|
||||
</nav>
|
||||
|
||||
<section id="tab-trading">
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Preis-Chart (4h) <span class="legend">▲ <span class="b">Buy</span> · ▼ <span class="s">Sell</span> · ○ offen</span></h2>
|
||||
<div class="pair-btns" id="pair-btns"></div>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="price-trend" class="price" height="280"></canvas>
|
||||
<div class="tooltip" id="tip-trend"></div>
|
||||
</div>
|
||||
</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>
|
||||
</section>
|
||||
|
||||
<section id="tab-grid" hidden>
|
||||
<div class="kpis" id="grid-kpis"></div>
|
||||
<div class="panel" style="padding:10px 16px"><div id="grid-info" style="color:var(--muted);font-size:12.5px"></div></div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>XRP-Chart (4h) <span class="legend">▲ <span class="b">Buy</span> · ▼ <span class="s">Sell</span> · ○ offen · ┄ Grid-Levels</span></h2>
|
||||
</div>
|
||||
<div class="chart-wrap">
|
||||
<canvas id="price-grid" class="price" height="280"></canvas>
|
||||
<div class="tooltip" id="tip-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel"><h2>Equity-Kurve (4h)</h2><canvas id="grid-chart" height="220"></canvas></div>
|
||||
|
||||
<div class="panel"><h2>Offene Lots</h2><div id="grid-lots"></div></div>
|
||||
|
||||
<div class="panel"><h2>Grid-Trades</h2><div id="grid-trades"></div></div>
|
||||
</section>
|
||||
|
||||
<section id="tab-trump" hidden>
|
||||
<div class="kpis" id="trump-cards"></div>
|
||||
|
||||
<div class="panel"><h2>Offene Positionen</h2><div id="trump-positions"></div></div>
|
||||
|
||||
<div class="panel"><h2>Events</h2><div id="trump-events"></div></div>
|
||||
|
||||
<div class="panel"><h2>Trades</h2><div id="trump-trades"></div></div>
|
||||
</section>
|
||||
|
||||
<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);
|
||||
const priceDec = (p) => p < 1 ? 5 : p < 10 ? 4 : p < 1000 ? 2 : 0;
|
||||
// HTML-Escaping für DB-Felder mit externem Ursprung (Truth-Feed/On-chain)
|
||||
const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
// ── Equity-Kurve (Linie) ────────────────────────────────────────────────
|
||||
function makeEquityChart(canvasId) {
|
||||
const cv = document.getElementById(canvasId);
|
||||
let data = null;
|
||||
function draw() {
|
||||
if (!data) return;
|
||||
const { curve, startCapital } = data;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth, h = 220;
|
||||
if (!w) return; // Tab versteckt
|
||||
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.lineWidth = 1;
|
||||
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);
|
||||
}
|
||||
return {
|
||||
render(curve, startCapital) { data = { curve, startCapital }; draw(); },
|
||||
redraw: draw,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Preis-Chart (Candles + Buy/Sell-Marker + Tooltip) ───────────────────
|
||||
// markers: [{ ts, price, kind: 'buy'|'sell'|'open', title, lines: [string] }]
|
||||
// levels: [{ price, label, emph }] — horizontale gestrichelte Linien (Grid)
|
||||
function makePriceChart(canvasId, tipId) {
|
||||
const cv = document.getElementById(canvasId);
|
||||
const tip = document.getElementById(tipId);
|
||||
const H = 280;
|
||||
let data = null; // { candles, markers, levels }
|
||||
let hits = []; // [{ x, y, m }] Marker-Hitboxen in CSS-px
|
||||
let geo = null; // { pad, w, xmin, xmax, ymin, ymax, barW }
|
||||
|
||||
function draw() {
|
||||
if (!data) return;
|
||||
const { candles, markers, levels } = data;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth;
|
||||
if (!w) return; // Tab versteckt
|
||||
cv.width = w * dpr; cv.height = H * dpr;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, H);
|
||||
hits = [];
|
||||
if (candles.length < 2) {
|
||||
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
||||
ctx.fillText('Noch keine Candles geladen.', 10, H / 2);
|
||||
return;
|
||||
}
|
||||
const pad = { l: 56, r: 10, t: 12, b: 22 };
|
||||
const xmin = candles[0].ts, xmax = candles[candles.length - 1].ts;
|
||||
let ymin = Infinity, ymax = -Infinity;
|
||||
for (const c of candles) { if (c.low < ymin) ymin = c.low; if (c.high > ymax) ymax = c.high; }
|
||||
for (const m of markers) if (m.ts >= xmin) { if (m.price < ymin) ymin = m.price; if (m.price > ymax) ymax = m.price; }
|
||||
const yspan = (ymax - ymin) || 1; ymin -= yspan * .07; ymax += yspan * .10;
|
||||
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);
|
||||
const barW = Math.max(1, Math.min(9, (w - pad.l - pad.r) / candles.length * 0.7));
|
||||
geo = { pad, w, xmin, xmax, ymin, ymax, X, Y };
|
||||
const dec = priceDec(ymax);
|
||||
|
||||
// Gitter + Y-Achse
|
||||
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, dec), 4, y + 4);
|
||||
}
|
||||
|
||||
// Grid-Levels
|
||||
for (const lv of levels || []) {
|
||||
if (lv.price < ymin || lv.price > ymax) continue;
|
||||
const y = Y(lv.price);
|
||||
ctx.strokeStyle = lv.emph ? '#58a6ff' : '#3d444d';
|
||||
ctx.setLineDash([3, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
if (lv.label) {
|
||||
ctx.fillStyle = lv.emph ? '#58a6ff' : '#6e7681';
|
||||
ctx.fillText(lv.label, w - pad.r - ctx.measureText(lv.label).width - 2, y - 3);
|
||||
}
|
||||
}
|
||||
|
||||
// Candles
|
||||
for (const c of candles) {
|
||||
const x = X(c.ts), up = c.close >= c.open;
|
||||
ctx.strokeStyle = ctx.fillStyle = up ? '#3fb950' : '#f85149';
|
||||
ctx.beginPath(); ctx.moveTo(x, Y(c.high)); ctx.lineTo(x, Y(c.low)); ctx.stroke();
|
||||
const yo = Y(c.open), yc = Y(c.close);
|
||||
ctx.fillRect(x - barW / 2, Math.min(yo, yc), barW, Math.max(1, Math.abs(yc - yo)));
|
||||
}
|
||||
|
||||
// Marker
|
||||
for (const m of markers) {
|
||||
if (m.ts < xmin || m.ts > xmax) continue;
|
||||
const x = X(m.ts), y = Y(m.price);
|
||||
const buyish = m.kind !== 'sell';
|
||||
ctx.fillStyle = ctx.strokeStyle = buyish ? '#3fb950' : '#f85149';
|
||||
const my = buyish ? y + 11 : y - 11; // Buy unter, Sell über dem Preis
|
||||
if (m.kind === 'open') {
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.beginPath(); ctx.arc(x, my, 4.5, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.lineWidth = 1;
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
if (buyish) { ctx.moveTo(x, my - 5); ctx.lineTo(x - 5, my + 4); ctx.lineTo(x + 5, my + 4); }
|
||||
else { ctx.moveTo(x, my + 5); ctx.lineTo(x - 5, my - 4); ctx.lineTo(x + 5, my - 4); }
|
||||
ctx.closePath(); ctx.fill();
|
||||
}
|
||||
hits.push({ x, y: my, m });
|
||||
}
|
||||
|
||||
// X-Achse
|
||||
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);
|
||||
}
|
||||
|
||||
function showTip(px, py, title, lines, titleClass) {
|
||||
tip.innerHTML = `<div class="t-title ${titleClass || ''}">${title}</div>` + lines.join('<br>');
|
||||
tip.style.display = 'block';
|
||||
const wrapW = cv.clientWidth;
|
||||
const tw = tip.offsetWidth, th = tip.offsetHeight;
|
||||
let left = px + 14, top = py - th - 8;
|
||||
if (left + tw > wrapW) left = px - tw - 14;
|
||||
if (top < 0) top = py + 14;
|
||||
tip.style.left = left + 'px'; tip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
cv.addEventListener('mousemove', (ev) => {
|
||||
if (!data || !geo) return;
|
||||
const r = cv.getBoundingClientRect();
|
||||
const px = ev.clientX - r.left, py = ev.clientY - r.top;
|
||||
// 1) Marker in der Nähe?
|
||||
let best = null, bestD = 144; // 12px Radius²
|
||||
for (const h of hits) {
|
||||
const d = (h.x - px) ** 2 + (h.y - py) ** 2;
|
||||
if (d < bestD) { bestD = d; best = h; }
|
||||
}
|
||||
if (best) {
|
||||
const m = best.m;
|
||||
showTip(px, py, m.title, m.lines, m.kind === 'sell' ? 'neg' : 'pos');
|
||||
return;
|
||||
}
|
||||
// 2) sonst: Candle-OHLC unter dem Cursor
|
||||
const { candles } = data;
|
||||
const span = geo.xmax - geo.xmin;
|
||||
const ts = geo.xmin + (px - geo.pad.l) / (geo.w - geo.pad.l - geo.pad.r) * span;
|
||||
let ci = -1, cd = Infinity;
|
||||
for (let i = 0; i < candles.length; i++) {
|
||||
const d = Math.abs(candles[i].ts - ts);
|
||||
if (d < cd) { cd = d; ci = i; }
|
||||
}
|
||||
if (ci < 0 || px < geo.pad.l || px > geo.w - geo.pad.r) { tip.style.display = 'none'; return; }
|
||||
const c = candles[ci], dec = priceDec(c.high);
|
||||
showTip(px, py, fmtTs(c.ts), [
|
||||
`O ${fmt(c.open, dec)} · H ${fmt(c.high, dec)}`,
|
||||
`L ${fmt(c.low, dec)} · C ${fmt(c.close, dec)}`,
|
||||
`Vol ${fmt(c.volume, 0)}`,
|
||||
]);
|
||||
});
|
||||
cv.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
|
||||
|
||||
return {
|
||||
render(candles, markers, levels) { data = { candles, markers: markers || [], levels: levels || [] }; draw(); },
|
||||
redraw: draw,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Marker-Erzeugung ────────────────────────────────────────────────────
|
||||
function tradeMarkers(trades, { withR = false, label = '' } = {}) {
|
||||
const ms = [];
|
||||
for (const t of trades) {
|
||||
const entryTs = new Date(t.entryTs).getTime(), exitTs = new Date(t.exitTs).getTime();
|
||||
const dec = priceDec(t.entryPrice);
|
||||
ms.push({
|
||||
ts: entryTs, price: t.entryPrice, kind: 'buy', title: `▲ BUY ${label || t.pair}`,
|
||||
lines: [fmtTs(entryTs), `Preis ${fmt(t.entryPrice, dec)} $`, `Menge ${fmt(t.qty, 4)}`],
|
||||
});
|
||||
ms.push({
|
||||
ts: exitTs, price: t.exitPrice, kind: 'sell', title: `▼ SELL ${label || t.pair}`,
|
||||
lines: [
|
||||
fmtTs(exitTs), `Preis ${fmt(t.exitPrice, dec)} $`,
|
||||
`PnL <span class="${cls(t.pnl)}">${sign(t.pnl)} $</span>` + (withR ? ` · R <span class="${cls(t.r)}">${sign(t.r, 2)}</span>` : ''),
|
||||
`Grund: ${t.exitReason}`, `Entry ${fmtTs(entryTs)} @ ${fmt(t.entryPrice, dec)}`,
|
||||
],
|
||||
});
|
||||
}
|
||||
return ms;
|
||||
}
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────
|
||||
const charts = {
|
||||
trading: [], // wird unten befüllt
|
||||
grid: [],
|
||||
trump: [],
|
||||
};
|
||||
function showTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
||||
document.getElementById('tab-trading').hidden = name !== 'trading';
|
||||
document.getElementById('tab-grid').hidden = name !== 'grid';
|
||||
document.getElementById('tab-trump').hidden = name !== 'trump';
|
||||
history.replaceState(null, '', '#' + name);
|
||||
requestAnimationFrame(() => (charts[name] || []).forEach(c => c.redraw()));
|
||||
}
|
||||
document.querySelectorAll('.tab').forEach(b => b.addEventListener('click', () => showTab(b.dataset.tab)));
|
||||
|
||||
// ── Pair-Auswahl Trend-Chart ────────────────────────────────────────────
|
||||
const PAIRS = ['BTC_USDT', 'ETH_USDT', 'SOL_USDT', 'XRP_USDT'];
|
||||
let selPair = 'BTC_USDT';
|
||||
document.getElementById('pair-btns').innerHTML =
|
||||
PAIRS.map(p => `<button data-pair="${p}" class="${p === selPair ? 'active' : ''}">${p.replace('_USDT', '')}</button>`).join('');
|
||||
document.querySelectorAll('#pair-btns button').forEach(b => b.addEventListener('click', () => {
|
||||
selPair = b.dataset.pair;
|
||||
document.querySelectorAll('#pair-btns button').forEach(x => x.classList.toggle('active', x.dataset.pair === selPair));
|
||||
renderTrendPrice();
|
||||
}));
|
||||
|
||||
const equityChart = makeEquityChart('chart');
|
||||
const gridEquityChart = makeEquityChart('grid-chart');
|
||||
const trendPriceChart = makePriceChart('price-trend', 'tip-trend');
|
||||
const gridPriceChart = makePriceChart('price-grid', 'tip-grid');
|
||||
charts.trading = [equityChart, trendPriceChart];
|
||||
charts.grid = [gridEquityChart, gridPriceChart];
|
||||
|
||||
let lastTrades = [], lastPositions = [];
|
||||
|
||||
async function renderTrendPrice() {
|
||||
try {
|
||||
const candles = await fetch(`/api/candles?pair=${selPair}&tf=4h&limit=300`).then(r => r.json());
|
||||
const markers = tradeMarkers(lastTrades.filter(t => t.pair === selPair), { withR: true });
|
||||
for (const p of lastPositions.filter(p => p.pair === selPair)) {
|
||||
const dec = priceDec(p.entryPrice);
|
||||
markers.push({
|
||||
ts: p.entryTs, price: p.entryPrice, kind: 'open', title: `○ OFFEN ${p.pair}`,
|
||||
lines: [
|
||||
`Entry ${fmtTs(p.entryTs)} @ ${fmt(p.entryPrice, dec)}`, `Menge ${fmt(p.qty, 4)}`,
|
||||
`Stop ${fmt(p.stop, dec)}`,
|
||||
`PnL <span class="${cls(p.unrealizedPnl)}">${sign(p.unrealizedPnl)} $</span>`,
|
||||
],
|
||||
});
|
||||
}
|
||||
trendPriceChart.render(candles, markers);
|
||||
} catch { /* nächster Refresh versucht's wieder */ }
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [pf, stats, trades, decisions, grid, gridTradesAll, trump, trumpTradesAll] = await Promise.all([
|
||||
fetch('/api/portfolio').then(r => r.json()),
|
||||
fetch('/api/stats').then(r => r.json()),
|
||||
fetch('/api/trades?limit=200').then(r => r.json()),
|
||||
fetch('/api/decisions?limit=12').then(r => r.json()),
|
||||
fetch('/api/grid').then(r => r.json()),
|
||||
fetch('/api/trades?bot=grid&limit=200').then(r => r.json()),
|
||||
fetch('/api/trump').then(r => r.json()),
|
||||
fetch('/api/trades?bot=trump&limit=200').then(r => r.json()),
|
||||
]);
|
||||
lastTrades = trades;
|
||||
lastPositions = pf.positions;
|
||||
|
||||
// ── Trading-Tab ──
|
||||
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));
|
||||
|
||||
equityChart.render(stats.equityCurve || [], stats.startCapital);
|
||||
renderTrendPrice();
|
||||
|
||||
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.slice(0, 30).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.');
|
||||
|
||||
// ── GridBot-Tab ──
|
||||
const gPnl = grid.equity - grid.startCapital;
|
||||
document.getElementById('grid-kpis').innerHTML =
|
||||
kpi('Equity', fmt(grid.equity) + ' $') +
|
||||
kpi('PnL', sign(gPnl) + ' $', cls(gPnl)) +
|
||||
kpi('Cash', fmt(grid.cash) + ' $') +
|
||||
kpi('Trades', grid.stats.trades) +
|
||||
kpi('Win Rate', grid.stats.trades ? fmt(grid.stats.winRate * 100, 0) + ' %' : '–');
|
||||
|
||||
const gs = grid.grids[0];
|
||||
document.getElementById('grid-info').textContent = gs
|
||||
? `Aktives Grid: Center ${fmt(gs.center, 4)} · Spacing ${fmt(gs.spacing, 4)} · Range ${fmt(gs.lowerBound, 4)}–${fmt(gs.upperBound, 4)} · Budget/Level ${fmt(gs.budgetPerLevel)} $ · aktiviert ${fmtTs(gs.activatedTs)} · XRP jetzt ${fmt(gs.lastPrice, 4)}`
|
||||
: 'Kein aktives Grid — Aktivierung beim nächsten 4h-Close.';
|
||||
|
||||
// XRP-Chart mit Grid-Levels und Markern
|
||||
const xrpCandles = await fetch('/api/candles?pair=XRP_USDT&tf=4h&limit=300').then(r => r.json());
|
||||
const gMarkers = tradeMarkers(gridTradesAll, { label: 'XRP' });
|
||||
for (const l of grid.lots) {
|
||||
const dec = priceDec(l.entryPrice);
|
||||
gMarkers.push({
|
||||
ts: l.entryTs, price: l.entryPrice, kind: 'open', title: `○ LOT L${l.levelIdx + 1}`,
|
||||
lines: [
|
||||
`Entry ${fmtTs(l.entryTs)} @ ${fmt(l.entryPrice, dec)}`, `Menge ${fmt(l.qty, 4)}`,
|
||||
`PnL <span class="${cls(l.unrealizedPnl)}">${sign(l.unrealizedPnl)} $</span>`,
|
||||
],
|
||||
});
|
||||
}
|
||||
const levels = [];
|
||||
if (gs) {
|
||||
levels.push({ price: gs.center, label: 'Center', emph: true });
|
||||
for (let k = 0; k < (grid.config.gridLevels ?? 8); k++) {
|
||||
levels.push({ price: gs.center - (k + 1) * gs.spacing, label: `L${k + 1}` });
|
||||
}
|
||||
}
|
||||
gridPriceChart.render(xrpCandles, gMarkers, levels);
|
||||
|
||||
gridEquityChart.render(grid.equityCurve || [], grid.startCapital);
|
||||
|
||||
document.getElementById('grid-lots').innerHTML = table(
|
||||
['Level', 'Entry', 'Entry-Preis', 'Letzter', 'Wert $', 'PnL $'],
|
||||
grid.lots.map(l => `<tr><td>L${l.levelIdx + 1}</td><td>${fmtTs(l.entryTs)}</td><td>${fmt(l.entryPrice, 4)}</td><td>${fmt(l.lastPrice, 4)}</td><td>${fmt(l.value)}</td><td class="${cls(l.unrealizedPnl)}">${sign(l.unrealizedPnl)}</td></tr>`),
|
||||
'Keine offenen Lots — warten auf den ersten Dip unter ein Level.');
|
||||
|
||||
document.getElementById('grid-trades').innerHTML = table(
|
||||
['Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'Grund'],
|
||||
grid.trades.map(t => `<tr><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>${t.exitReason}</td></tr>`),
|
||||
'Noch keine Grid-Trades.');
|
||||
|
||||
// ── Trump-Tab ──
|
||||
// Equity ≈ cash + Σ qty×entryPrice (Näherung; kein Marktpreis verfügbar im API)
|
||||
const trumpPositions = trump.positions || [];
|
||||
const trumpEvents = trump.events || [];
|
||||
const trumpEquityApprox = (trump.cash || 0) + trumpPositions.reduce((s, p) => s + p.qty * p.entryPrice, 0);
|
||||
const trumpPnl = trumpEquityApprox - (trump.startCapital || 0);
|
||||
const evCount = trumpEvents.length;
|
||||
document.getElementById('trump-cards').innerHTML =
|
||||
kpi('Equity (ca.)', fmt(trumpEquityApprox) + ' $') +
|
||||
kpi('PnL', sign(trumpPnl) + ' $', cls(trumpPnl)) +
|
||||
kpi('Cash', fmt(trump.cash) + ' $') +
|
||||
kpi('Offene Pos.', trumpPositions.length) +
|
||||
kpi('Events', evCount >= 50 ? '50+' : evCount);
|
||||
|
||||
document.getElementById('trump-positions').innerHTML = table(
|
||||
['Pair', 'Entry-Zeit', 'Entry-Preis', 'Qty', 'Exit fällig'],
|
||||
trumpPositions.map(p => `<tr><td><span class="tag long">${esc(p.pair)}</span></td><td>${fmtTs(p.entryTs)}</td><td>${fmt(p.entryPrice, priceDec(p.entryPrice))}</td><td>${fmt(p.qty, 4)}</td><td>${fmtTs(p.exitDueTs)}</td></tr>`),
|
||||
'Keine offenen Positionen.');
|
||||
|
||||
document.getElementById('trump-events').innerHTML = table(
|
||||
['Zeit', 'Quelle', 'Coin', 'Instrument', 'Notional $', 'Ref'],
|
||||
trumpEvents.map(e => {
|
||||
// href nur mit https-Schema (Truth-Refs kommen aus externem Feed)
|
||||
const safeHref = e.source === 'onchain'
|
||||
? `https://etherscan.io/tx/${esc(e.ref)}`
|
||||
: /^https:\/\//i.test(e.ref) ? esc(e.ref) : null;
|
||||
const refLabel = esc(e.source === 'onchain' ? e.ref.slice(0, 12) + '…' : e.ref.slice(0, 24) + (e.ref.length > 24 ? '…' : ''));
|
||||
const ref = safeHref ? `<a href="${safeHref}" target="_blank" rel="noopener" style="color:var(--accent)">${refLabel}</a>` : refLabel;
|
||||
return `<tr><td>${fmtTs(e.eventTs)}</td><td>${esc(e.source ?? '–')}</td><td>${esc(e.token ?? '–')}</td><td>${esc(e.instrument ?? '–')}</td><td>${e.notionalUsd != null ? fmt(e.notionalUsd) + ' $' : '–'}</td><td style="text-align:left">${ref}</td></tr>`;
|
||||
}),
|
||||
'Keine Events vorhanden.');
|
||||
|
||||
document.getElementById('trump-trades').innerHTML = table(
|
||||
['Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'Grund'],
|
||||
trumpTradesAll.map(t => `<tr><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>${esc(t.exitReason)}</td></tr>`),
|
||||
'Noch keine Trump-Trades.');
|
||||
|
||||
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`;
|
||||
}
|
||||
}
|
||||
|
||||
if (location.hash === '#grid') showTab('grid');
|
||||
else if (location.hash === '#trump') showTab('trump');
|
||||
refresh();
|
||||
setInterval(refresh, 30000);
|
||||
let resizeT;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeT);
|
||||
resizeT = setTimeout(() => Object.values(charts).flat().forEach(c => c.redraw()), 150);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
261
src/server/api/server.ts
Normal file
261
src/server/api/server.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { and, desc, eq, gte } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { botState, candles, decisionLogs, equitySnapshots, gridLots, gridState, paperTrades, positions, trumpEvents, trumpPositions } 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';
|
||||
import type { GridEngine } from '../live/grid-engine';
|
||||
import { GRID_CYCLE_CONFIG } from '../live/grid-engine';
|
||||
import type { TrumpEngine } from '../live/trump-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).where(eq(paperTrades.bot, 'trend'));
|
||||
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)
|
||||
.where(eq(equitySnapshots.bot, 'trend'))
|
||||
.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 };
|
||||
}
|
||||
|
||||
async function getGrid(gridEngine: GridEngine) {
|
||||
const [state] = await db.select().from(botState).where(eq(botState.id, 2));
|
||||
const stateRows = await db.select().from(gridState);
|
||||
const lotRows = await db.select().from(gridLots);
|
||||
const tradeRows = await db
|
||||
.select()
|
||||
.from(paperTrades)
|
||||
.where(eq(paperTrades.bot, 'grid'))
|
||||
.orderBy(desc(paperTrades.exitTs))
|
||||
.limit(30);
|
||||
const closes = await latestCloses();
|
||||
|
||||
let equity = state?.cash ?? 0;
|
||||
const lots = lotRows.map((l) => {
|
||||
const last = closes.get(l.pair as Pair) ?? l.entryPrice;
|
||||
const value = l.qty * last;
|
||||
equity += value;
|
||||
return {
|
||||
pair: l.pair,
|
||||
levelIdx: l.levelIdx,
|
||||
qty: l.qty,
|
||||
entryTs: l.entryTs.getTime(),
|
||||
entryPrice: l.entryPrice,
|
||||
lastPrice: last,
|
||||
value,
|
||||
unrealizedPnl: value - l.entryCost,
|
||||
};
|
||||
});
|
||||
const wins = tradeRows.filter((t) => t.pnl > 0);
|
||||
const grossWin = wins.reduce((s, t) => s + t.pnl, 0);
|
||||
const grossLoss = tradeRows.filter((t) => t.pnl < 0).reduce((s, t) => s - t.pnl, 0);
|
||||
const curveRows = await db
|
||||
.select()
|
||||
.from(equitySnapshots)
|
||||
.where(eq(equitySnapshots.bot, 'grid'))
|
||||
.orderBy(equitySnapshots.ts);
|
||||
|
||||
return {
|
||||
equity,
|
||||
cash: state?.cash ?? 0,
|
||||
startCapital: state?.startCapital ?? 0,
|
||||
cursorTs: state?.cursorTs.getTime() ?? null,
|
||||
config: {
|
||||
pairs: GRID_CYCLE_CONFIG.pairs,
|
||||
spacingAtrMult: GRID_CYCLE_CONFIG.params.spacingAtrMult,
|
||||
gridLevels: GRID_CYCLE_CONFIG.params.gridLevels,
|
||||
noStop: !GRID_CYCLE_CONFIG.params.hardStop,
|
||||
},
|
||||
grids: stateRows.map((s) => ({
|
||||
pair: s.pair,
|
||||
center: s.center,
|
||||
spacing: s.spacing,
|
||||
lowerBound: s.lowerBound,
|
||||
upperBound: s.upperBound,
|
||||
budgetPerLevel: s.budgetPerLevel,
|
||||
activatedTs: s.activatedTs.getTime(),
|
||||
lastPrice: closes.get(s.pair as Pair) ?? null,
|
||||
})),
|
||||
lots,
|
||||
trades: tradeRows,
|
||||
stats: {
|
||||
trades: tradeRows.length,
|
||||
winRate: tradeRows.length ? wins.length / tradeRows.length : 0,
|
||||
profitFactor: grossLoss > 0 ? grossWin / grossLoss : tradeRows.length ? Infinity : 0,
|
||||
totalPnl: tradeRows.reduce((s, t) => s + t.pnl, 0),
|
||||
},
|
||||
equityCurve: curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity })),
|
||||
engine: gridEngine.status,
|
||||
};
|
||||
}
|
||||
|
||||
export function createServer(engine: LiveEngine, gridEngine: GridEngine, trumpEngine: TrumpEngine, 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 bot = url.searchParams.get('bot') ?? 'trend';
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(paperTrades)
|
||||
.where(eq(paperTrades.bot, bot))
|
||||
.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/grid':
|
||||
return json(await getGrid(gridEngine));
|
||||
case '/api/trump': {
|
||||
const [state] = await db.select().from(botState).where(eq(botState.id, 3));
|
||||
const trumpPos = await db.select().from(trumpPositions);
|
||||
const events = await db.select().from(trumpEvents).orderBy(desc(trumpEvents.eventTs)).limit(50);
|
||||
return json({
|
||||
status: trumpEngine.status,
|
||||
cash: state?.cash ?? null,
|
||||
startCapital: state?.startCapital ?? null,
|
||||
positions: trumpPos,
|
||||
events,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
99
src/server/backtest/grid.test.ts
Normal file
99
src/server/backtest/grid.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import { runGridBacktest, DEFAULT_GRID_PARAMS, type GridConfig } from './grid';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const PAIR: Pair = 'BTC_USDT';
|
||||
const T0 = Date.UTC(2025, 0, 1);
|
||||
|
||||
function cfg(c15: Candle[], over: Partial<GridConfig> = {}): GridConfig {
|
||||
return {
|
||||
startCapital: 1000,
|
||||
exec: DEFAULT_EXEC,
|
||||
params: DEFAULT_GRID_PARAMS,
|
||||
minNotionalUsdt: 10,
|
||||
tradeFrom: 0,
|
||||
tradeTo: c15[c15.length - 1].ts + M15,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
/** 15m-Candle als flacher Bar um `price` mit gegebener Range. */
|
||||
function bar(k: number, price: number, lo = 0.1, hi = 0.1): Candle {
|
||||
return { ts: T0 + k * M15, open: price, high: price + hi, low: price - lo, close: price, volume: 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seitwärtsserie um 100: erst 50 4h-Bars Mini-Oszillation (ATR klein → Grid
|
||||
* aktiviert mit engen Levels nahe 100), dann breite Oszillation ±2, die Levels
|
||||
* füllt und TPs auslöst. Sinus-Periode 1 Tag → ADX bleibt < 10.
|
||||
*/
|
||||
function sideways(bars4h: number): Candle[] {
|
||||
const out: Candle[] = [];
|
||||
const warmup = 50 * 16;
|
||||
for (let k = 0; k < bars4h * 16; k++) {
|
||||
const amp = k < warmup ? 0.2 : 2;
|
||||
out.push(bar(k, 100 + amp * Math.sin((2 * Math.PI * k) / 96)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Starker Trend: monotone Steigung → hoher ADX. */
|
||||
function trending(bars4h: number, slope = 1): Candle[] {
|
||||
const out: Candle[] = [];
|
||||
for (let k = 0; k < bars4h * 16; k++) out.push(bar(k, 100 + k * slope));
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('runGridBacktest', () => {
|
||||
test('Seitwärtsmarkt: Grid handelt und ist nach Fees profitabel', () => {
|
||||
const c15 = sideways(200);
|
||||
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
|
||||
const tps = res.trades.filter((t) => t.exitReason === 'grid_tp');
|
||||
expect(tps.length).toBeGreaterThan(3);
|
||||
// Jeder TP gewinnt genau ~1 Spacing minus Fees → positiv
|
||||
for (const t of tps) expect(t.pnl).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('starker Trend: ADX-Filter verhindert Aktivierung (keine Trades)', () => {
|
||||
const c15 = trending(150);
|
||||
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
|
||||
expect(res.trades.length).toBe(0);
|
||||
expect(res.finalEquity).toBe(1000);
|
||||
});
|
||||
|
||||
test('Range-Breakdown liquidiert alle Lots als grid_stop', () => {
|
||||
// Seitwärts (Grid aktiviert, Dips füllen Levels), dann Absturz weit unter den Stop
|
||||
const side = sideways(120);
|
||||
const crash: Candle[] = [];
|
||||
let price = side[side.length - 1].close;
|
||||
for (let k = 0; k < 32 * 16; k++) {
|
||||
price = Math.max(5, price - 0.4);
|
||||
crash.push(bar(side.length + k, price));
|
||||
}
|
||||
const c15 = [...side, ...crash];
|
||||
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
|
||||
const stops = res.trades.filter((t) => t.exitReason === 'grid_stop');
|
||||
expect(stops.length).toBeGreaterThan(0);
|
||||
for (const t of stops) expect(t.pnl).toBeLessThan(0);
|
||||
// Nach dem Crash bleibt kein Grid mit Lots offen, das beim end_of_data verliert
|
||||
const eod = res.trades.filter((t) => t.exitReason === 'end_of_data');
|
||||
// Re-Aktivierung nach dem Crash ist erlaubt — aber alle Crash-Lots wurden via grid_stop geschlossen
|
||||
expect(stops.length + eod.length + res.trades.filter((t) => t.exitReason === 'grid_tp').length).toBe(res.trades.length);
|
||||
});
|
||||
|
||||
test('Determinismus: identischer Input → identisches Ergebnis', () => {
|
||||
const c15 = sideways(150);
|
||||
const a = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
|
||||
const b = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15));
|
||||
expect(JSON.stringify(a)).toBe(JSON.stringify(b));
|
||||
});
|
||||
|
||||
test('kein Trade vor tradeFrom', () => {
|
||||
const c15 = sideways(200);
|
||||
const mid = c15[Math.floor(c15.length / 2)].ts;
|
||||
const res = runGridBacktest(new Map([[PAIR, c15]]), cfg(c15, { tradeFrom: mid }));
|
||||
for (const t of res.trades) expect(t.entryTs).toBeGreaterThanOrEqual(mid);
|
||||
});
|
||||
});
|
||||
206
src/server/backtest/grid.ts
Normal file
206
src/server/backtest/grid.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { PAIRS } from '../types';
|
||||
import { aggregateTf, H4 } from '../market/aggregate';
|
||||
import { atr } from '../indicators/atr';
|
||||
import { adx } from '../indicators/adx';
|
||||
import type { ClosedTrade, ExecConfig } from '../engine/portfolio';
|
||||
import type { EquityPoint } from './metrics';
|
||||
import type { BacktestResult } from './runner';
|
||||
|
||||
export interface GridParams {
|
||||
spacingAtrMult: number; // Level-Abstand = mult × ATR(atrPeriod, tf) bei Aktivierung
|
||||
gridLevels: number; // Buy-Levels unterhalb des Centers
|
||||
adxMax: number; // Grid nur aktiv im Seitwärtsregime (ADX < adxMax)
|
||||
atrPeriod: number;
|
||||
tfMs: number; // Entscheidungs-Timeframe (Aktivierung/Deaktivierung, ATR/ADX-Basis)
|
||||
/**
|
||||
* true: Range-Breakdown/Ausbruch/Trendbeginn liquidiert alle Lots (harter Stop).
|
||||
* false: Lots werden nie mit Verlust verkauft (nur TP oder end_of_data);
|
||||
* Re-Center nur, wenn das Grid leer ist und der Preis die Range verlassen hat.
|
||||
*/
|
||||
hardStop: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_GRID_PARAMS: GridParams = {
|
||||
spacingAtrMult: 1.0,
|
||||
gridLevels: 4,
|
||||
adxMax: 20,
|
||||
atrPeriod: 14,
|
||||
tfMs: H4,
|
||||
hardStop: true,
|
||||
};
|
||||
|
||||
export interface GridConfig {
|
||||
startCapital: number;
|
||||
exec: ExecConfig;
|
||||
params: GridParams;
|
||||
minNotionalUsdt: number;
|
||||
tradeFrom: number; // ms inklusiv — Aktivierungen/Fills erst ab hier
|
||||
tradeTo: number; // ms exklusiv — danach Zwangsglattstellung
|
||||
}
|
||||
|
||||
interface Lot {
|
||||
levelIdx: number;
|
||||
qty: number;
|
||||
entryTs: number;
|
||||
entryPrice: number; // Fill inkl. Slippage
|
||||
entryCost: number; // qty×fill + Fee
|
||||
riskAmount: number; // Distanz zum Grid-Stop × qty
|
||||
}
|
||||
|
||||
interface ActiveGrid {
|
||||
center: number;
|
||||
spacing: number; // eingefroren bei Aktivierung — kein Level-Chasing
|
||||
stopPrice: number; // center − (N+1)×spacing
|
||||
upperExit: number; // center + (N+1)×spacing
|
||||
budgetPerLevel: number;
|
||||
lots: (Lot | null)[]; // Index = Level (0 = oberstes Buy-Level bei center − 1×spacing)
|
||||
}
|
||||
|
||||
/**
|
||||
* ATR-Grid mit ADX-Regime-Filter, long-only, je Pair unabhängig.
|
||||
* Tf-Close (default 4h): Aktivierung/Deaktivierung; 15m: Fills (Sells vor
|
||||
* Buys — ein im selben Bar gekaufter Lot kann nicht im selben Bar verkaufen).
|
||||
* Fee/Slippage-Mathematik identisch zu Portfolio (pessimistische Fills).
|
||||
*/
|
||||
export function runGridBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: GridConfig): BacktestResult {
|
||||
const { exec, params: p } = cfg;
|
||||
let cash = cfg.startCapital;
|
||||
const trades: ClosedTrade[] = [];
|
||||
const grids = new Map<Pair, ActiveGrid>();
|
||||
const lastClose = new Map<Pair, number>();
|
||||
const equityCurve: EquityPoint[] = [];
|
||||
|
||||
const equity = (): number => {
|
||||
let eq = cash;
|
||||
for (const [pair, g] of grids) {
|
||||
const last = lastClose.get(pair) ?? 0;
|
||||
for (const lot of g.lots) if (lot) eq += lot.qty * last;
|
||||
}
|
||||
return eq;
|
||||
};
|
||||
|
||||
const buy = (pair: Pair, ts: number, price: number, levelIdx: number, g: ActiveGrid): void => {
|
||||
const fill = price * (1 + exec.slippage);
|
||||
const qty = g.budgetPerLevel / fill;
|
||||
const cost = qty * fill;
|
||||
const fee = cost * exec.feeRate;
|
||||
if (cash < cost + fee) return; // kein Cash → Fill entfällt
|
||||
cash -= cost + fee;
|
||||
g.lots[levelIdx] = {
|
||||
levelIdx, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee,
|
||||
riskAmount: Math.max((price - g.stopPrice) * qty, 1e-9),
|
||||
};
|
||||
};
|
||||
|
||||
const sell = (pair: Pair, ts: number, price: number, lot: Lot, reason: ClosedTrade['exitReason']): void => {
|
||||
const fill = price * (1 - exec.slippage);
|
||||
const proceeds = lot.qty * fill;
|
||||
const fee = proceeds * exec.feeRate;
|
||||
cash += proceeds - fee;
|
||||
const pnl = proceeds - fee - lot.entryCost;
|
||||
trades.push({
|
||||
pair, entryTs: lot.entryTs, entryPrice: lot.entryPrice, exitTs: ts, exitPrice: fill,
|
||||
qty: lot.qty, pnl, r: pnl / lot.riskAmount, exitReason: reason, side: 'long',
|
||||
});
|
||||
};
|
||||
|
||||
const liquidate = (pair: Pair, ts: number, price: number, reason: ClosedTrade['exitReason']): void => {
|
||||
const g = grids.get(pair);
|
||||
if (!g) return;
|
||||
for (const lot of g.lots) if (lot) sell(pair, ts, price, lot, reason);
|
||||
grids.delete(pair);
|
||||
};
|
||||
|
||||
// --- Kontexte + gemergte 15m-Timeline (wie runner.ts) ---
|
||||
const contexts = PAIRS.filter((pr) => candles15ByPair.has(pr)).map((pair) => {
|
||||
const c15 = candles15ByPair.get(pair)!;
|
||||
const c4h = aggregateTf(c15, p.tfMs);
|
||||
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h: 0 };
|
||||
});
|
||||
const byPair = new Map<Pair, (typeof contexts)[number]>(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 < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number]));
|
||||
|
||||
let lastEquityBucket = -1;
|
||||
|
||||
for (const { ts, pair, candle } of timeline) {
|
||||
const ctx = byPair.get(pair)!;
|
||||
const bucket = Math.floor(ts / p.tfMs) * p.tfMs;
|
||||
|
||||
// 1) Neu abgeschlossene Tf-Bars: Deaktivierung / Aktivierung
|
||||
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
|
||||
const i = ctx.next4h++;
|
||||
const bar = ctx.c4h[i];
|
||||
const barCloseTs = bar.ts + p.tfMs;
|
||||
if (barCloseTs < cfg.tradeFrom || barCloseTs >= cfg.tradeTo) continue;
|
||||
|
||||
const g = grids.get(pair);
|
||||
if (g) {
|
||||
const outOfRange = bar.close < g.stopPrice || bar.close > g.upperExit;
|
||||
if (p.hardStop) {
|
||||
const trendStart = !Number.isNaN(ctx.adx[i]) && ctx.adx[i] >= p.adxMax + 5; // Hysterese
|
||||
if (outOfRange || trendStart) liquidate(pair, barCloseTs, bar.close, 'grid_stop');
|
||||
} else if (outOfRange && g.lots.every((l) => !l)) {
|
||||
grids.delete(pair); // leeres Grid folgt dem Preis (Re-Center ohne Verlust)
|
||||
}
|
||||
} else if (
|
||||
!Number.isNaN(ctx.atr[i]) &&
|
||||
!Number.isNaN(ctx.adx[i]) &&
|
||||
ctx.adx[i] < p.adxMax
|
||||
) {
|
||||
const spacing = p.spacingAtrMult * ctx.atr[i];
|
||||
const budgetPerLevel = equity() / contexts.length / p.gridLevels;
|
||||
if (spacing > 0 && budgetPerLevel >= cfg.minNotionalUsdt) {
|
||||
grids.set(pair, {
|
||||
center: bar.close,
|
||||
spacing,
|
||||
stopPrice: bar.close - (p.gridLevels + 1) * spacing,
|
||||
upperExit: bar.close + (p.gridLevels + 1) * spacing,
|
||||
budgetPerLevel,
|
||||
lots: Array(p.gridLevels).fill(null),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 15m-Fills auf aktivem Grid: Sells zuerst (nur vor diesem Bar gekaufte Lots), dann Buys
|
||||
const g = grids.get(pair);
|
||||
if (g && ts >= cfg.tradeFrom) {
|
||||
for (let k = 0; k < g.lots.length; k++) {
|
||||
const lot = g.lots[k];
|
||||
if (!lot || lot.entryTs >= ts) continue;
|
||||
const tp = g.center - k * g.spacing; // ein Spacing über dem Buy-Level k (center − (k+1)·S)
|
||||
if (candle.high >= tp) {
|
||||
sell(pair, ts, tp, lot, 'grid_tp');
|
||||
g.lots[k] = null;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < g.lots.length; k++) {
|
||||
const levelPrice = g.center - (k + 1) * g.spacing;
|
||||
if (!g.lots[k] && candle.low <= levelPrice) buy(pair, ts, levelPrice, k, g);
|
||||
}
|
||||
}
|
||||
|
||||
lastClose.set(pair, candle.close);
|
||||
|
||||
// 3) Equity-Punkt einmal pro 4h-Bucket
|
||||
if (bucket !== lastEquityBucket && ts >= cfg.tradeFrom) {
|
||||
lastEquityBucket = bucket;
|
||||
equityCurve.push({ ts: bucket, equity: equity() });
|
||||
}
|
||||
}
|
||||
|
||||
// Zwangsglattstellung
|
||||
for (const pair of [...grids.keys()]) {
|
||||
liquidate(pair, cfg.tradeTo, lastClose.get(pair) ?? 0, 'end_of_data');
|
||||
}
|
||||
|
||||
return { trades, equityCurve, finalEquity: equity() };
|
||||
}
|
||||
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 {
|
||||
@@ -49,7 +50,7 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
if (candle.ts < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair) - PAIRS.indexOf(b.pair));
|
||||
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number]));
|
||||
|
||||
const byPair = new Map<Pair, PairContext>(contexts.map((c) => [c.pair, c]));
|
||||
let lastEquityBucket = -1;
|
||||
@@ -69,14 +70,25 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
// 1a) Trailing-Stop der offenen Position nachziehen
|
||||
const pos = portfolio.positions.get(pair);
|
||||
if (pos) {
|
||||
const next = updateChandelier(
|
||||
{ highestHigh: pos.highestHigh, stop: pos.stop },
|
||||
bar.high,
|
||||
ctx.ind.atr[i],
|
||||
cfg.params.atrMultiplier,
|
||||
);
|
||||
pos.highestHigh = next.highestHigh;
|
||||
pos.stop = next.stop;
|
||||
if (pos.side === 'short') {
|
||||
const next = updateChandelierShort(
|
||||
{ lowestLow: pos.trailExtreme, stop: pos.stop },
|
||||
bar.low,
|
||||
ctx.ind.atr[i],
|
||||
cfg.params.atrMultiplier,
|
||||
);
|
||||
pos.trailExtreme = next.lowestLow;
|
||||
pos.stop = next.stop;
|
||||
} else {
|
||||
const next = updateChandelier(
|
||||
{ highestHigh: pos.trailExtreme, stop: pos.stop },
|
||||
bar.high,
|
||||
ctx.ind.atr[i],
|
||||
cfg.params.atrMultiplier,
|
||||
);
|
||||
pos.trailExtreme = next.highestHigh;
|
||||
pos.stop = next.stop;
|
||||
}
|
||||
}
|
||||
|
||||
// 1b) Entry-Evaluation
|
||||
@@ -87,12 +99,17 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
barCloseTs >= cfg.tradeFrom &&
|
||||
barCloseTs < cfg.tradeTo
|
||||
) {
|
||||
const ev = evaluateAt(ctx.c4h, ctx.ind, i);
|
||||
const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params, cfg.allowShort);
|
||||
if (ev.signal === 'long') {
|
||||
const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr;
|
||||
const equity = portfolio.equity(lastClose);
|
||||
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk);
|
||||
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount);
|
||||
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'long');
|
||||
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'long');
|
||||
} else if (ev.signal === 'short') {
|
||||
const initialStop = ev.close + cfg.params.atrMultiplier * ev.atr;
|
||||
const equity = portfolio.equity(lastClose);
|
||||
const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'short');
|
||||
if (!s.blockedBy) portfolio.open(pair, barCloseTs, ev.close, initialStop, s.qty, s.riskAmount, 'short');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,9 +119,20 @@ export function runBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: BacktestC
|
||||
// ihre gesamte Range liegt nach dem Fill — eine echte Stop-Order wäre aktiv).
|
||||
// Pessimistisch-realistisch, nicht "wegoptimieren".
|
||||
const pos = portfolio.positions.get(pair);
|
||||
if (pos && candle.low <= pos.stop) {
|
||||
const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill
|
||||
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
|
||||
if (pos) {
|
||||
if (pos.side === 'short') {
|
||||
// Short: Stop wird getriggert wenn High >= Stop (Deckungskauf)
|
||||
if (candle.high >= pos.stop) {
|
||||
const exitPrice = candle.open > pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill (höherer Preis)
|
||||
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
|
||||
}
|
||||
} else {
|
||||
// Long: Stop wird getriggert wenn Low <= Stop
|
||||
if (candle.low <= pos.stop) {
|
||||
const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill
|
||||
portfolio.close(pair, ts, exitPrice, 'trailing_stop');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastClose.set(pair, candle.close);
|
||||
|
||||
14
src/server/backtest/trump.ts
Normal file
14
src/server/backtest/trump.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { processTrumpCycle, type TrumpCycleConfig, type TrumpCycleResult, type TrumpEventInput } from '../live/trump-cycle';
|
||||
|
||||
/** Backtest = ein Cycle-Lauf von frischem State. Gleicher Code-Pfad wie Live (Spec §5). */
|
||||
export function runTrumpBacktest(
|
||||
candles15ByPair: Map<Pair, Candle[]>,
|
||||
events: TrumpEventInput[],
|
||||
startCapital: number,
|
||||
cfg: TrumpCycleConfig,
|
||||
): TrumpCycleResult {
|
||||
let minTs = Infinity;
|
||||
for (const cs of candles15ByPair.values()) if (cs.length > 0) minTs = Math.min(minTs, cs[0].ts);
|
||||
return processTrumpCycle(candles15ByPair, events, { cash: startCapital, positions: [], cursorTs: minTs - 1 }, cfg);
|
||||
}
|
||||
@@ -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,96 @@ 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(),
|
||||
bot: text('bot').notNull().default('trend'), // 'trend' | 'grid' | 'trump'
|
||||
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',
|
||||
{
|
||||
bot: text('bot').notNull().default('trend'),
|
||||
ts: timestamp('ts', { withTimezone: true }).notNull(), // 4h-Bucket
|
||||
equity: doublePrecision('equity').notNull(),
|
||||
cash: doublePrecision('cash').notNull(),
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.bot, t.ts] })],
|
||||
);
|
||||
|
||||
/** Aktives Grid je Pair (No-Stop-GridBot). */
|
||||
export const gridState = pgTable('grid_state', {
|
||||
pair: varchar('pair', { length: 16 }).primaryKey(),
|
||||
center: doublePrecision('center').notNull(),
|
||||
spacing: doublePrecision('spacing').notNull(),
|
||||
lowerBound: doublePrecision('lower_bound').notNull(),
|
||||
upperBound: doublePrecision('upper_bound').notNull(),
|
||||
budgetPerLevel: doublePrecision('budget_per_level').notNull(),
|
||||
activatedTs: timestamp('activated_ts', { withTimezone: true }).notNull(),
|
||||
});
|
||||
|
||||
/** Offene Grid-Lots (Inventar). */
|
||||
export const gridLots = pgTable('grid_lots', {
|
||||
id: serial('id').primaryKey(),
|
||||
pair: varchar('pair', { length: 16 }).notNull(),
|
||||
levelIdx: integer('level_idx').notNull(),
|
||||
qty: doublePrecision('qty').notNull(),
|
||||
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
||||
entryPrice: doublePrecision('entry_price').notNull(),
|
||||
entryCost: doublePrecision('entry_cost').notNull(),
|
||||
riskAmount: doublePrecision('risk_amount').notNull(),
|
||||
});
|
||||
|
||||
export const backtestRuns = pgTable('backtest_runs', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
@@ -21,3 +111,39 @@ export const backtestRuns = pgTable('backtest_runs', {
|
||||
config: jsonb('config').notNull(),
|
||||
result: jsonb('result').notNull(),
|
||||
});
|
||||
|
||||
/** „Trump kauft"-Events (on-chain Transfers in Watchlist-Wallets, Truth-Social-Erwähnungen). */
|
||||
export const trumpEvents = pgTable(
|
||||
'trump_events',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
source: text('source').notNull(), // 'onchain' | 'truth'
|
||||
token: text('token').notNull(), // Symbol, z. B. 'WBTC', 'TRX'
|
||||
instrument: varchar('instrument', { length: 16 }), // null = nicht auf Crypto.com handelbar
|
||||
eventTs: timestamp('event_ts', { withTimezone: true }).notNull(),
|
||||
ref: text('ref').notNull(), // Tx-Hash bzw. Post-URL
|
||||
notionalUsd: doublePrecision('notional_usd'), // nur onchain
|
||||
consumedAt: timestamp('consumed_at', { withTimezone: true }), // null = noch nicht von der Engine verarbeitet
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [uniqueIndex('trump_events_source_ref_token').on(t.source, t.ref, t.token)],
|
||||
);
|
||||
|
||||
/** Offene Positionen der Trump-Engine (Zeit-Exit, kein Stop). */
|
||||
export const trumpPositions = pgTable('trump_positions', {
|
||||
pair: varchar('pair', { length: 16 }).primaryKey(),
|
||||
qty: doublePrecision('qty').notNull(),
|
||||
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
||||
entryPrice: doublePrecision('entry_price').notNull(),
|
||||
entryCost: doublePrecision('entry_cost').notNull(),
|
||||
riskAmount: doublePrecision('risk_amount').notNull(), // = entryCost → r = Return auf Einsatz
|
||||
exitDueTs: timestamp('exit_due_ts', { withTimezone: true }).notNull(),
|
||||
eventId: integer('event_id').notNull(),
|
||||
});
|
||||
|
||||
/** Cursor des On-Chain-Pollers (letzter vollständig gescannter Block). */
|
||||
export const trumpSignalState = pgTable('trump_signal_state', {
|
||||
id: integer('id').primaryKey(), // immer 1
|
||||
lastBlock: integer('last_block').notNull(),
|
||||
updatedAt: timestamp('updated_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' | 'grid_tp' | 'grid_stop' | 'trump_hold';
|
||||
side: 'long' | 'short';
|
||||
}
|
||||
|
||||
|
||||
25
src/server/index.ts
Normal file
25
src/server/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { env } from './config';
|
||||
import { LiveEngine } from './live/engine';
|
||||
import { GridEngine } from './live/grid-engine';
|
||||
import { TrumpEngine } from './live/trump-engine';
|
||||
import { createServer } from './api/server';
|
||||
|
||||
const CYCLE_MS = 5 * 60 * 1000;
|
||||
|
||||
const engine = new LiveEngine();
|
||||
const gridEngine = new GridEngine();
|
||||
const trumpEngine = new TrumpEngine();
|
||||
await engine.init();
|
||||
await gridEngine.init();
|
||||
await trumpEngine.init();
|
||||
createServer(engine, gridEngine, trumpEngine, env.PORT);
|
||||
console.log(`trade-kuns Live-Paper-Engines (trend + grid + trump) laufen auf :${env.PORT}`);
|
||||
|
||||
// Grid/Trump laufen nach der Trend-Engine — deren Gap-Fetch füllt die Candle-DB für PAIRS.
|
||||
const cycle = async () => {
|
||||
await engine.runCycle();
|
||||
await gridEngine.runCycle();
|
||||
await trumpEngine.runCycle();
|
||||
};
|
||||
void cycle();
|
||||
setInterval(() => void cycle(), 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;
|
||||
}
|
||||
229
src/server/live/engine.ts
Normal file
229
src/server/live/engine.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
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 = { bot: 'trend', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||
await tx
|
||||
.insert(equitySnapshots)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: [equitySnapshots.bot, 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
85
src/server/live/grid-cycle.test.ts
Normal file
85
src/server/live/grid-cycle.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import { runGridBacktest, DEFAULT_GRID_PARAMS } from '../backtest/grid';
|
||||
import { processGridCycle, type GridCycleConfig, type GridLiveState } from './grid-cycle';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const PAIR: Pair = 'XRP_USDT';
|
||||
const T0 = Date.UTC(2025, 0, 1);
|
||||
|
||||
// Live-Spec: No-Stop, ADX-Filter aus, 3×ATR, 8 Levels
|
||||
const PARAMS = { ...DEFAULT_GRID_PARAMS, spacingAtrMult: 3, gridLevels: 8, adxMax: 100, hardStop: false };
|
||||
const CFG: GridCycleConfig = { exec: DEFAULT_EXEC, params: PARAMS, minNotionalUsdt: 10, pairs: [PAIR] };
|
||||
|
||||
/** Volatile Serie: Trend + zwei Crash-Erholungs-Zyklen — füllt Levels und löst TPs aus. */
|
||||
function synthetic(): Candle[] {
|
||||
const out: Candle[] = [];
|
||||
let price = 1.0;
|
||||
for (let k = 0; k < 16 * 400; k++) {
|
||||
const phase = Math.floor(k / 16);
|
||||
let drift = 0.0002;
|
||||
if ((phase > 120 && phase < 150) || (phase > 260 && phase < 290)) drift = -0.004; // Crashes
|
||||
if ((phase >= 150 && phase < 220) || (phase >= 290 && phase < 360)) drift = 0.0025; // Erholungen
|
||||
const open = price;
|
||||
price = Math.max(0.1, price + drift + 0.01 * Math.sin(k / 5));
|
||||
out.push({
|
||||
ts: T0 + k * M15, open, high: Math.max(open, price) + 0.005,
|
||||
low: Math.min(open, price) - 0.005, close: price, volume: 1,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function freshState(c15: Candle[]): GridLiveState {
|
||||
return { cash: 1000, grids: new Map(), cursorTs: c15[0].ts - 1 };
|
||||
}
|
||||
|
||||
describe('processGridCycle', () => {
|
||||
test('Parität mit runGridBacktest (identische grid_tp-Trades)', () => {
|
||||
const c15 = synthetic();
|
||||
const map = new Map([[PAIR, c15]]);
|
||||
const live = processGridCycle(map, freshState(c15), CFG);
|
||||
const bt = runGridBacktest(map, {
|
||||
startCapital: 1000, exec: DEFAULT_EXEC, params: PARAMS, minNotionalUsdt: 10,
|
||||
tradeFrom: 0, tradeTo: c15[c15.length - 1].ts + M15,
|
||||
});
|
||||
const btTps = bt.trades.filter((t) => t.exitReason === 'grid_tp');
|
||||
expect(live.closedTrades.length).toBeGreaterThanOrEqual(3);
|
||||
expect(live.closedTrades).toEqual(btTps);
|
||||
});
|
||||
|
||||
test('Split-Äquivalenz: ein Lauf ≡ zwei Läufe mit Cut', () => {
|
||||
const c15 = synthetic();
|
||||
const map = new Map([[PAIR, c15]]);
|
||||
const full = processGridCycle(map, freshState(c15), CFG);
|
||||
|
||||
const cut = c15[Math.floor(c15.length * 0.6)].ts;
|
||||
const r1 = processGridCycle(new Map([[PAIR, c15.filter((c) => c.ts <= cut)]]), freshState(c15), CFG);
|
||||
const r2 = processGridCycle(map, { cash: r1.cash, grids: r1.grids, cursorTs: r1.cursorTs }, CFG);
|
||||
|
||||
expect(r2.cursorTs).toBe(full.cursorTs);
|
||||
expect(r2.cash).toBeCloseTo(full.cash, 8);
|
||||
expect([...r1.closedTrades, ...r2.closedTrades]).toEqual(full.closedTrades);
|
||||
expect(JSON.stringify([...r2.grids.entries()])).toBe(JSON.stringify([...full.grids.entries()]));
|
||||
});
|
||||
|
||||
test('Idempotenz: zweiter Lauf ohne neue Candles ist No-op', () => {
|
||||
const c15 = synthetic();
|
||||
const map = new Map([[PAIR, c15]]);
|
||||
const r1 = processGridCycle(map, freshState(c15), CFG);
|
||||
const r2 = processGridCycle(map, { cash: r1.cash, grids: r1.grids, cursorTs: r1.cursorTs }, CFG);
|
||||
expect(r2.closedTrades).toEqual([]);
|
||||
expect(r2.cash).toBe(r1.cash);
|
||||
expect(r2.cursorTs).toBe(r1.cursorTs);
|
||||
});
|
||||
|
||||
test('No-Stop-Invariante: kein Trade mit Verlust außer end_of_data', () => {
|
||||
const c15 = synthetic();
|
||||
const res = processGridCycle(new Map([[PAIR, c15]]), freshState(c15), CFG);
|
||||
for (const t of res.closedTrades) {
|
||||
expect(t.exitReason).toBe('grid_tp');
|
||||
expect(t.pnl).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
189
src/server/live/grid-cycle.ts
Normal file
189
src/server/live/grid-cycle.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { PAIRS } from '../types';
|
||||
import { aggregateTf } from '../market/aggregate';
|
||||
import { atr } from '../indicators/atr';
|
||||
import { adx } from '../indicators/adx';
|
||||
import type { ClosedTrade, ExecConfig } from '../engine/portfolio';
|
||||
import type { GridParams } from '../backtest/grid';
|
||||
import type { EquitySnapshot } from './process-cycle';
|
||||
|
||||
export interface GridLot {
|
||||
levelIdx: number;
|
||||
qty: number;
|
||||
entryTs: number;
|
||||
entryPrice: number;
|
||||
entryCost: number;
|
||||
riskAmount: number;
|
||||
}
|
||||
|
||||
export interface GridStateForPair {
|
||||
center: number;
|
||||
spacing: number;
|
||||
lowerBound: number; // center − (N+1)·spacing
|
||||
upperBound: number; // center + (N+1)·spacing
|
||||
budgetPerLevel: number;
|
||||
activatedTs: number;
|
||||
lots: (GridLot | null)[]; // Index = Level
|
||||
}
|
||||
|
||||
export interface GridLiveState {
|
||||
cash: number;
|
||||
grids: Map<Pair, GridStateForPair>;
|
||||
cursorTs: number;
|
||||
}
|
||||
|
||||
export interface GridCycleConfig {
|
||||
exec: ExecConfig;
|
||||
params: GridParams; // hardStop muss false sein (No-Stop-Design)
|
||||
minNotionalUsdt: number;
|
||||
pairs: Pair[];
|
||||
}
|
||||
|
||||
export interface GridCycleResult {
|
||||
cash: number;
|
||||
grids: Map<Pair, GridStateForPair>;
|
||||
cursorTs: number;
|
||||
closedTrades: ClosedTrade[];
|
||||
equitySnapshots: EquitySnapshot[];
|
||||
equity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cursor-inkrementelle Variante von runGridBacktest (No-Stop-Semantik):
|
||||
* Tf-Close: Aktivierung / verlustfreies Re-Center bei leerem Grid außerhalb der Range;
|
||||
* 15m: Sells (nur Lots von vor diesem Bar) vor Buys. Lots werden nie mit Verlust
|
||||
* verkauft. Pure Funktion — Paritätstest gegen runGridBacktest erzwingt Gleichheit.
|
||||
*/
|
||||
export function processGridCycle(
|
||||
candles15ByPair: Map<Pair, Candle[]>,
|
||||
state: GridLiveState,
|
||||
cfg: GridCycleConfig,
|
||||
): GridCycleResult {
|
||||
const { exec, params: p } = cfg;
|
||||
let cash = state.cash;
|
||||
const grids = new Map<Pair, GridStateForPair>();
|
||||
for (const [pair, g] of state.grids) grids.set(pair, { ...g, lots: g.lots.map((l) => (l ? { ...l } : null)) });
|
||||
const trades: ClosedTrade[] = [];
|
||||
const equitySnapshots: EquitySnapshot[] = [];
|
||||
const lastClose = new Map<Pair, number>();
|
||||
const cursorBucket = Math.floor(state.cursorTs / p.tfMs) * p.tfMs;
|
||||
|
||||
const pairs = cfg.pairs.filter((pr) => candles15ByPair.has(pr));
|
||||
|
||||
const equity = (): number => {
|
||||
let eq = cash;
|
||||
for (const [pair, g] of grids) {
|
||||
const last = lastClose.get(pair) ?? 0;
|
||||
for (const lot of g.lots) if (lot) eq += lot.qty * last;
|
||||
}
|
||||
return eq;
|
||||
};
|
||||
|
||||
const contexts = pairs.map((pair) => {
|
||||
const c15 = candles15ByPair.get(pair)!;
|
||||
const c4h = aggregateTf(c15, p.tfMs);
|
||||
let next4h = 0;
|
||||
while (next4h < c4h.length && c4h[next4h].ts < cursorBucket) next4h++;
|
||||
for (const c of c15) {
|
||||
if (c.ts > state.cursorTs) break;
|
||||
lastClose.set(pair, c.close);
|
||||
}
|
||||
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h };
|
||||
});
|
||||
const byPair = new Map<Pair, (typeof contexts)[number]>(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 as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number]));
|
||||
|
||||
const sell = (pair: Pair, ts: number, price: number, lot: GridLot, reason: ClosedTrade['exitReason']): void => {
|
||||
const fill = price * (1 - exec.slippage);
|
||||
const proceeds = lot.qty * fill;
|
||||
const fee = proceeds * exec.feeRate;
|
||||
cash += proceeds - fee;
|
||||
const pnl = proceeds - fee - lot.entryCost;
|
||||
trades.push({
|
||||
pair, entryTs: lot.entryTs, entryPrice: lot.entryPrice, exitTs: ts, exitPrice: fill,
|
||||
qty: lot.qty, pnl, r: pnl / lot.riskAmount, exitReason: reason, side: 'long',
|
||||
});
|
||||
};
|
||||
|
||||
let cursorTs = state.cursorTs;
|
||||
let lastEquityBucket = -1;
|
||||
|
||||
for (const { ts, pair, candle } of timeline) {
|
||||
const ctx = byPair.get(pair)!;
|
||||
const bucket = Math.floor(ts / p.tfMs) * p.tfMs;
|
||||
|
||||
// 1) Neu abgeschlossene Tf-Bars: Aktivierung / verlustfreies Re-Center
|
||||
while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) {
|
||||
const i = ctx.next4h++;
|
||||
const bar = ctx.c4h[i];
|
||||
|
||||
const g = grids.get(pair);
|
||||
if (g) {
|
||||
const outOfRange = bar.close < g.lowerBound || bar.close > g.upperBound;
|
||||
if (outOfRange && g.lots.every((l) => !l)) grids.delete(pair);
|
||||
} else if (!Number.isNaN(ctx.atr[i]) && !Number.isNaN(ctx.adx[i]) && ctx.adx[i] < p.adxMax) {
|
||||
const spacing = p.spacingAtrMult * ctx.atr[i];
|
||||
const budgetPerLevel = equity() / pairs.length / p.gridLevels;
|
||||
if (spacing > 0 && budgetPerLevel >= cfg.minNotionalUsdt) {
|
||||
grids.set(pair, {
|
||||
center: bar.close,
|
||||
spacing,
|
||||
lowerBound: bar.close - (p.gridLevels + 1) * spacing,
|
||||
upperBound: bar.close + (p.gridLevels + 1) * spacing,
|
||||
budgetPerLevel,
|
||||
activatedTs: bar.ts + p.tfMs,
|
||||
lots: Array(p.gridLevels).fill(null),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 15m-Fills: Sells zuerst (nur Lots von vor diesem Bar), dann Buys
|
||||
const g = grids.get(pair);
|
||||
if (g) {
|
||||
for (let k = 0; k < g.lots.length; k++) {
|
||||
const lot = g.lots[k];
|
||||
if (!lot || lot.entryTs >= ts) continue;
|
||||
const tp = g.center - k * g.spacing;
|
||||
if (candle.high >= tp) {
|
||||
sell(pair, ts, tp, lot, 'grid_tp');
|
||||
g.lots[k] = null;
|
||||
}
|
||||
}
|
||||
for (let k = 0; k < g.lots.length; k++) {
|
||||
const levelPrice = g.center - (k + 1) * g.spacing;
|
||||
if (!g.lots[k] && candle.low <= levelPrice) {
|
||||
const fill = levelPrice * (1 + exec.slippage);
|
||||
const qty = g.budgetPerLevel / fill;
|
||||
const cost = qty * fill;
|
||||
const fee = cost * exec.feeRate;
|
||||
if (cash >= cost + fee) {
|
||||
cash -= cost + fee;
|
||||
g.lots[k] = {
|
||||
levelIdx: k, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee,
|
||||
riskAmount: Math.max((levelPrice - g.lowerBound) * qty, 1e-9),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastClose.set(pair, candle.close);
|
||||
cursorTs = Math.max(cursorTs, ts);
|
||||
|
||||
// 3) Equity-Punkt einmal pro Tf-Bucket
|
||||
if (bucket !== lastEquityBucket) {
|
||||
lastEquityBucket = bucket;
|
||||
equitySnapshots.push({ ts: bucket, equity: equity(), cash });
|
||||
}
|
||||
}
|
||||
|
||||
return { cash, grids, cursorTs, closedTrades: trades, equitySnapshots, equity: equity() };
|
||||
}
|
||||
181
src/server/live/grid-engine.ts
Normal file
181
src/server/live/grid-engine.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { botState, equitySnapshots, gridLots, gridState, paperTrades } from '../db/schema';
|
||||
import { getCandles } from '../market/candle-store';
|
||||
import { H4 } from '../market/aggregate';
|
||||
import { DEFAULT_GRID_PARAMS } from '../backtest/grid';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import type { Candle, Pair } from '../types';
|
||||
import {
|
||||
processGridCycle,
|
||||
type GridCycleConfig,
|
||||
type GridCycleResult,
|
||||
type GridLiveState,
|
||||
type GridStateForPair,
|
||||
} from './grid-cycle';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const BOT_STATE_ID = 2; // id=1 gehört der Trend-Engine
|
||||
const START_CAPITAL = 1000;
|
||||
/** ATR/ADX(14) brauchen nur ~30 Bars — 200 ist reichlich Warmup. */
|
||||
const WARMUP_TF_BARS = 200;
|
||||
|
||||
/** Live-Spec aus dem Walk-Forward: No-Stop, XRP only, 3×ATR, 8 Levels, kein ADX-Filter. */
|
||||
export const GRID_CYCLE_CONFIG: GridCycleConfig = {
|
||||
exec: DEFAULT_EXEC,
|
||||
params: { ...DEFAULT_GRID_PARAMS, spacingAtrMult: 3, gridLevels: 8, adxMax: 100, hardStop: false },
|
||||
minNotionalUsdt: 10,
|
||||
pairs: ['XRP_USDT'],
|
||||
};
|
||||
|
||||
export interface GridEngineStatus {
|
||||
lastCycleAt: number | null;
|
||||
lastCycleOk: boolean;
|
||||
lastError: string | null;
|
||||
cursorTs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zweite Paper-Engine (No-Stop-Grid). Holt selbst keine Candles —
|
||||
* läuft im Zyklus NACH der Trend-Engine, deren Gap-Fetch die DB füllt.
|
||||
*/
|
||||
export class GridEngine {
|
||||
status: GridEngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, cursorTs: null };
|
||||
private cycling = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||
if (row) {
|
||||
this.status.cursorTs = row.cursorTs.getTime();
|
||||
return;
|
||||
}
|
||||
const cursor = Math.floor(Date.now() / M15) * M15 - M15;
|
||||
await db.insert(botState).values({
|
||||
id: BOT_STATE_ID,
|
||||
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();
|
||||
const tfMs = GRID_CYCLE_CONFIG.params.tfMs;
|
||||
const from = Math.floor(state.cursorTs / tfMs) * tfMs - WARMUP_TF_BARS * tfMs;
|
||||
const candles15 = new Map<Pair, Candle[]>();
|
||||
for (const pair of GRID_CYCLE_CONFIG.pairs) {
|
||||
candles15.set(pair, await getCandles(pair, from));
|
||||
}
|
||||
const result = processGridCycle(candles15, state, GRID_CYCLE_CONFIG);
|
||||
await this.persist(state, result);
|
||||
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('Grid-Zyklus fehlgeschlagen:', err);
|
||||
} finally {
|
||||
this.cycling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadState(): Promise<GridLiveState> {
|
||||
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||
if (!row) throw new Error('bot_state (grid) fehlt — init() nicht gelaufen?');
|
||||
const stateRows = await db.select().from(gridState);
|
||||
const lotRows = await db.select().from(gridLots);
|
||||
const grids = new Map<Pair, GridStateForPair>();
|
||||
for (const s of stateRows) {
|
||||
const levels = GRID_CYCLE_CONFIG.params.gridLevels;
|
||||
const lots: GridStateForPair['lots'] = Array(levels).fill(null);
|
||||
for (const l of lotRows) {
|
||||
if (l.pair === s.pair && l.levelIdx < levels) {
|
||||
lots[l.levelIdx] = {
|
||||
levelIdx: l.levelIdx,
|
||||
qty: l.qty,
|
||||
entryTs: l.entryTs.getTime(),
|
||||
entryPrice: l.entryPrice,
|
||||
entryCost: l.entryCost,
|
||||
riskAmount: l.riskAmount,
|
||||
};
|
||||
}
|
||||
}
|
||||
grids.set(s.pair as Pair, {
|
||||
center: s.center,
|
||||
spacing: s.spacing,
|
||||
lowerBound: s.lowerBound,
|
||||
upperBound: s.upperBound,
|
||||
budgetPerLevel: s.budgetPerLevel,
|
||||
activatedTs: s.activatedTs.getTime(),
|
||||
lots,
|
||||
});
|
||||
}
|
||||
return { cash: row.cash, grids, cursorTs: row.cursorTs.getTime() };
|
||||
}
|
||||
|
||||
private async persist(prev: GridLiveState, result: GridCycleResult): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
// Grid-State + Lots vollständig ersetzen (kleine Mengen: ≤1 Grid, ≤8 Lots)
|
||||
await tx.delete(gridLots);
|
||||
await tx.delete(gridState);
|
||||
for (const [pair, g] of result.grids) {
|
||||
await tx.insert(gridState).values({
|
||||
pair,
|
||||
center: g.center,
|
||||
spacing: g.spacing,
|
||||
lowerBound: g.lowerBound,
|
||||
upperBound: g.upperBound,
|
||||
budgetPerLevel: g.budgetPerLevel,
|
||||
activatedTs: new Date(g.activatedTs),
|
||||
});
|
||||
for (const lot of g.lots) {
|
||||
if (!lot) continue;
|
||||
await tx.insert(gridLots).values({
|
||||
pair,
|
||||
levelIdx: lot.levelIdx,
|
||||
qty: lot.qty,
|
||||
entryTs: new Date(lot.entryTs),
|
||||
entryPrice: lot.entryPrice,
|
||||
entryCost: lot.entryCost,
|
||||
riskAmount: lot.riskAmount,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (result.closedTrades.length > 0) {
|
||||
await tx.insert(paperTrades).values(
|
||||
result.closedTrades.map((t) => ({
|
||||
bot: 'grid',
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
for (const s of result.equitySnapshots) {
|
||||
const row = { bot: 'grid', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||
await tx
|
||||
.insert(equitySnapshots)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row });
|
||||
}
|
||||
await tx
|
||||
.update(botState)
|
||||
.set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() })
|
||||
.where(eq(botState.id, BOT_STATE_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<Pair, (typeof contexts)[number]>(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 as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number]));
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
178
src/server/live/trump-cycle.test.ts
Normal file
178
src/server/live/trump-cycle.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import { processTrumpCycle, type TrumpCycleConfig, type TrumpLiveState } from './trump-cycle';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const T0 = 1_750_000_000_000 - (1_750_000_000_000 % (4 * 3600_000)); // 4h-aligned
|
||||
|
||||
function flat(pair: Pair, n: number, price = 100): Candle[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
ts: T0 + i * M15, open: price, high: price, low: price, close: price, volume: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
const CFG: TrumpCycleConfig = {
|
||||
exec: DEFAULT_EXEC, holdHours: 60, equityFraction: 0.2,
|
||||
maxPositions: 5, minNotionalUsdt: 10, pairs: ['BTC_USDT', 'ETH_USDT'],
|
||||
};
|
||||
const fresh = (): TrumpLiveState => ({ cash: 10_000, positions: [], cursorTs: T0 });
|
||||
|
||||
describe('processTrumpCycle', () => {
|
||||
test('Event → Buy am Open der ersten Candle nach eventTs, 20% Equity', () => {
|
||||
const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 200)]]);
|
||||
const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + M15 + 1 }];
|
||||
const r = processTrumpCycle(candles, events, fresh(), CFG);
|
||||
expect(r.positions).toHaveLength(1);
|
||||
expect(r.positions[0].entryTs).toBe(T0 + 2 * M15); // erste Candle mit ts ≥ eventTs
|
||||
expect(r.positions[0].entryCost).toBeCloseTo(10_000 * 0.2, 0);
|
||||
expect(r.consumed).toEqual([{ eventId: 1, consumedAt: T0 + 2 * M15 }]);
|
||||
});
|
||||
|
||||
test('Zeit-Exit zum Close nach genau holdHours, exitReason trump_hold', () => {
|
||||
const n = 60 * 4 + 20; // > 60h an 15m-Candles
|
||||
const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', n)]]);
|
||||
const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 }];
|
||||
const r = processTrumpCycle(candles, events, fresh(), CFG);
|
||||
expect(r.positions).toHaveLength(0);
|
||||
expect(r.closedTrades).toHaveLength(1);
|
||||
const t = r.closedTrades[0];
|
||||
expect(t.exitReason).toBe('trump_hold');
|
||||
// Entry bei T0+M15 (erste Candle > Cursor), Exit-Candle: ts + M15 ≥ entry + 60h
|
||||
expect(t.exitTs).toBe(T0 + M15 + 60 * 3600_000 - M15);
|
||||
// Flat-Markt → Verlust = Round-Trip-Kosten (Fee+Slippage beide Seiten)
|
||||
expect(t.pnl).toBeLessThan(0);
|
||||
expect(t.r).toBeCloseTo(t.pnl / (10_000 * 0.2), 3);
|
||||
});
|
||||
|
||||
test('Event verfällt, wenn Pair schon belegt (consumed, kein 2. Trade)', () => {
|
||||
const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 200)]]);
|
||||
const events = [
|
||||
{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 },
|
||||
{ id: 2, instrument: 'BTC_USDT' as Pair, eventTs: T0 + M15 + 1 },
|
||||
];
|
||||
const r = processTrumpCycle(candles, events, fresh(), CFG);
|
||||
expect(r.positions).toHaveLength(1);
|
||||
expect(r.consumed.map((c) => c.eventId).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('maxPositions blockiert, Event verfällt', () => {
|
||||
const cfg = { ...CFG, maxPositions: 1 };
|
||||
const candles = new Map([
|
||||
['BTC_USDT' as Pair, flat('BTC_USDT', 200)],
|
||||
['ETH_USDT' as Pair, flat('ETH_USDT', 200, 50)],
|
||||
]);
|
||||
const events = [
|
||||
{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 },
|
||||
{ id: 2, instrument: 'ETH_USDT' as Pair, eventTs: T0 + 1 },
|
||||
];
|
||||
const r = processTrumpCycle(candles, events, fresh(), cfg);
|
||||
expect(r.positions).toHaveLength(1);
|
||||
expect(r.positions[0].pair).toBe('BTC_USDT'); // cfg.pairs-Reihenfolge bei ts-Gleichstand
|
||||
expect(r.consumed).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('minNotional-Ablehnung: Budget < minNotionalUsdt → kein Trade, Event konsumiert', () => {
|
||||
// equityFraction 0.2 × cash 10000 = 2000 < minNotionalUsdt 5000
|
||||
const cfg = { ...CFG, minNotionalUsdt: 5000 };
|
||||
const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 200)]]);
|
||||
const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 }];
|
||||
const r = processTrumpCycle(candles, events, fresh(), cfg);
|
||||
expect(r.positions).toHaveLength(0);
|
||||
expect(r.closedTrades).toHaveLength(0);
|
||||
expect(r.consumed).toEqual([{ eventId: 1, consumedAt: T0 + M15 }]);
|
||||
expect(r.cash).toBe(10_000);
|
||||
});
|
||||
|
||||
test('Cash-Erschöpfung: budget > minNotional aber cash < cost → kein neuer Trade, Event konsumiert', () => {
|
||||
// Equity ≈ 100 + 100×100 = 10100, budget ≈ 2020 > minNotional 10
|
||||
// aber cash 100 < cost (2020) → Entry wird abgelehnt
|
||||
const state: TrumpLiveState = {
|
||||
cash: 100,
|
||||
positions: [{
|
||||
pair: 'ETH_USDT' as Pair,
|
||||
qty: 100,
|
||||
entryTs: T0 - 10 * M15,
|
||||
entryPrice: 99,
|
||||
entryCost: 9900,
|
||||
riskAmount: 9900,
|
||||
exitDueTs: T0 + 1000 * M15,
|
||||
eventId: 99,
|
||||
}],
|
||||
cursorTs: T0,
|
||||
};
|
||||
const candles = new Map([
|
||||
['BTC_USDT' as Pair, flat('BTC_USDT', 200, 100)],
|
||||
['ETH_USDT' as Pair, flat('ETH_USDT', 200, 100)],
|
||||
]);
|
||||
const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 }];
|
||||
const r = processTrumpCycle(candles, events, state, CFG);
|
||||
// ETH-Position bleibt, kein neuer BTC-Trade
|
||||
expect(r.positions).toHaveLength(1);
|
||||
expect(r.positions[0].pair).toBe('ETH_USDT');
|
||||
// Event konsumiert
|
||||
expect(r.consumed).toHaveLength(1);
|
||||
expect(r.consumed[0].eventId).toBe(1);
|
||||
// Cash unverändert
|
||||
expect(r.cash).toBe(100);
|
||||
});
|
||||
|
||||
test('Re-Entry nach Exit im selben Pair am selben Bar (Exit vor Entry)', () => {
|
||||
// holdHours 1 → holdMs = 4×M15
|
||||
// Cursor T0, Event id=1 eventTs=T0: Entry an Candle T0+M15, exitDueTs=T0+5*M15
|
||||
// Exit-Bedingung: ts+M15 >= T0+5*M15 → ts >= T0+4*M15 → Exit-Candle ts=T0+4*M15
|
||||
// Event id=2 eventTs=T0+4*M15: an selber Candle → Exit läuft zuerst, dann Entry
|
||||
// Candles auf 6 begrenzen (T0..T0+5*M15): zweite Position hat exitDueTs=T0+8*M15
|
||||
// → kein zweiter Exit innerhalb der Candle-Fensters → r.positions enthält noch die neue Position
|
||||
const cfg = { ...CFG, holdHours: 1 };
|
||||
const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 6)]]);
|
||||
const events = [
|
||||
{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 },
|
||||
{ id: 2, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 4 * M15 },
|
||||
];
|
||||
const r = processTrumpCycle(candles, events, fresh(), cfg);
|
||||
// Erster Trade geschlossen: Exit an ts=T0+4*M15
|
||||
expect(r.closedTrades).toHaveLength(1);
|
||||
expect(r.closedTrades[0].exitTs).toBe(T0 + 4 * M15);
|
||||
expect(r.closedTrades[0].exitReason).toBe('trump_hold');
|
||||
// Re-Entry: neue offene Position mit entryTs=T0+4*M15 und eventId=2
|
||||
expect(r.positions).toHaveLength(1);
|
||||
expect(r.positions[0].entryTs).toBe(T0 + 4 * M15);
|
||||
expect(r.positions[0].eventId).toBe(2);
|
||||
// Beide Events konsumiert
|
||||
expect(r.consumed.map((c) => c.eventId).sort()).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test('Cursor-Idempotenz: gesplittete Zyklen ≡ ein Zyklus (Parität)', () => {
|
||||
const n = 300;
|
||||
const wave = (pair: Pair, base: number): Candle[] =>
|
||||
Array.from({ length: n }, (_, i) => {
|
||||
const p = base * (1 + 0.05 * Math.sin(i / 7));
|
||||
return { ts: T0 + i * M15, open: p, high: p * 1.002, low: p * 0.998, close: p * 1.001, volume: 1 };
|
||||
});
|
||||
const candles = new Map([['BTC_USDT' as Pair, wave('BTC_USDT', 100)], ['ETH_USDT' as Pair, wave('ETH_USDT', 50)]]);
|
||||
const events = [
|
||||
{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 5 * M15 },
|
||||
{ id: 2, instrument: 'ETH_USDT' as Pair, eventTs: T0 + 80 * M15 },
|
||||
{ id: 3, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 290 * M15 },
|
||||
];
|
||||
const oneShot = processTrumpCycle(candles, events, fresh(), CFG);
|
||||
|
||||
let state = fresh();
|
||||
const trades: any[] = [];
|
||||
const consumedIds = new Set<number>();
|
||||
for (const splitAt of [T0 + 23 * M15, T0 + 100 * M15, T0 + 222 * M15, T0 + (n - 1) * M15]) {
|
||||
const sliced = new Map(
|
||||
[...candles].map(([p, cs]) => [p, cs.filter((c) => c.ts <= splitAt)]),
|
||||
);
|
||||
const remaining = events.filter((e) => !consumedIds.has(e.id));
|
||||
const r = processTrumpCycle(sliced, remaining, state, CFG);
|
||||
for (const c of r.consumed) consumedIds.add(c.eventId);
|
||||
trades.push(...r.closedTrades);
|
||||
state = { cash: r.cash, positions: r.positions, cursorTs: r.cursorTs };
|
||||
}
|
||||
expect(state.cash).toBeCloseTo(oneShot.cash, 8);
|
||||
expect(trades).toEqual(oneShot.closedTrades);
|
||||
expect(state.positions).toEqual(oneShot.positions);
|
||||
});
|
||||
});
|
||||
161
src/server/live/trump-cycle.ts
Normal file
161
src/server/live/trump-cycle.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { Candle, Pair } from '../types';
|
||||
import { H4 } from '../market/aggregate';
|
||||
import type { ClosedTrade, ExecConfig } from '../engine/portfolio';
|
||||
import type { EquitySnapshot } from './process-cycle';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
|
||||
export interface TrumpEventInput {
|
||||
id: number;
|
||||
instrument: Pair;
|
||||
eventTs: number;
|
||||
}
|
||||
|
||||
export interface TrumpPosition {
|
||||
pair: Pair;
|
||||
qty: number;
|
||||
entryTs: number;
|
||||
entryPrice: number; // Fill inkl. Slippage
|
||||
entryCost: number; // qty×fill + Fee
|
||||
riskAmount: number; // = entryCost → r = Return auf Einsatz
|
||||
exitDueTs: number; // entryTs + holdHours
|
||||
eventId: number;
|
||||
}
|
||||
|
||||
export interface TrumpLiveState {
|
||||
cash: number;
|
||||
positions: TrumpPosition[];
|
||||
cursorTs: number;
|
||||
}
|
||||
|
||||
export interface TrumpCycleConfig {
|
||||
exec: ExecConfig;
|
||||
holdHours: number;
|
||||
equityFraction: number;
|
||||
maxPositions: number;
|
||||
minNotionalUsdt: number;
|
||||
pairs: Pair[];
|
||||
}
|
||||
|
||||
export interface TrumpCycleResult {
|
||||
cash: number;
|
||||
positions: TrumpPosition[];
|
||||
cursorTs: number;
|
||||
closedTrades: ClosedTrade[];
|
||||
consumed: { eventId: number; consumedAt: number }[];
|
||||
equitySnapshots: EquitySnapshot[];
|
||||
equity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure Event-Copy-Strategie: Buy am Open der ersten 15m-Candle mit ts ≥ eventTs,
|
||||
* Zeit-Exit zum Close der ersten Candle mit ts+15m ≥ entryTs+holdHours, kein Stop.
|
||||
* Events werden beim Verarbeiten immer konsumiert (verfallen ohne freien Slot).
|
||||
* Cursor-idempotent: gesplittete Zyklen ergeben exakt dasselbe wie ein Lauf —
|
||||
* Paritätstest erzwingt das (Aufrufer reicht nur unkonsumierte Events ein).
|
||||
* Events für Pairs, die in candles15ByPair fehlen oder nach dem Cursor kein Candle
|
||||
* haben (fehlender Key oder leeres Array), werden nicht besucht und bleiben unkonsumiert —
|
||||
* der Aufrufer muss sicherstellen, dass solche Events später Candles bekommen
|
||||
* (Gap-Fetch/Backfill) oder vom Aufrufer selbst verworfen werden.
|
||||
*/
|
||||
export function processTrumpCycle(
|
||||
candles15ByPair: Map<Pair, Candle[]>,
|
||||
events: TrumpEventInput[],
|
||||
state: TrumpLiveState,
|
||||
cfg: TrumpCycleConfig,
|
||||
): TrumpCycleResult {
|
||||
let cash = state.cash;
|
||||
const positions = new Map<Pair, TrumpPosition>();
|
||||
for (const p of state.positions) positions.set(p.pair, { ...p });
|
||||
const trades: ClosedTrade[] = [];
|
||||
const consumed: { eventId: number; consumedAt: number }[] = [];
|
||||
const equitySnapshots: EquitySnapshot[] = [];
|
||||
const lastClose = new Map<Pair, number>();
|
||||
const holdMs = cfg.holdHours * 3600_000;
|
||||
|
||||
const pairs = cfg.pairs.filter((p) => candles15ByPair.has(p));
|
||||
|
||||
// Events nach Pair gruppieren und sortieren
|
||||
const pending = new Map<Pair, TrumpEventInput[]>();
|
||||
for (const ev of [...events].sort((a, b) => a.eventTs - b.eventTs || a.id - b.id)) {
|
||||
if (!pending.has(ev.instrument)) pending.set(ev.instrument, []);
|
||||
pending.get(ev.instrument)!.push(ev);
|
||||
}
|
||||
|
||||
// lastClose mit Candles bis einschließlich cursorTs vorbelegen
|
||||
for (const pair of pairs) {
|
||||
for (const c of candles15ByPair.get(pair)!) {
|
||||
if (c.ts > state.cursorTs) break;
|
||||
lastClose.set(pair, c.close);
|
||||
}
|
||||
}
|
||||
|
||||
const equity = (): number => {
|
||||
let eq = cash;
|
||||
for (const p of positions.values()) eq += p.qty * (lastClose.get(p.pair) ?? p.entryPrice);
|
||||
return eq;
|
||||
};
|
||||
|
||||
// Timeline aller Candles strikt nach cursorTs, sortiert nach ts dann nach pairs-Reihenfolge
|
||||
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
|
||||
for (const pair of pairs) {
|
||||
for (const candle of candles15ByPair.get(pair)!) {
|
||||
if (candle.ts > state.cursorTs) timeline.push({ ts: candle.ts, pair, candle });
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a.ts - b.ts || cfg.pairs.indexOf(a.pair) - cfg.pairs.indexOf(b.pair));
|
||||
|
||||
let cursorTs = state.cursorTs;
|
||||
let lastEquityBucket = -1;
|
||||
|
||||
for (const { ts, pair, candle } of timeline) {
|
||||
// 1) Zeit-Exit zum Close (vor Entries: Slot/Cash wird frei)
|
||||
const pos = positions.get(pair);
|
||||
if (pos && ts + M15 >= pos.exitDueTs) {
|
||||
const fill = candle.close * (1 - cfg.exec.slippage);
|
||||
const proceeds = pos.qty * fill;
|
||||
const fee = proceeds * cfg.exec.feeRate;
|
||||
cash += proceeds - fee;
|
||||
const pnl = proceeds - fee - pos.entryCost;
|
||||
trades.push({
|
||||
pair, entryTs: pos.entryTs, entryPrice: pos.entryPrice, exitTs: ts, exitPrice: fill,
|
||||
qty: pos.qty, pnl, r: pnl / pos.riskAmount, exitReason: 'trump_hold', side: 'long',
|
||||
});
|
||||
positions.delete(pair);
|
||||
}
|
||||
|
||||
// 2) Fällige Events konsumieren; Entry nur wenn Slot frei
|
||||
const queue = pending.get(pair);
|
||||
while (queue && queue.length > 0 && queue[0].eventTs <= ts) {
|
||||
const ev = queue.shift()!;
|
||||
consumed.push({ eventId: ev.id, consumedAt: ts });
|
||||
if (positions.has(pair) || positions.size >= cfg.maxPositions) continue;
|
||||
const budget = equity() * cfg.equityFraction;
|
||||
const fill = candle.open * (1 + cfg.exec.slippage);
|
||||
const qty = budget / fill / (1 + cfg.exec.feeRate); // Budget deckt Kosten inkl. Fee
|
||||
const cost = qty * fill;
|
||||
const fee = cost * cfg.exec.feeRate;
|
||||
if (budget < cfg.minNotionalUsdt || cash < cost + fee) continue;
|
||||
cash -= cost + fee;
|
||||
positions.set(pair, {
|
||||
pair, qty, entryTs: ts, entryPrice: fill, entryCost: cost + fee,
|
||||
riskAmount: cost + fee, exitDueTs: ts + holdMs, eventId: ev.id,
|
||||
});
|
||||
}
|
||||
|
||||
lastClose.set(pair, candle.close);
|
||||
cursorTs = Math.max(cursorTs, ts);
|
||||
|
||||
// 3) Equity-Punkt einmal pro 4h-Bucket (wie Trend/Grid)
|
||||
const bucket = Math.floor(ts / H4) * H4;
|
||||
if (bucket !== lastEquityBucket) {
|
||||
lastEquityBucket = bucket;
|
||||
equitySnapshots.push({ ts: bucket, equity: equity(), cash });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cash, positions: [...positions.values()], cursorTs,
|
||||
closedTrades: trades, consumed, equitySnapshots, equity: equity(),
|
||||
};
|
||||
}
|
||||
202
src/server/live/trump-engine.ts
Normal file
202
src/server/live/trump-engine.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { and, eq, isNotNull, isNull } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { botState, equitySnapshots, paperTrades, trumpEvents, trumpPositions } from '../db/schema';
|
||||
import { fetchCandles } from '../market/cryptocom';
|
||||
import { getCandles, insertCandles } from '../market/candle-store';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import { pollSignals } from '../signals/poller';
|
||||
import { PAIRS, TRUMP_PAIRS } from '../types';
|
||||
import type { Candle, Pair } from '../types';
|
||||
import {
|
||||
processTrumpCycle,
|
||||
type TrumpCycleConfig,
|
||||
type TrumpCycleResult,
|
||||
type TrumpEventInput,
|
||||
type TrumpLiveState,
|
||||
} from './trump-cycle';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const BOT_STATE_ID = 3; // 1 = Trend, 2 = Grid
|
||||
const START_CAPITAL = 10_000;
|
||||
/** Keine Indikatoren — Warmup nur für lastClose-Seed (Equity offener Positionen). */
|
||||
const WARMUP_BARS_15M = 8; // Spät eingespielte Events (z. B. Backfill ohne consumed-Markierung) würden zum nächsten verfügbaren Open statt zum Event-Open einsteigen
|
||||
|
||||
export const TRUMP_CYCLE_CONFIG: TrumpCycleConfig = {
|
||||
exec: DEFAULT_EXEC,
|
||||
holdHours: 24, // Event-Study 2026-06-12: 24h klar bester Horizont (Mean +3.1%, Hit 65% cluster-dedupt; ab 60h keine Edge)
|
||||
equityFraction: 0.2,
|
||||
maxPositions: 5,
|
||||
minNotionalUsdt: 10,
|
||||
pairs: [...TRUMP_PAIRS],
|
||||
};
|
||||
|
||||
export interface TrumpEngineStatus {
|
||||
lastCycleAt: number | null;
|
||||
lastCycleOk: boolean;
|
||||
lastError: string | null;
|
||||
cursorTs: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dritte Paper-Engine (Trump-Copy). Läuft NACH Trend+Grid im Zyklus;
|
||||
* Candle-Gap-Fetch für die Nicht-Trend-Pairs kommt in Task 10.
|
||||
*/
|
||||
export class TrumpEngine {
|
||||
status: TrumpEngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, cursorTs: null };
|
||||
private cycling = false;
|
||||
|
||||
async init(): Promise<void> {
|
||||
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||
if (row) {
|
||||
this.status.cursorTs = row.cursorTs.getTime();
|
||||
return;
|
||||
}
|
||||
const cursor = Math.floor(Date.now() / M15) * M15 - M15;
|
||||
await db.insert(botState).values({
|
||||
id: BOT_STATE_ID,
|
||||
cash: START_CAPITAL,
|
||||
startCapital: START_CAPITAL,
|
||||
cursorTs: new Date(cursor),
|
||||
});
|
||||
this.status.cursorTs = cursor;
|
||||
}
|
||||
|
||||
/** Holt fehlende 15m-Candles für Nicht-Trend-Pairs (TRUMP_CYCLE_CONFIG.pairs minus PAIRS). */
|
||||
private async fillCandleGaps(): Promise<void> {
|
||||
const trumpOnlyPairs = TRUMP_CYCLE_CONFIG.pairs.filter(
|
||||
(p) => !(PAIRS as readonly string[]).includes(p),
|
||||
);
|
||||
const state = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||
const cursorTs = state[0]?.cursorTs.getTime() ?? Date.now();
|
||||
const now = Date.now();
|
||||
for (const pair of trumpOnlyPairs) {
|
||||
try {
|
||||
const fresh: Candle[] = [];
|
||||
let endTs: number | undefined;
|
||||
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) {
|
||||
console.warn(`Trump-Engine Gap-Fetch Fehler für ${pair}:`, err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runCycle(): Promise<void> {
|
||||
if (this.cycling) return;
|
||||
this.cycling = true;
|
||||
try {
|
||||
await pollSignals(); // non-fatal intern; wirft nicht
|
||||
await this.fillCandleGaps();
|
||||
|
||||
const state = await this.loadState();
|
||||
const from = state.cursorTs - WARMUP_BARS_15M * M15;
|
||||
const candles15 = new Map<Pair, Candle[]>();
|
||||
for (const pair of TRUMP_CYCLE_CONFIG.pairs) {
|
||||
const cs = await getCandles(pair, from);
|
||||
// Pairs ohne Candle-Daten NICHT in die Map: deren Events bleiben pending,
|
||||
// bis Gap-Fetch/Backfill liefert (Vertrag von processTrumpCycle)
|
||||
if (cs.length > 0) candles15.set(pair, cs);
|
||||
else console.warn(`Trump-Engine: keine Candles für ${pair} — Pair in diesem Zyklus übersprungen`);
|
||||
}
|
||||
const events = await this.loadOpenEvents();
|
||||
const result = processTrumpCycle(candles15, events, state, TRUMP_CYCLE_CONFIG);
|
||||
await this.persist(result);
|
||||
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('Trump-Zyklus fehlgeschlagen:', err);
|
||||
} finally {
|
||||
this.cycling = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async loadState(): Promise<TrumpLiveState> {
|
||||
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
|
||||
if (!row) throw new Error('bot_state (trump) fehlt — init() nicht gelaufen?');
|
||||
const posRows = await db.select().from(trumpPositions);
|
||||
return {
|
||||
cash: row.cash,
|
||||
cursorTs: row.cursorTs.getTime(),
|
||||
positions: posRows.map((p) => ({
|
||||
pair: p.pair as Pair,
|
||||
qty: p.qty,
|
||||
entryTs: p.entryTs.getTime(),
|
||||
entryPrice: p.entryPrice,
|
||||
entryCost: p.entryCost,
|
||||
riskAmount: p.riskAmount,
|
||||
exitDueTs: p.exitDueTs.getTime(),
|
||||
eventId: p.eventId,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async loadOpenEvents(): Promise<TrumpEventInput[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(trumpEvents)
|
||||
.where(and(isNull(trumpEvents.consumedAt), isNotNull(trumpEvents.instrument)));
|
||||
return rows.map((r) => ({ id: r.id, instrument: r.instrument as Pair, eventTs: r.eventTs.getTime() }));
|
||||
}
|
||||
|
||||
private async persist(result: TrumpCycleResult): Promise<void> {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(trumpPositions);
|
||||
for (const p of result.positions) {
|
||||
await tx.insert(trumpPositions).values({
|
||||
pair: p.pair,
|
||||
qty: p.qty,
|
||||
entryTs: new Date(p.entryTs),
|
||||
entryPrice: p.entryPrice,
|
||||
entryCost: p.entryCost,
|
||||
riskAmount: p.riskAmount,
|
||||
exitDueTs: new Date(p.exitDueTs),
|
||||
eventId: p.eventId,
|
||||
});
|
||||
}
|
||||
if (result.closedTrades.length > 0) {
|
||||
await tx.insert(paperTrades).values(
|
||||
result.closedTrades.map((t) => ({
|
||||
bot: 'trump',
|
||||
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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
for (const c of result.consumed) {
|
||||
await tx.update(trumpEvents).set({ consumedAt: new Date(c.consumedAt) }).where(eq(trumpEvents.id, c.eventId));
|
||||
}
|
||||
for (const s of result.equitySnapshots) {
|
||||
const row = { bot: 'trump', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||
await tx
|
||||
.insert(equitySnapshots)
|
||||
.values(row)
|
||||
.onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row });
|
||||
}
|
||||
await tx
|
||||
.update(botState)
|
||||
.set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() })
|
||||
.where(eq(botState.id, BOT_STATE_ID));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import type { Candle } from '../types';
|
||||
export const H4 = 4 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Aggregiert 15m-Candles (sortiert, ts aufsteigend) zu 4h-Candles.
|
||||
* Aggregiert 15m-Candles (sortiert, ts aufsteigend) zu Buckets von `tfMs`.
|
||||
* Der letzte Bucket wird verworfen, weil nicht feststellbar ist, ob er
|
||||
* abgeschlossen ist — der Backtest-Runner arbeitet nur auf geschlossenen Candles.
|
||||
* abgeschlossen ist — Backtests arbeiten nur auf geschlossenen Candles.
|
||||
*/
|
||||
export function aggregate4h(c15: Candle[]): Candle[] {
|
||||
export function aggregateTf(c15: Candle[], tfMs: number): Candle[] {
|
||||
const out: Candle[] = [];
|
||||
let cur: Candle | null = null;
|
||||
for (const c of c15) {
|
||||
const bucket = Math.floor(c.ts / H4) * H4;
|
||||
const bucket = Math.floor(c.ts / tfMs) * tfMs;
|
||||
if (cur && cur.ts === bucket) {
|
||||
cur.high = Math.max(cur.high, c.high);
|
||||
cur.low = Math.min(cur.low, c.low);
|
||||
@@ -24,3 +24,9 @@ export function aggregate4h(c15: Candle[]): Candle[] {
|
||||
}
|
||||
return out; // letzter Bucket absichtlich nicht gepusht
|
||||
}
|
||||
|
||||
/** Aggregiert 15m-Candles zu 4h-Candles. */
|
||||
export function aggregate4h(c15: Candle[]): Candle[] {
|
||||
return aggregateTf(c15, H4);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { PAIRS } from '../types';
|
||||
import { ALL_PAIRS } from '../types';
|
||||
import { fetchCandles } from '../market/cryptocom';
|
||||
import { insertCandles, getCoverage } from '../market/candle-store';
|
||||
import { sql } from '../db/client';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const TARGET_MONTHS = 14;
|
||||
const TARGET_MONTHS = 36;
|
||||
const since = Date.now() - TARGET_MONTHS * 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const pair of PAIRS) {
|
||||
for (const pair of ALL_PAIRS) {
|
||||
let endTs: number | undefined = undefined;
|
||||
let total = 0;
|
||||
let prevOldest: number | undefined = undefined;
|
||||
|
||||
119
src/server/scripts/grid-walkforward.ts
Normal file
119
src/server/scripts/grid-walkforward.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { PAIRS, type Candle, type Pair } from '../types';
|
||||
import { getCandles, getCoverage } from '../market/candle-store';
|
||||
import { buildWindows, aggregateOos } from '../backtest/walkforward';
|
||||
import { runGridBacktest, DEFAULT_GRID_PARAMS, type GridConfig } from '../backtest/grid';
|
||||
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-Search; Varianten nur als bewusste
|
||||
// Design-Entscheidung via CLI: --spacing 1.5 --levels 4 --adx 15) ---
|
||||
function argNum(flag: string, def: number): number {
|
||||
const i = process.argv.indexOf(flag);
|
||||
return i >= 0 ? Number(process.argv[i + 1]) : def;
|
||||
}
|
||||
const PARAMS = {
|
||||
...DEFAULT_GRID_PARAMS, // spacing 1×ATR, 4 Levels, ADX < 20, tf 4h
|
||||
spacingAtrMult: argNum('--spacing', DEFAULT_GRID_PARAMS.spacingAtrMult),
|
||||
gridLevels: argNum('--levels', DEFAULT_GRID_PARAMS.gridLevels),
|
||||
adxMax: argNum('--adx', DEFAULT_GRID_PARAMS.adxMax),
|
||||
tfMs: argNum('--tf', DEFAULT_GRID_PARAMS.tfMs / 60000) * 60000, // Minuten
|
||||
hardStop: !process.argv.includes('--no-stop'),
|
||||
};
|
||||
const ONLY_PAIR = (() => {
|
||||
const i = process.argv.indexOf('--pair');
|
||||
return i >= 0 ? (process.argv[i + 1] as Pair) : null;
|
||||
})();
|
||||
const START_CAPITAL = 1000;
|
||||
const EXEC = DEFAULT_EXEC;
|
||||
const MIN_NOTIONAL = 10;
|
||||
|
||||
const candles15ByPair = new Map<Pair, Candle[]>();
|
||||
let dataFrom = 0;
|
||||
let dataTo = Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (const pair of PAIRS) {
|
||||
if (ONLY_PAIR && pair !== ONLY_PAIR) continue;
|
||||
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(`\nATR-Grid (fix: spacing ${PARAMS.spacingAtrMult}×ATR, ${PARAMS.gridLevels} Levels, ADX < ${PARAMS.adxMax}, tf ${PARAMS.tfMs / 60000}m, ${PARAMS.hardStop ? 'hard-stop' : 'NO-STOP'}${ONLY_PAIR ? ', nur ' + ONLY_PAIR : ''}, 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 mkCfg = (tradeFrom: number, tradeTo: number): GridConfig => ({
|
||||
startCapital: START_CAPITAL,
|
||||
exec: EXEC,
|
||||
params: PARAMS,
|
||||
minNotionalUsdt: MIN_NOTIONAL,
|
||||
tradeFrom,
|
||||
tradeTo,
|
||||
});
|
||||
|
||||
const trainResult = runGridBacktest(candles15ByPair, mkCfg(w.trainFrom, w.trainTo));
|
||||
const trainMetrics = computeMetrics(trainResult.trades, trainResult.equityCurve, START_CAPITAL);
|
||||
|
||||
const testResult = runGridBacktest(candles15ByPair, mkCfg(w.testFrom, w.testTo));
|
||||
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`,
|
||||
);
|
||||
}
|
||||
|
||||
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'}`);
|
||||
|
||||
await db.insert(backtestRuns).values({
|
||||
kind: 'grid-walkforward',
|
||||
config: { startCapital: START_CAPITAL, exec: EXEC, params: PARAMS, minNotionalUsdt: MIN_NOTIONAL } 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();
|
||||
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();
|
||||
145
src/server/scripts/trump-backfill.ts
Normal file
145
src/server/scripts/trump-backfill.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { parseArgs } from 'util';
|
||||
import { sql, db } from '../db/client';
|
||||
import { trumpEvents } from '../db/schema';
|
||||
import { fetchTransfers, getBlockNumber, getBlockTs } from '../signals/onchain';
|
||||
import { dedupeTruthEvents, passesNotional } from '../signals/poller';
|
||||
import { matchCoins } from '../signals/truth';
|
||||
import { COIN_KEYWORDS } from '../signals/watchlist';
|
||||
import { getCandles } from '../market/candle-store';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
'from-block': { type: 'string', default: '20600000' }, // ≈ Sep 2024, vor den ersten WLFI-Treasury-Käufen
|
||||
'truth-pages': { type: 'string', default: '300' },
|
||||
'skip-onchain': { type: 'boolean', default: false }, // für Re-Runs nach Truth-Abbruch (Inserts sind idempotent)
|
||||
},
|
||||
});
|
||||
|
||||
// ── On-chain ──
|
||||
const fromBlock = Number(args['from-block']);
|
||||
if (!args['skip-onchain']) {
|
||||
const head = await getBlockNumber();
|
||||
console.log(`On-chain-Scan Block ${fromBlock} → ${head} (${head - fromBlock} Blöcke, ~${Math.ceil((head - fromBlock) / 5000)} Requests)`);
|
||||
let onchainCount = 0;
|
||||
const STEP = 5000;
|
||||
for (let from = fromBlock; from <= head; from += STEP) {
|
||||
const to = Math.min(from + STEP - 1, head);
|
||||
const transfers = await fetchTransfers(from, to, STEP);
|
||||
for (const t of transfers) {
|
||||
const ts = await getBlockTs(t.blockNumber);
|
||||
let price: number | null = null;
|
||||
if (t.instrument) {
|
||||
const cs = await getCandles(t.instrument, ts - 24 * 3600_000, ts + M15);
|
||||
price = cs.length > 0 ? cs[cs.length - 1].close : null;
|
||||
}
|
||||
if (!passesNotional(t.amount, price)) continue;
|
||||
await db.insert(trumpEvents).values({
|
||||
source: 'onchain', token: t.symbol, instrument: t.instrument,
|
||||
eventTs: new Date(ts), ref: t.txHash, notionalUsd: t.amount * price!,
|
||||
}).onConflictDoNothing();
|
||||
onchainCount++;
|
||||
console.log(` ${new Date(ts).toISOString()} ${t.symbol} ${t.amount.toFixed(2)} (~$${Math.round(t.amount * price!)}) ${t.txHash}`);
|
||||
}
|
||||
if ((from - fromBlock) % (STEP * 20) === 0) console.log(` … Block ${to}`);
|
||||
await Bun.sleep(150);
|
||||
}
|
||||
console.log(`On-chain: ${onchainCount} Events`);
|
||||
}
|
||||
|
||||
// ── Truth (best effort, Cursor-Pagination rückwärts bis TRUTH_CUTOFF) ──
|
||||
// Markup verifiziert 2026-06-12: je Post ein <div class="status"> (exakt).
|
||||
// Timestamp als menschenlesbarer Text in class="status-info__meta-item" (kein datetime=-Attribut!).
|
||||
// Post-Text in class="status__content". URL via href="https://trumpstruth.org/statuses/\d+".
|
||||
// ACHTUNG: ?page=N wird vom Server IGNORIERT (liefert immer Seite 1) — Pagination ist
|
||||
// Cursor-basiert: ?cursor=<base64 {status_created_at, _pointsToNextItems}> aus dem "older"-Link.
|
||||
const pages = Number(args['truth-pages']);
|
||||
const TRUTH_CUTOFF = Date.parse('2024-09-01T00:00:00Z'); // Beginn der WLFI-Ära, älter brauchen wir nicht
|
||||
const candidates: { symbol: string; ts: number; url: string }[] = [];
|
||||
let oldestTs = Infinity;
|
||||
|
||||
/**
|
||||
* Archiv-Zeiten sind US-Eastern OHNE Zeitzonen-Angabe (gegen RSS-pubDate kalibriert
|
||||
* 2026-06-12: Offset exakt −4h = EDT). Interpretiert den String als America/New_York
|
||||
* inkl. Sommer-/Winterzeit, ohne Dependency.
|
||||
*/
|
||||
function parseEasternTime(s: string): number {
|
||||
const utcGuess = Date.parse(s + ' UTC');
|
||||
if (Number.isNaN(utcGuess)) return NaN;
|
||||
// Offset von New York zum Zeitpunkt utcGuess bestimmen (±1h Fehler durch DST-Grenze ist hier irrelevant)
|
||||
const nyParts = new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', timeZoneName: 'shortOffset' })
|
||||
.formatToParts(new Date(utcGuess))
|
||||
.find((p) => p.type === 'timeZoneName')?.value ?? 'GMT-5';
|
||||
const m = nyParts.match(/GMT([+-]\d+)/);
|
||||
const offsetH = m ? Number(m[1]) : -5;
|
||||
return utcGuess - offsetH * 3600_000; // ET 9:49 PM = UTC 9:49 PM − (−4h) = 01:49 nächster Tag
|
||||
}
|
||||
/** Seiten-Fetch mit 3 Versuchen (Timeouts/Netzfehler dürfen den Scan nicht crashen). */
|
||||
async function fetchPage(url: string): Promise<string | null> {
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(20_000) });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return await res.text();
|
||||
} catch (err) {
|
||||
console.warn(`${url}, Versuch ${attempt}/3 fehlgeschlagen:`, err instanceof Error ? err.message : err);
|
||||
await Bun.sleep(2000 * attempt);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "Older"-Cursor aus dem HTML: der _pointsToNextItems-Cursor mit dem ältesten status_created_at. */
|
||||
function nextCursor(html: string): string | null {
|
||||
let best: { ts: string; cursor: string } | null = null;
|
||||
for (const m of html.match(/cursor=([A-Za-z0-9+/=%]+)/g) ?? []) {
|
||||
const raw = decodeURIComponent(m.slice(7));
|
||||
try {
|
||||
const j = JSON.parse(atob(raw));
|
||||
if (j._pointsToNextItems === true && (!best || j.status_created_at < best.ts)) {
|
||||
best = { ts: j.status_created_at, cursor: raw };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return best ? encodeURIComponent(best.cursor) : null;
|
||||
}
|
||||
|
||||
let pageUrl = 'https://trumpstruth.org/?sort=desc&per_page=50';
|
||||
for (let p = 1; p <= pages; p++) {
|
||||
const html = await fetchPage(pageUrl);
|
||||
if (html === null) { console.warn(`Seite ${p}: dauerhaft nicht erreichbar — Truth-Scan endet hier (best effort)`); break; }
|
||||
// Split auf exaktes class="status" (nicht \b) — gibt genau 10 Post-Blöcke je Seite.
|
||||
// Vorsicht: class="status\b würde auch status__header, status__body etc. treffen (138 statt 10).
|
||||
const blocks = html.split(/class="status"/).slice(1);
|
||||
if (blocks.length === 0) { console.warn(`Seite ${p}: 0 Blöcke — möglicherweise letzte Seite`); break; }
|
||||
for (const block of blocks) {
|
||||
const url = block.match(/href="(https:\/\/trumpstruth\.org\/statuses\/\d+)"/)?.[1];
|
||||
// Timestamp als menschenlesbarer Text z. B. "June 11, 2026, 9:49 PM" (kein datetime=-Attribut)
|
||||
const timeRaw = block.match(/class="status-info__meta-item">([A-Z][a-z]+ \d+, \d{4}, \d+:\d+ [AP]M)<\/a>/)?.[1];
|
||||
// Post-Text aus status__content (nicht status-info__body — das ist der Autor-/Meta-Bereich)
|
||||
const body = block.match(/status__content[^>]*>([\s\S]*?)<\/div>/)?.[1] ?? '';
|
||||
if (!url || !timeRaw) continue;
|
||||
const ts = parseEasternTime(timeRaw);
|
||||
if (Number.isNaN(ts)) continue;
|
||||
oldestTs = Math.min(oldestTs, ts);
|
||||
const text = body.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
for (const symbol of matchCoins(text)) candidates.push({ symbol, ts, url });
|
||||
}
|
||||
if (p % 20 === 0) console.log(` … Seite ${p}, ältester Post bisher ${new Date(oldestTs).toISOString()}, ${candidates.length} Kandidaten`);
|
||||
if (oldestTs < TRUTH_CUTOFF) { console.log(`Cutoff ${new Date(TRUTH_CUTOFF).toISOString()} erreicht — Scan komplett für die WLFI-Ära`); break; }
|
||||
const cursor = nextCursor(html);
|
||||
if (!cursor) { console.warn(`Seite ${p}: kein Older-Cursor — Archiv-Ende erreicht`); break; }
|
||||
pageUrl = `https://trumpstruth.org/?sort=desc&per_page=50&cursor=${cursor}`;
|
||||
await Bun.sleep(300);
|
||||
}
|
||||
console.log(`Truth-Scan: ${candidates.length} Kandidaten, ältester Post ${oldestTs === Infinity ? '—' : new Date(oldestTs).toISOString()}`);
|
||||
let truthCount = 0;
|
||||
for (const e of dedupeTruthEvents(candidates, new Map())) {
|
||||
const kw = COIN_KEYWORDS.find((c) => c.symbol === e.symbol)!;
|
||||
await db.insert(trumpEvents).values({
|
||||
source: 'truth', token: e.symbol, instrument: kw.instrument, eventTs: new Date(e.ts), ref: e.url,
|
||||
}).onConflictDoNothing();
|
||||
truthCount++;
|
||||
}
|
||||
console.log(`Truth: ${truthCount} Events (Historie nur letzte ${pages} Seiten — Lücke davor ist bekannt und akzeptiert)`);
|
||||
await sql.end();
|
||||
68
src/server/scripts/trump-event-study.ts
Normal file
68
src/server/scripts/trump-event-study.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { isNotNull } from 'drizzle-orm';
|
||||
import { db, sql } from '../db/client';
|
||||
import { trumpEvents } from '../db/schema';
|
||||
import { getCandles } from '../market/candle-store';
|
||||
import { DEFAULT_EXEC } from '../engine/portfolio';
|
||||
import type { Pair } from '../types';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
const HORIZONS_H = [24, 48, 60, 72, 120];
|
||||
const COST = (1 - DEFAULT_EXEC.slippage) * (1 - DEFAULT_EXEC.feeRate) / ((1 + DEFAULT_EXEC.slippage) * (1 + DEFAULT_EXEC.feeRate)); // Round-Trip-Faktor
|
||||
|
||||
const events = await db.select().from(trumpEvents).where(isNotNull(trumpEvents.instrument));
|
||||
type Row = { source: string; horizon: number; ret: number };
|
||||
const rows: Row[] = [];
|
||||
const baselines = new Map<string, Map<number, number>>(); // instrument → horizon → mean ret
|
||||
|
||||
for (const ev of events) {
|
||||
const instrument = ev.instrument as Pair;
|
||||
const evTs = ev.eventTs.getTime();
|
||||
const candles = await getCandles(instrument, evTs - M15, evTs + 130 * 3600_000);
|
||||
const entryIdx = candles.findIndex((c) => c.ts >= evTs);
|
||||
if (entryIdx < 0) continue;
|
||||
const entry = candles[entryIdx].open;
|
||||
for (const h of HORIZONS_H) {
|
||||
const exitTs = candles[entryIdx].ts + h * 3600_000;
|
||||
const exit = candles.findLast((c) => c.ts <= exitTs);
|
||||
if (!exit || exit.ts < exitTs - 2 * M15) continue; // Horizont nicht abgedeckt
|
||||
rows.push({ source: ev.source, horizon: h, ret: (exit.close / entry) * COST - 1 });
|
||||
}
|
||||
// Baseline je Instrument einmalig: unbedingter Mean-Forward-Return über alle 15m-Starts
|
||||
if (!baselines.has(instrument)) {
|
||||
const all = await getCandles(instrument);
|
||||
const byH = new Map<number, number>();
|
||||
for (const h of HORIZONS_H) {
|
||||
const stepIdx = (h * 3600_000) / M15;
|
||||
let s = 0, n = 0;
|
||||
for (let i = 0; i + stepIdx < all.length; i += 16) { // jede 4h ein Sample, deterministisch
|
||||
s += (all[i + stepIdx].close / all[i].open) * COST - 1;
|
||||
n++;
|
||||
}
|
||||
byH.set(h, n > 0 ? s / n : NaN);
|
||||
}
|
||||
baselines.set(instrument, byH);
|
||||
}
|
||||
}
|
||||
|
||||
const fmt = (x: number) => (100 * x).toFixed(2) + '%';
|
||||
const lines: string[] = ['# Event-Study Trump-Copy — ' + new Date().toISOString().slice(0, 10), ''];
|
||||
for (const source of ['onchain', 'truth']) {
|
||||
lines.push(`## Quelle: ${source}`, '', '| Horizont | n | Mean | Median | Hit-Rate |', '|---|---|---|---|---|');
|
||||
for (const h of HORIZONS_H) {
|
||||
const rs = rows.filter((r) => r.source === source && r.horizon === h).map((r) => r.ret).sort((a, b) => a - b);
|
||||
if (rs.length === 0) { lines.push(`| ${h}h | 0 | — | — | — |`); continue; }
|
||||
const mean = rs.reduce((a, b) => a + b, 0) / rs.length;
|
||||
const median = rs[Math.floor(rs.length / 2)];
|
||||
const hit = rs.filter((r) => r > 0).length / rs.length;
|
||||
lines.push(`| ${h}h | ${rs.length} | ${fmt(mean)} | ${fmt(median)} | ${(100 * hit).toFixed(0)}% |`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
lines.push('## Baselines (unbedingter Mean-Forward-Return, gleiche Kosten)', '');
|
||||
for (const [inst, byH] of baselines) {
|
||||
lines.push(`- ${inst}: ` + HORIZONS_H.map((h) => `${h}h ${fmt(byH.get(h)!)}`).join(' · '));
|
||||
}
|
||||
const out = lines.join('\n') + '\n';
|
||||
await Bun.write('docs/event-study-trump-2026-06-12.md', out);
|
||||
console.log(out);
|
||||
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;
|
||||
|
||||
45
src/server/signals/onchain.test.ts
Normal file
45
src/server/signals/onchain.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { buildLogFilter, decodeTransferLogs, TRANSFER_TOPIC } from './onchain';
|
||||
|
||||
const WALLET = '0x5be9a4959308a0d0c7bc0870e319314d8d957dbb';
|
||||
|
||||
describe('buildLogFilter', () => {
|
||||
test('filtert auf Token-Whitelist + Transfer-Topic + Wallet als Empfänger', () => {
|
||||
const f = buildLogFilter(100, 200);
|
||||
expect(f.fromBlock).toBe('0x64');
|
||||
expect(f.toBlock).toBe('0xc8');
|
||||
expect(f.address).toContain('0x514910771af9ca656af840dff83e8264ecf986ca'); // LINK
|
||||
expect(f.topics[0]).toBe(TRANSFER_TOPIC);
|
||||
expect(f.topics[1]).toBeNull(); // from: beliebig
|
||||
expect(f.topics[2]).toContain('0x000000000000000000000000' + WALLET.slice(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeTransferLogs', () => {
|
||||
test('dekodiert Token, Menge (decimals-skaliert), Tx-Hash, Block', () => {
|
||||
const log = {
|
||||
address: '0x514910771af9ca656af840dff83e8264ecf986ca', // LINK, 18 decimals
|
||||
topics: [
|
||||
TRANSFER_TOPIC,
|
||||
'0x000000000000000000000000aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
'0x000000000000000000000000' + WALLET.slice(2),
|
||||
],
|
||||
data: '0x' + (5n * 10n ** 18n).toString(16).padStart(64, '0'), // 5 LINK
|
||||
transactionHash: '0xabc',
|
||||
blockNumber: '0x64',
|
||||
};
|
||||
const out = decodeTransferLogs([log]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]).toEqual({ symbol: 'LINK', instrument: 'LINK_USDT', amount: 5, txHash: '0xabc', blockNumber: 100 });
|
||||
});
|
||||
test('ignoriert Logs unbekannter Token-Contracts', () => {
|
||||
const log = {
|
||||
address: '0x000000000000000000000000000000000000dead',
|
||||
topics: [TRANSFER_TOPIC, '0x0', '0x0'],
|
||||
data: '0x1',
|
||||
transactionHash: '0xdef',
|
||||
blockNumber: '0x65',
|
||||
};
|
||||
expect(decodeTransferLogs([log])).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
87
src/server/signals/onchain.ts
Normal file
87
src/server/signals/onchain.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { Pair } from '../types';
|
||||
import { RPC_URLS, TOKEN_BY_CONTRACT, TRACKED_TOKENS, WATCHED_WALLETS } from './watchlist';
|
||||
|
||||
/** keccak256("Transfer(address,address,uint256)") */
|
||||
export const TRANSFER_TOPIC = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef';
|
||||
|
||||
export interface OnchainTransfer {
|
||||
symbol: string;
|
||||
instrument: Pair | null;
|
||||
amount: number; // decimals-skaliert
|
||||
txHash: string;
|
||||
blockNumber: number;
|
||||
}
|
||||
|
||||
export function buildLogFilter(fromBlock: number, toBlock: number) {
|
||||
return {
|
||||
fromBlock: '0x' + fromBlock.toString(16),
|
||||
toBlock: '0x' + toBlock.toString(16),
|
||||
address: TRACKED_TOKENS.map((t) => t.contract),
|
||||
topics: [
|
||||
TRANSFER_TOPIC,
|
||||
null, // from: beliebig
|
||||
WATCHED_WALLETS.map((w) => '0x000000000000000000000000' + w.address.slice(2)),
|
||||
] as (string | string[] | null)[],
|
||||
};
|
||||
}
|
||||
|
||||
export function decodeTransferLogs(logs: any[]): OnchainTransfer[] {
|
||||
const out: OnchainTransfer[] = [];
|
||||
for (const log of logs) {
|
||||
const token = TOKEN_BY_CONTRACT.get(String(log.address).toLowerCase());
|
||||
if (!token) continue;
|
||||
const raw = BigInt(!log.data || log.data === '0x' ? '0x0' : log.data);
|
||||
out.push({
|
||||
symbol: token.symbol,
|
||||
instrument: token.instrument,
|
||||
amount: Number(raw) / 10 ** token.decimals,
|
||||
txHash: log.transactionHash,
|
||||
blockNumber: Number(BigInt(log.blockNumber)),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** JSON-RPC mit URL-Fallback (erst alle URLs einmal, dann Fehler). */
|
||||
export async function rpc(method: string, params: unknown[]): Promise<any> {
|
||||
let lastErr: unknown;
|
||||
for (const url of RPC_URLS) {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} (${url})`);
|
||||
const json = await res.json();
|
||||
if (json.error) throw new Error(`RPC ${method}: ${json.error.message} (${url})`);
|
||||
return json.result;
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
console.warn(`RPC ${method} auf ${url} fehlgeschlagen — nächster Endpunkt:`, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
export async function getBlockNumber(): Promise<number> {
|
||||
return Number(BigInt(await rpc('eth_blockNumber', [])));
|
||||
}
|
||||
|
||||
/** Block-Timestamp in Unix ms. */
|
||||
export async function getBlockTs(blockNumber: number): Promise<number> {
|
||||
const block = await rpc('eth_getBlockByNumber', ['0x' + blockNumber.toString(16), false]);
|
||||
return Number(BigInt(block.timestamp)) * 1000;
|
||||
}
|
||||
|
||||
/** Transfers in Watchlist-Wallets im Blockbereich [fromBlock, toBlock] (inkl.), gechunkt à maxChunk. */
|
||||
export async function fetchTransfers(fromBlock: number, toBlock: number, maxChunk = 5000): Promise<OnchainTransfer[]> {
|
||||
const out: OnchainTransfer[] = [];
|
||||
for (let from = fromBlock; from <= toBlock; from += maxChunk) {
|
||||
const to = Math.min(from + maxChunk - 1, toBlock);
|
||||
const logs = await rpc('eth_getLogs', [buildLogFilter(from, to)]);
|
||||
out.push(...decodeTransferLogs(logs));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
33
src/server/signals/poller.test.ts
Normal file
33
src/server/signals/poller.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { consumedAtForInsert, dedupeTruthEvents, passesNotional, STALE_EVENT_MS } from './poller';
|
||||
|
||||
describe('passesNotional', () => {
|
||||
test('amount × Preis gegen MIN_NOTIONAL_USD (50k)', () => {
|
||||
expect(passesNotional(10, 6000)).toBe(true); // 60k
|
||||
expect(passesNotional(10, 4000)).toBe(false); // 40k
|
||||
expect(passesNotional(10, null)).toBe(false); // kein Preis → kein Event
|
||||
});
|
||||
});
|
||||
|
||||
describe('dedupeTruthEvents', () => {
|
||||
const H = 3600_000;
|
||||
test('max. ein Event je Coin pro 72h, über DB-Bestand + Batch hinweg', () => {
|
||||
const existing = new Map([['BTC', 1000 * H]]);
|
||||
const batch = [
|
||||
{ symbol: 'BTC', ts: 1000 * H + 71 * H, url: 'u1' }, // < 72h nach Bestand → raus
|
||||
{ symbol: 'BTC', ts: 1000 * H + 73 * H, url: 'u2' }, // ≥ 72h → bleibt
|
||||
{ symbol: 'BTC', ts: 1000 * H + 74 * H, url: 'u3' }, // < 72h nach u2 → raus
|
||||
{ symbol: 'ETH', ts: 1000 * H, url: 'u4' }, // anderer Coin → bleibt
|
||||
];
|
||||
expect(dedupeTruthEvents(batch, existing).map((e) => e.url)).toEqual(['u2', 'u4']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('consumedAtForInsert', () => {
|
||||
const NOW = 1_750_000_000_000;
|
||||
test('frisches Event → null (handelbar), zu altes → sofort consumed (nur Log)', () => {
|
||||
expect(consumedAtForInsert(NOW - 5 * 60_000, NOW)).toBeNull(); // 5 min alt
|
||||
expect(consumedAtForInsert(NOW - STALE_EVENT_MS + 1, NOW)).toBeNull(); // knapp drunter
|
||||
expect(consumedAtForInsert(NOW - STALE_EVENT_MS - 1, NOW)).toEqual(new Date(NOW)); // drüber → consumed
|
||||
});
|
||||
});
|
||||
137
src/server/signals/poller.ts
Normal file
137
src/server/signals/poller.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { trumpEvents, trumpSignalState } from '../db/schema';
|
||||
import { getCandles } from '../market/candle-store';
|
||||
import { fetchTransfers, getBlockNumber, getBlockTs } from './onchain';
|
||||
import { matchCoins, parseTruthFeed } from './truth';
|
||||
import { COIN_KEYWORDS, MIN_NOTIONAL_USD, TRUTH_DEDUPE_MS, TRUTH_FEED_URL } from './watchlist';
|
||||
import type { Pair } from '../types';
|
||||
|
||||
const M15 = 15 * 60 * 1000;
|
||||
/** Obergrenze Blöcke je Zyklus (~4 getLogs-Calls); Ethereum macht ~25 Blöcke/5min — reichlich Aufholpuffer. */
|
||||
const MAX_BLOCKS_PER_CYCLE = 20_000;
|
||||
/** Events, die beim Einsammeln älter sind, werden nur geloggt (consumed), nie gehandelt —
|
||||
* die Study misst Entries am Event-Open; ein Tage später entdecktes Event zum Tagespreis
|
||||
* zu kaufen wäre eine andere (ungetestete) Strategie. Deckt v. a. den ersten Prod-Start ab:
|
||||
* der RSS-Feed liefert sofort die letzten ~100 Posts, die dürfen keine Käufe auslösen. */
|
||||
export const STALE_EVENT_MS = 2 * 3600_000;
|
||||
|
||||
export function passesNotional(amount: number, price: number | null): boolean {
|
||||
return price !== null && amount * price >= MIN_NOTIONAL_USD;
|
||||
}
|
||||
|
||||
/** consumedAt-Wert für einen frischen Insert: jetzt (= nur loggen) wenn das Event zu alt ist, sonst null. */
|
||||
export function consumedAtForInsert(eventTs: number, now: number): Date | null {
|
||||
return now - eventTs > STALE_EVENT_MS ? new Date(now) : null;
|
||||
}
|
||||
|
||||
/** existing: Coin → eventTs des jüngsten Truth-Events in der DB. Batch muss ts-aufsteigend sein. */
|
||||
export function dedupeTruthEvents(
|
||||
batch: { symbol: string; ts: number; url: string }[],
|
||||
existing: Map<string, number>,
|
||||
): { symbol: string; ts: number; url: string }[] {
|
||||
// Erst ts-aufsteigend sortieren, um lastTs korrekt zu befüllen; dann Akzeptanz-Set bauen
|
||||
const sorted = [...batch].sort((a, b) => a.ts - b.ts);
|
||||
const lastTs = new Map(existing);
|
||||
const accepted = new Set<string>();
|
||||
for (const e of sorted) {
|
||||
const prev = lastTs.get(e.symbol);
|
||||
if (prev !== undefined && e.ts - prev < TRUTH_DEDUPE_MS) continue;
|
||||
lastTs.set(e.symbol, e.ts);
|
||||
accepted.add(e.url);
|
||||
}
|
||||
// Ursprüngliche Batch-Reihenfolge beibehalten
|
||||
return batch.filter((e) => accepted.has(e.url));
|
||||
}
|
||||
|
||||
/** Close der jüngsten Candle im Fenster [ts−24h, ts+15m) als USD-Proxy (USDT≈USD). null wenn keine Candle vorhanden. */
|
||||
async function priceAt(instrument: Pair, ts: number): Promise<number | null> {
|
||||
const candles = await getCandles(instrument, ts - 24 * 3600_000, ts + M15);
|
||||
return candles.length > 0 ? candles[candles.length - 1].close : null;
|
||||
}
|
||||
|
||||
export async function pollOnchain(): Promise<number> {
|
||||
const head = await getBlockNumber();
|
||||
const [state] = await db.select().from(trumpSignalState).where(eq(trumpSignalState.id, 1));
|
||||
if (!state) {
|
||||
// Erster Lauf: ab jetzt scannen (Historie macht trump-backfill)
|
||||
await db.insert(trumpSignalState).values({ id: 1, lastBlock: head });
|
||||
return 0;
|
||||
}
|
||||
const from = state.lastBlock + 1;
|
||||
const to = Math.min(head, state.lastBlock + MAX_BLOCKS_PER_CYCLE);
|
||||
if (from > to) return 0;
|
||||
|
||||
const transfers = await fetchTransfers(from, to);
|
||||
let inserted = 0;
|
||||
const blockTs = new Map<number, number>();
|
||||
for (const t of transfers) {
|
||||
if (!blockTs.has(t.blockNumber)) blockTs.set(t.blockNumber, await getBlockTs(t.blockNumber));
|
||||
const ts = blockTs.get(t.blockNumber)!;
|
||||
const price = t.instrument ? await priceAt(t.instrument, ts) : null;
|
||||
if (t.instrument && price === null) {
|
||||
// Candle-Lücke (frisches Listing / Backfill fehlt) — sichtbar machen statt still verwerfen
|
||||
console.warn(`Trump-Transfer ohne Preis verworfen: ${t.symbol} ${t.amount} (${t.txHash})`);
|
||||
}
|
||||
if (!passesNotional(t.amount, price)) continue;
|
||||
await db
|
||||
.insert(trumpEvents)
|
||||
.values({
|
||||
source: 'onchain', token: t.symbol, instrument: t.instrument,
|
||||
eventTs: new Date(ts), ref: t.txHash, notionalUsd: t.amount * price!,
|
||||
consumedAt: consumedAtForInsert(ts, Date.now()),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
inserted++;
|
||||
}
|
||||
await db.update(trumpSignalState).set({ lastBlock: to, updatedAt: new Date() }).where(eq(trumpSignalState.id, 1));
|
||||
return inserted;
|
||||
}
|
||||
|
||||
export async function pollTruth(): Promise<number> {
|
||||
const res = await fetch(TRUTH_FEED_URL, { signal: AbortSignal.timeout(15_000) });
|
||||
if (!res.ok) throw new Error(`trumpstruth HTTP ${res.status}`);
|
||||
const posts = parseTruthFeed(await res.text());
|
||||
|
||||
const candidates: { symbol: string; ts: number; url: string }[] = [];
|
||||
for (const p of posts) for (const symbol of matchCoins(p.text)) candidates.push({ symbol, ts: p.ts, url: p.url });
|
||||
if (candidates.length === 0) return 0;
|
||||
|
||||
const symbols = [...new Set(candidates.map((c) => c.symbol))];
|
||||
const existing = new Map<string, number>();
|
||||
for (const s of symbols) {
|
||||
const [row] = await db
|
||||
.select({ eventTs: trumpEvents.eventTs })
|
||||
.from(trumpEvents)
|
||||
.where(and(eq(trumpEvents.source, 'truth'), eq(trumpEvents.token, s)))
|
||||
.orderBy(desc(trumpEvents.eventTs))
|
||||
.limit(1);
|
||||
if (row) existing.set(s, row.eventTs.getTime());
|
||||
}
|
||||
|
||||
let inserted = 0;
|
||||
for (const e of dedupeTruthEvents(candidates, existing)) {
|
||||
const kw = COIN_KEYWORDS.find((c) => c.symbol === e.symbol)!;
|
||||
await db
|
||||
.insert(trumpEvents)
|
||||
.values({
|
||||
source: 'truth', token: e.symbol, instrument: kw.instrument, eventTs: new Date(e.ts), ref: e.url,
|
||||
consumedAt: consumedAtForInsert(e.ts, Date.now()),
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
inserted++;
|
||||
}
|
||||
return inserted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beide Quellen, Fehler isoliert (eine tote Quelle stoppt die andere nicht).
|
||||
* Annahme: läuft nie nebenläufig (5-min-Loop ist seriell, Engine hat cycling-Guard) —
|
||||
* das Truth-Dedupe liest existing vor den Inserts und wäre bei Parallelläufen lückenhaft.
|
||||
*/
|
||||
export async function pollSignals(): Promise<void> {
|
||||
const results = await Promise.allSettled([pollOnchain(), pollTruth()]);
|
||||
for (const r of results) {
|
||||
if (r.status === 'rejected') console.error('Signal-Poller-Fehler:', r.reason);
|
||||
}
|
||||
}
|
||||
40
src/server/signals/truth.test.ts
Normal file
40
src/server/signals/truth.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { matchCoins, parseTruthFeed } from './truth';
|
||||
|
||||
const XML = `<?xml version="1.0"?><rss><channel>
|
||||
<item><link>https://trumpstruth.org/statuses/1</link><pubDate>Fri, 12 Jun 2026 01:49:56 +0000</pubDate><description><![CDATA[<p>Bitcoin is going to the MOON. Buy BTC!</p>]]></description></item>
|
||||
<item><link>https://trumpstruth.org/statuses/2</link><pubDate>Thu, 11 Jun 2026 09:00:00 +0000</pubDate><description><![CDATA[Crooked media!]]></description></item>
|
||||
</channel></rss>`;
|
||||
|
||||
describe('parseTruthFeed', () => {
|
||||
test('extrahiert URL, Timestamp, Klartext (Tags entfernt)', () => {
|
||||
const posts = parseTruthFeed(XML);
|
||||
expect(posts).toHaveLength(2);
|
||||
expect(posts[0].url).toBe('https://trumpstruth.org/statuses/1');
|
||||
expect(posts[0].ts).toBe(Date.parse('Fri, 12 Jun 2026 01:49:56 +0000'));
|
||||
expect(posts[0].text).toContain('Bitcoin is going to the MOON');
|
||||
expect(posts[0].text).not.toContain('<p>');
|
||||
});
|
||||
test('überspringt malformed Items, leerer Input → []', () => {
|
||||
expect(parseTruthFeed('')).toEqual([]);
|
||||
const bad = `<rss><channel>
|
||||
<item><pubDate>Fri, 12 Jun 2026 01:49:56 +0000</pubDate><description>ohne Link</description></item>
|
||||
<item><link>https://trumpstruth.org/statuses/3</link><pubDate>kein Datum</pubDate><description>x</description></item>
|
||||
</channel></rss>`;
|
||||
expect(parseTruthFeed(bad)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchCoins', () => {
|
||||
test('Name case-insensitive, Ticker nur exakt groß', () => {
|
||||
expect(matchCoins('I love BITCOIN and solana')).toEqual(['BTC', 'SOL']);
|
||||
expect(matchCoins('Buy ETH now')).toEqual(['ETH']);
|
||||
expect(matchCoins('the ethics committee')).toEqual([]); // 'eth' klein/Teilwort matcht nicht
|
||||
expect(matchCoins('Das sei seitwärts')).toEqual([]); // 'SEI' nur in Großschreibung
|
||||
expect(matchCoins('THE ARENA IS PACKED')).toEqual([]); // 'ENA' nur mit Wortgrenze
|
||||
expect(matchCoins('Tron will be huge')).toEqual(['TRX']); // nicht handelbar, aber Event
|
||||
});
|
||||
test('dedupliziert Mehrfach-Erwähnungen im selben Text', () => {
|
||||
expect(matchCoins('BTC BTC Bitcoin')).toEqual(['BTC']);
|
||||
});
|
||||
});
|
||||
41
src/server/signals/truth.ts
Normal file
41
src/server/signals/truth.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { COIN_KEYWORDS } from './watchlist';
|
||||
|
||||
export interface TruthPost {
|
||||
url: string;
|
||||
ts: number; // Unix ms
|
||||
text: string;
|
||||
}
|
||||
|
||||
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
/** RSS-Items per Regex (kein XML-Parser nötig — Feed ist flach und stabil). */
|
||||
export function parseTruthFeed(xml: string): TruthPost[] {
|
||||
const posts: TruthPost[] = [];
|
||||
for (const item of xml.match(/<item>[\s\S]*?<\/item>/g) ?? []) {
|
||||
const url = item.match(/<link>([^<]+)<\/link>/)?.[1]?.trim();
|
||||
const pubDate = item.match(/<pubDate>([^<]+)<\/pubDate>/)?.[1];
|
||||
const descRaw = item.match(/<description>([\s\S]*?)<\/description>/)?.[1] ?? '';
|
||||
if (!url || !pubDate) continue;
|
||||
const ts = Date.parse(pubDate);
|
||||
if (Number.isNaN(ts)) continue;
|
||||
const text = descRaw
|
||||
.replace(/^<!\[CDATA\[|\]\]>$/g, '')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/ /g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
posts.push({ url, ts, text });
|
||||
}
|
||||
return posts;
|
||||
}
|
||||
|
||||
/** Erwähnte Coins (Symbole, dedupliziert, in COIN_KEYWORDS-Reihenfolge). */
|
||||
export function matchCoins(text: string): string[] {
|
||||
const hits: string[] = [];
|
||||
for (const c of COIN_KEYWORDS) {
|
||||
const nameHit = c.names.some((n) => new RegExp(`\\b${esc(n)}\\b`, 'i').test(text));
|
||||
const tickerHit = c.tickers.some((t) => new RegExp(`\\b${esc(t)}\\b`).test(text)); // case-sensitive
|
||||
if (nameHit || tickerHit) hits.push(c.symbol);
|
||||
}
|
||||
return hits;
|
||||
}
|
||||
24
src/server/signals/watchlist.test.ts
Normal file
24
src/server/signals/watchlist.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { TRACKED_TOKENS, COIN_KEYWORDS, WATCHED_WALLETS } from './watchlist';
|
||||
import { TRUMP_PAIRS } from '../types';
|
||||
|
||||
describe('watchlist', () => {
|
||||
test('alle Token-Instrumente liegen im Trump-Universum', () => {
|
||||
for (const t of TRACKED_TOKENS) {
|
||||
if (t.instrument) expect(TRUMP_PAIRS).toContain(t.instrument);
|
||||
}
|
||||
});
|
||||
test('keine doppelten Contracts', () => {
|
||||
const contracts = TRACKED_TOKENS.map((t) => t.contract);
|
||||
expect(new Set(contracts).size).toBe(contracts.length);
|
||||
});
|
||||
test('Contract-Adressen und Wallets sind lowercase (getLogs-Vergleich)', () => {
|
||||
for (const t of TRACKED_TOKENS) expect(t.contract).toBe(t.contract.toLowerCase());
|
||||
for (const w of WATCHED_WALLETS) expect(w.address).toBe(w.address.toLowerCase());
|
||||
});
|
||||
test('Keyword-Instrumente liegen im Trump-Universum oder sind null', () => {
|
||||
for (const c of COIN_KEYWORDS) {
|
||||
if (c.instrument) expect(TRUMP_PAIRS).toContain(c.instrument);
|
||||
}
|
||||
});
|
||||
});
|
||||
57
src/server/signals/watchlist.ts
Normal file
57
src/server/signals/watchlist.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Pair } from '../types';
|
||||
|
||||
/**
|
||||
* Kuratierte Trump-Signal-Config. Aufnahme-Kriterium Watchlist:
|
||||
* öffentlich dokumentierte Attribution (Etherscan-Label oder Presse mit Tx-Beleg),
|
||||
* Quelle als Kommentar an jedem Eintrag. Alle Adressen lowercase.
|
||||
*/
|
||||
export interface TrackedToken {
|
||||
symbol: string;
|
||||
contract: string;
|
||||
decimals: number;
|
||||
instrument: Pair | null; // null = Event nur loggen, nicht handeln
|
||||
}
|
||||
|
||||
export const WATCHED_WALLETS: { address: string; label: string }[] = [
|
||||
// Etherscan-Label „World Liberty: Multisig"; hält ONDO + Reste LINK/AAVE/ENA/WBTC (on-chain verifiziert 2026-06-12)
|
||||
{ address: '0x5be9a4959308a0d0c7bc0870e319314d8d957dbb', label: 'World Liberty: Multisig' },
|
||||
];
|
||||
|
||||
// Contracts + decimals on-chain verifiziert via eth_call symbol()/decimals() am 2026-06-12
|
||||
export const TRACKED_TOKENS: TrackedToken[] = [
|
||||
{ symbol: 'WBTC', contract: '0x2260fac5e5542a773aa44fbcfedf7c193bc2c599', decimals: 8, instrument: 'BTC_USDT' },
|
||||
{ symbol: 'WETH', contract: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', decimals: 18, instrument: 'ETH_USDT' },
|
||||
{ symbol: 'LINK', contract: '0x514910771af9ca656af840dff83e8264ecf986ca', decimals: 18, instrument: 'LINK_USDT' },
|
||||
{ symbol: 'AAVE', contract: '0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9', decimals: 18, instrument: 'AAVE_USDT' },
|
||||
{ symbol: 'ONDO', contract: '0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3', decimals: 18, instrument: 'ONDO_USDT' },
|
||||
{ symbol: 'ENA', contract: '0x57e114b691db790c35207b2e685d4a43181e6061', decimals: 18, instrument: 'ENA_USDT' },
|
||||
];
|
||||
|
||||
export const TOKEN_BY_CONTRACT = new Map(TRACKED_TOKENS.map((t) => [t.contract, t]));
|
||||
|
||||
/** Truth-Social-Matching: names case-insensitive, tickers nur in Großschreibung (False-Positive-Schutz). */
|
||||
export const COIN_KEYWORDS: { symbol: string; instrument: Pair | null; names: string[]; tickers: string[] }[] = [
|
||||
{ symbol: 'BTC', instrument: 'BTC_USDT', names: ['bitcoin'], tickers: ['BTC'] },
|
||||
{ symbol: 'ETH', instrument: 'ETH_USDT', names: ['ethereum', 'ether'], tickers: ['ETH'] },
|
||||
{ symbol: 'XRP', instrument: 'XRP_USDT', names: ['ripple'], tickers: ['XRP'] },
|
||||
{ symbol: 'SOL', instrument: 'SOL_USDT', names: ['solana'], tickers: ['SOL'] },
|
||||
{ symbol: 'LINK', instrument: 'LINK_USDT', names: ['chainlink'], tickers: ['LINK'] },
|
||||
{ symbol: 'AAVE', instrument: 'AAVE_USDT', names: ['aave'], tickers: ['AAVE'] },
|
||||
{ symbol: 'ONDO', instrument: 'ONDO_USDT', names: ['ondo'], tickers: ['ONDO'] },
|
||||
{ symbol: 'ENA', instrument: 'ENA_USDT', names: ['ethena'], tickers: ['ENA'] },
|
||||
{ symbol: 'SUI', instrument: 'SUI_USDT', names: [], tickers: ['SUI'] }, // 'sui' lowercase zu generisch
|
||||
{ symbol: 'SEI', instrument: 'SEI_USDT', names: [], tickers: ['SEI'] }, // 'sei' lowercase zu generisch (engl. Wort)
|
||||
{ symbol: 'TRX', instrument: null, names: ['tron'], tickers: ['TRX'] }, // kein USDT-Paar auf Crypto.com
|
||||
];
|
||||
|
||||
/** RPC-Endpunkte ohne Account; Reihenfolge = Priorität. */
|
||||
export const RPC_URLS = [
|
||||
'https://ethereum-rpc.publicnode.com',
|
||||
'https://eth.llamarpc.com',
|
||||
'https://cloudflare-eth.com',
|
||||
];
|
||||
|
||||
/** Inoffizieller RSS-Mirror (Dritt-Archiv), kein offizielles Truth-Social-API. */
|
||||
export const TRUTH_FEED_URL = 'https://trumpstruth.org/feed';
|
||||
export const MIN_NOTIONAL_USD = 50_000; // Spam-/Dust-Schutz on-chain
|
||||
export const TRUTH_DEDUPE_MS = 72 * 3600_000; // max. 1 Truth-Event je Coin pro 72 h
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -8,4 +8,14 @@ export interface Candle {
|
||||
}
|
||||
|
||||
export const PAIRS = ['BTC_USDT', 'ETH_USDT', 'SOL_USDT', 'XRP_USDT'] as const;
|
||||
export type Pair = (typeof PAIRS)[number];
|
||||
|
||||
/** Handels-Universum der Trump-Engine (Schnittmenge Mapping × Crypto.com-USDT-Paare). */
|
||||
export const TRUMP_PAIRS = [
|
||||
'BTC_USDT', 'ETH_USDT', 'SOL_USDT', 'XRP_USDT',
|
||||
'LINK_USDT', 'AAVE_USDT', 'ONDO_USDT', 'ENA_USDT', 'SUI_USDT', 'SEI_USDT',
|
||||
] as const;
|
||||
|
||||
export type Pair = (typeof PAIRS)[number] | (typeof TRUMP_PAIRS)[number];
|
||||
|
||||
/** Vereinigung beider Universen — für Candle-Backfill. */
|
||||
export const ALL_PAIRS: readonly Pair[] = [...new Set<Pair>([...PAIRS, ...TRUMP_PAIRS])];
|
||||
|
||||
Reference in New Issue
Block a user