Compare commits
38 Commits
29846e82a7
...
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 |
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`)
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
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");
|
||||
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": {}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,20 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -8,8 +8,11 @@
|
||||
"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",
|
||||
|
||||
@@ -12,18 +12,33 @@
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; padding: 24px; max-width: 1200px; margin: 0 auto; }
|
||||
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||||
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; }
|
||||
@@ -38,25 +53,78 @@
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>trade-kuns <small>Donchian-Trendfolge · Paper · BTC ETH SOL XRP</small></h1>
|
||||
<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>
|
||||
|
||||
<div class="kpis" id="kpis"></div>
|
||||
<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>
|
||||
|
||||
<div class="panel"><h2>Equity-Kurve (4h)</h2><canvas id="chart" height="220"></canvas></div>
|
||||
<section id="tab-trading">
|
||||
<div class="kpis" id="kpis"></div>
|
||||
|
||||
<div class="panel"><h2>Offene Positionen</h2><div id="positions"></div></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>Abgeschlossene Trades</h2><div id="trades"></div></div>
|
||||
<div class="panel"><h2>Equity-Kurve (4h)</h2><canvas id="chart" height="220"></canvas></div>
|
||||
|
||||
<div class="panel"><h2>Letzte Entscheidungen (4h-Bars)</h2><div id="decisions"></div></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>`;
|
||||
@@ -67,55 +135,299 @@ function table(headers, rows, emptyMsg) {
|
||||
return `<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
|
||||
}
|
||||
|
||||
function drawChart(curve, startCapital) {
|
||||
const cv = document.getElementById('chart');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = cv.clientWidth, h = 220;
|
||||
cv.width = w * dpr; cv.height = h * dpr;
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (curve.length < 2) {
|
||||
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
|
||||
ctx.fillText('Noch keine Equity-Historie — der erste 4h-Bucket kommt.', 10, h / 2);
|
||||
return;
|
||||
// ── 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);
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
ctx.strokeStyle = '#8b949e'; ctx.setLineDash([4, 4]);
|
||||
ctx.beginPath(); ctx.moveTo(pad.l, Y(startCapital)); ctx.lineTo(w - pad.r, Y(startCapital)); ctx.stroke();
|
||||
ctx.setLineDash([]);
|
||||
const last = ys[ys.length - 1];
|
||||
ctx.strokeStyle = last >= startCapital ? '#3fb950' : '#f85149'; ctx.lineWidth = 1.8;
|
||||
ctx.beginPath();
|
||||
curve.forEach((p, i) => i ? ctx.lineTo(X(p.ts), Y(p.equity)) : ctx.moveTo(X(p.ts), Y(p.equity)));
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = '#8b949e';
|
||||
ctx.fillText(fmtTs(xmin), pad.l, h - 6);
|
||||
const endLabel = fmtTs(xmax);
|
||||
ctx.fillText(endLabel, w - pad.r - ctx.measureText(endLabel).width, h - 6);
|
||||
|
||||
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] = await Promise.all([
|
||||
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=30').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 =
|
||||
@@ -128,7 +440,8 @@ async function refresh() {
|
||||
kpi('Max DD', fmt(stats.maxDrawdownPct * 100, 1) + ' %') +
|
||||
kpi('BTC Buy&Hold', stats.btcBuyHoldPct == null ? '–' : sign(stats.btcBuyHoldPct, 1) + ' %', cls(stats.btcBuyHoldPct));
|
||||
|
||||
drawChart(stats.equityCurve || [], stats.startCapital);
|
||||
equityChart.render(stats.equityCurve || [], stats.startCapital);
|
||||
renderTrendPrice();
|
||||
|
||||
document.getElementById('positions').innerHTML = table(
|
||||
['Pair', 'Entry', 'Entry-Preis', 'Letzter', 'Stop', 'Wert $', 'PnL $'],
|
||||
@@ -137,7 +450,7 @@ async function refresh() {
|
||||
|
||||
document.getElementById('trades').innerHTML = table(
|
||||
['Pair', 'Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'R', 'Grund'],
|
||||
trades.map(t => `<tr><td><span class="tag long">${t.pair}</span></td><td>${fmtTs(new Date(t.entryTs).getTime())}</td><td>${fmtTs(new Date(t.exitTs).getTime())}</td><td>${fmt(t.entryPrice, 4)}</td><td>${fmt(t.exitPrice, 4)}</td><td class="${cls(t.pnl)}">${sign(t.pnl)}</td><td class="${cls(t.r)}">${sign(t.r, 2)}</td><td>${t.exitReason}</td></tr>`),
|
||||
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(
|
||||
@@ -150,6 +463,91 @@ async function refresh() {
|
||||
}),
|
||||
'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 =
|
||||
@@ -159,8 +557,16 @@ async function refresh() {
|
||||
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>
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { and, desc, eq, gte } from 'drizzle-orm';
|
||||
import { db } from '../db/client';
|
||||
import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema';
|
||||
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' } });
|
||||
@@ -66,7 +69,7 @@ async function getPortfolio(engine: LiveEngine) {
|
||||
|
||||
async function getStats() {
|
||||
const [state] = await db.select().from(botState).where(eq(botState.id, 1));
|
||||
const tradeRows = await db.select().from(paperTrades);
|
||||
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(),
|
||||
@@ -79,7 +82,11 @@ async function getStats() {
|
||||
exitReason: t.exitReason as ClosedTrade['exitReason'],
|
||||
side: t.side as 'long' | 'short',
|
||||
}));
|
||||
const curveRows = await db.select().from(equitySnapshots).orderBy(equitySnapshots.ts);
|
||||
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);
|
||||
@@ -104,7 +111,78 @@ async function getStats() {
|
||||
return { ...metrics, startCapital: start, equityCurve: curve, btcBuyHoldPct };
|
||||
}
|
||||
|
||||
export function createServer(engine: LiveEngine, port: number) {
|
||||
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({
|
||||
@@ -124,7 +202,13 @@ export function createServer(engine: LiveEngine, port: number) {
|
||||
return json(await getPortfolio(engine));
|
||||
case '/api/trades': {
|
||||
const limit = clampLimit(url, 100, 500);
|
||||
const rows = await db.select().from(paperTrades).orderBy(desc(paperTrades.exitTs)).limit(limit);
|
||||
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': {
|
||||
@@ -136,6 +220,20 @@ export function createServer(engine: LiveEngine, port: number) {
|
||||
}
|
||||
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);
|
||||
|
||||
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() };
|
||||
}
|
||||
@@ -50,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;
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -29,6 +29,7 @@ export const positions = pgTable('positions', {
|
||||
|
||||
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(),
|
||||
@@ -69,10 +70,38 @@ export const botState = pgTable('bot_state', {
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const equitySnapshots = pgTable('equity_snapshots', {
|
||||
ts: timestamp('ts', { withTimezone: true }).primaryKey(), // 4h-Bucket
|
||||
equity: doublePrecision('equity').notNull(),
|
||||
cash: doublePrecision('cash').notNull(),
|
||||
export const 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', {
|
||||
@@ -82,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' | 'rotation';
|
||||
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop' | 'trump_hold';
|
||||
side: 'long' | 'short';
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +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();
|
||||
createServer(engine, env.PORT);
|
||||
console.log(`trade-kuns Live-Paper-Engine läuft auf :${env.PORT}`);
|
||||
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}`);
|
||||
|
||||
void engine.runCycle();
|
||||
setInterval(() => void engine.runCycle(), CYCLE_MS);
|
||||
// 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);
|
||||
|
||||
@@ -186,8 +186,11 @@ export class LiveEngine {
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
for (const s of result.equitySnapshots) {
|
||||
const row = { ts: new Date(s.ts), equity: s.equity, cash: s.cash };
|
||||
await tx.insert(equitySnapshots).values(row).onConflictDoUpdate({ target: equitySnapshots.ts, set: row });
|
||||
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)
|
||||
|
||||
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));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ export function processCycle(
|
||||
}
|
||||
return { pair, c4h, ind: computeIndicators(c4h, cfg.params), next4h };
|
||||
});
|
||||
const byPair = new Map(contexts.map((c) => [c.pair, c]));
|
||||
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) {
|
||||
@@ -90,7 +90,7 @@ export function processCycle(
|
||||
if (candle.ts > state.cursorTs) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
||||
}
|
||||
}
|
||||
timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair) - PAIRS.indexOf(b.pair));
|
||||
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;
|
||||
|
||||
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,4 +1,4 @@
|
||||
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';
|
||||
@@ -7,7 +7,7 @@ const M15 = 15 * 60 * 1000;
|
||||
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();
|
||||
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();
|
||||
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
|
||||
@@ -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