Compare commits
31 Commits
2bd566ce5e
...
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 |
@@ -1,8 +1,9 @@
|
|||||||
# trade-kuns
|
# trade-kuns
|
||||||
|
|
||||||
Zwei Paper-Engines in einem Prozess (**paper-only — keine Order-Ausführung**):
|
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.
|
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`.
|
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
|
## Stack & Befehle
|
||||||
- Bun 1.3 + TypeScript, Drizzle (Postgres), Zod. Tests collocated (`*.test.ts`).
|
- Bun 1.3 + TypeScript, Drizzle (Postgres), Zod. Tests collocated (`*.test.ts`).
|
||||||
|
|||||||
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
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.
|
||||||
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");
|
||||||
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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,13 @@
|
|||||||
"when": 1781076227862,
|
"when": 1781076227862,
|
||||||
"tag": "0002_burly_joystick",
|
"tag": "0002_burly_joystick",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781250542971,
|
||||||
|
"tag": "0003_kind_sheva_callister",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -10,7 +10,9 @@
|
|||||||
"rotation": "bun run src/server/scripts/rotation-walkforward.ts",
|
"rotation": "bun run src/server/scripts/rotation-walkforward.ts",
|
||||||
"grid": "bun run src/server/scripts/grid-walkforward.ts",
|
"grid": "bun run src/server/scripts/grid-walkforward.ts",
|
||||||
"db:generate": "bunx drizzle-kit generate",
|
"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": {
|
"dependencies": {
|
||||||
"drizzle-orm": "^0.44.0",
|
"drizzle-orm": "^0.44.0",
|
||||||
|
|||||||
@@ -53,13 +53,14 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>trade-kuns <small>Paper · Trend (BTC ETH SOL XRP) + Grid (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>
|
<div id="status"><span class="dot" style="background:var(--muted)"></span>lade…</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tabs">
|
<nav class="tabs">
|
||||||
<button class="tab active" data-tab="trading">Trading</button>
|
<button class="tab active" data-tab="trading">Trading</button>
|
||||||
<button class="tab" data-tab="grid">GridBot</button>
|
<button class="tab" data-tab="grid">GridBot</button>
|
||||||
|
<button class="tab" data-tab="trump">Trump</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section id="tab-trading">
|
<section id="tab-trading">
|
||||||
@@ -106,12 +107,24 @@
|
|||||||
<div class="panel"><h2>Grid-Trades</h2><div id="grid-trades"></div></div>
|
<div class="panel"><h2>Grid-Trades</h2><div id="grid-trades"></div></div>
|
||||||
</section>
|
</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>
|
<script>
|
||||||
const fmt = (n, d = 2) => n == null || Number.isNaN(n) ? '–' : n.toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
|
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 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 cls = (n) => n > 0 ? 'pos' : n < 0 ? 'neg' : '';
|
||||||
const sign = (n, d = 2) => (n > 0 ? '+' : '') + fmt(n, d);
|
const sign = (n, d = 2) => (n > 0 ? '+' : '') + fmt(n, d);
|
||||||
const priceDec = (p) => p < 1 ? 5 : p < 10 ? 4 : p < 1000 ? 2 : 0;
|
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 = '') {
|
function kpi(label, value, klass = '') {
|
||||||
return `<div class="kpi"><div class="label">${label}</div><div class="value ${klass}">${value}</div></div>`;
|
return `<div class="kpi"><div class="label">${label}</div><div class="value ${klass}">${value}</div></div>`;
|
||||||
@@ -348,11 +361,13 @@ function tradeMarkers(trades, { withR = false, label = '' } = {}) {
|
|||||||
const charts = {
|
const charts = {
|
||||||
trading: [], // wird unten befüllt
|
trading: [], // wird unten befüllt
|
||||||
grid: [],
|
grid: [],
|
||||||
|
trump: [],
|
||||||
};
|
};
|
||||||
function showTab(name) {
|
function showTab(name) {
|
||||||
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
document.querySelectorAll('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === name));
|
||||||
document.getElementById('tab-trading').hidden = name !== 'trading';
|
document.getElementById('tab-trading').hidden = name !== 'trading';
|
||||||
document.getElementById('tab-grid').hidden = name !== 'grid';
|
document.getElementById('tab-grid').hidden = name !== 'grid';
|
||||||
|
document.getElementById('tab-trump').hidden = name !== 'trump';
|
||||||
history.replaceState(null, '', '#' + name);
|
history.replaceState(null, '', '#' + name);
|
||||||
requestAnimationFrame(() => (charts[name] || []).forEach(c => c.redraw()));
|
requestAnimationFrame(() => (charts[name] || []).forEach(c => c.redraw()));
|
||||||
}
|
}
|
||||||
@@ -399,13 +414,15 @@ async function renderTrendPrice() {
|
|||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const [pf, stats, trades, decisions, grid, gridTradesAll] = await Promise.all([
|
const [pf, stats, trades, decisions, grid, gridTradesAll, trump, trumpTradesAll] = await Promise.all([
|
||||||
fetch('/api/portfolio').then(r => r.json()),
|
fetch('/api/portfolio').then(r => r.json()),
|
||||||
fetch('/api/stats').then(r => r.json()),
|
fetch('/api/stats').then(r => r.json()),
|
||||||
fetch('/api/trades?limit=200').then(r => r.json()),
|
fetch('/api/trades?limit=200').then(r => r.json()),
|
||||||
fetch('/api/decisions?limit=12').then(r => r.json()),
|
fetch('/api/decisions?limit=12').then(r => r.json()),
|
||||||
fetch('/api/grid').then(r => r.json()),
|
fetch('/api/grid').then(r => r.json()),
|
||||||
fetch('/api/trades?bot=grid&limit=200').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;
|
lastTrades = trades;
|
||||||
lastPositions = pf.positions;
|
lastPositions = pf.positions;
|
||||||
@@ -494,6 +511,43 @@ async function refresh() {
|
|||||||
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>`),
|
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.');
|
'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 eng = pf.engine || {};
|
||||||
const ok = eng.lastCycleOk !== false;
|
const ok = eng.lastCycleOk !== false;
|
||||||
document.getElementById('status').innerHTML =
|
document.getElementById('status').innerHTML =
|
||||||
@@ -505,6 +559,7 @@ async function refresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (location.hash === '#grid') showTab('grid');
|
if (location.hash === '#grid') showTab('grid');
|
||||||
|
else if (location.hash === '#trump') showTab('trump');
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 30000);
|
setInterval(refresh, 30000);
|
||||||
let resizeT;
|
let resizeT;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { and, desc, eq, gte } from 'drizzle-orm';
|
import { and, desc, eq, gte } from 'drizzle-orm';
|
||||||
import { db } from '../db/client';
|
import { db } from '../db/client';
|
||||||
import { botState, candles, decisionLogs, equitySnapshots, gridLots, gridState, 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 { aggregate4h } from '../market/aggregate';
|
||||||
import { computeMetrics, type EquityPoint } from '../backtest/metrics';
|
import { computeMetrics, type EquityPoint } from '../backtest/metrics';
|
||||||
import type { ClosedTrade } from '../engine/portfolio';
|
import type { ClosedTrade } from '../engine/portfolio';
|
||||||
@@ -9,6 +9,7 @@ import { PAIRS } from '../types';
|
|||||||
import type { LiveEngine } from '../live/engine';
|
import type { LiveEngine } from '../live/engine';
|
||||||
import type { GridEngine } from '../live/grid-engine';
|
import type { GridEngine } from '../live/grid-engine';
|
||||||
import { GRID_CYCLE_CONFIG } 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 {
|
function json(data: unknown, status = 200): Response {
|
||||||
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } });
|
||||||
@@ -181,7 +182,7 @@ async function getGrid(gridEngine: GridEngine) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer(engine: LiveEngine, gridEngine: GridEngine, port: number) {
|
export function createServer(engine: LiveEngine, gridEngine: GridEngine, trumpEngine: TrumpEngine, port: number) {
|
||||||
const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url));
|
const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url));
|
||||||
|
|
||||||
return Bun.serve({
|
return Bun.serve({
|
||||||
@@ -221,6 +222,18 @@ export function createServer(engine: LiveEngine, gridEngine: GridEngine, port: n
|
|||||||
return json(await getStats());
|
return json(await getStats());
|
||||||
case '/api/grid':
|
case '/api/grid':
|
||||||
return json(await getGrid(gridEngine));
|
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': {
|
case '/api/candles': {
|
||||||
const pair = url.searchParams.get('pair') ?? 'BTC_USDT';
|
const pair = url.searchParams.get('pair') ?? 'BTC_USDT';
|
||||||
if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400);
|
if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400);
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function runGridBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: GridC
|
|||||||
const c4h = aggregateTf(c15, p.tfMs);
|
const c4h = aggregateTf(c15, p.tfMs);
|
||||||
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h: 0 };
|
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h: 0 };
|
||||||
});
|
});
|
||||||
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 }[] = [];
|
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
|
||||||
for (const ctx of contexts) {
|
for (const ctx of contexts) {
|
||||||
@@ -126,7 +126,7 @@ export function runGridBacktest(candles15ByPair: Map<Pair, Candle[]>, cfg: GridC
|
|||||||
if (candle.ts < cfg.tradeTo) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
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]));
|
||||||
|
|
||||||
let lastEquityBucket = -1;
|
let lastEquityBucket = -1;
|
||||||
|
|
||||||
|
|||||||
@@ -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 });
|
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]));
|
const byPair = new Map<Pair, PairContext>(contexts.map((c) => [c.pair, c]));
|
||||||
let lastEquityBucket = -1;
|
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,7 +29,7 @@ export const positions = pgTable('positions', {
|
|||||||
|
|
||||||
export const paperTrades = pgTable('paper_trades', {
|
export const paperTrades = pgTable('paper_trades', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
bot: text('bot').notNull().default('trend'), // 'trend' | 'grid'
|
bot: text('bot').notNull().default('trend'), // 'trend' | 'grid' | 'trump'
|
||||||
pair: varchar('pair', { length: 16 }).notNull(),
|
pair: varchar('pair', { length: 16 }).notNull(),
|
||||||
side: text('side').notNull(),
|
side: text('side').notNull(),
|
||||||
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
entryTs: timestamp('entry_ts', { withTimezone: true }).notNull(),
|
||||||
@@ -111,3 +111,39 @@ export const backtestRuns = pgTable('backtest_runs', {
|
|||||||
config: jsonb('config').notNull(),
|
config: jsonb('config').notNull(),
|
||||||
result: jsonb('result').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;
|
qty: number;
|
||||||
pnl: number;
|
pnl: number;
|
||||||
r: number;
|
r: number;
|
||||||
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop';
|
exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop' | 'trump_hold';
|
||||||
side: 'long' | 'short';
|
side: 'long' | 'short';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
import { env } from './config';
|
import { env } from './config';
|
||||||
import { LiveEngine } from './live/engine';
|
import { LiveEngine } from './live/engine';
|
||||||
import { GridEngine } from './live/grid-engine';
|
import { GridEngine } from './live/grid-engine';
|
||||||
|
import { TrumpEngine } from './live/trump-engine';
|
||||||
import { createServer } from './api/server';
|
import { createServer } from './api/server';
|
||||||
|
|
||||||
const CYCLE_MS = 5 * 60 * 1000;
|
const CYCLE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
const engine = new LiveEngine();
|
const engine = new LiveEngine();
|
||||||
const gridEngine = new GridEngine();
|
const gridEngine = new GridEngine();
|
||||||
|
const trumpEngine = new TrumpEngine();
|
||||||
await engine.init();
|
await engine.init();
|
||||||
await gridEngine.init();
|
await gridEngine.init();
|
||||||
createServer(engine, gridEngine, env.PORT);
|
await trumpEngine.init();
|
||||||
console.log(`trade-kuns Live-Paper-Engines (trend + grid) laufen auf :${env.PORT}`);
|
createServer(engine, gridEngine, trumpEngine, env.PORT);
|
||||||
|
console.log(`trade-kuns Live-Paper-Engines (trend + grid + trump) laufen auf :${env.PORT}`);
|
||||||
|
|
||||||
// Grid läuft nach der Trend-Engine — deren Gap-Fetch füllt die Candle-DB für beide.
|
// Grid/Trump laufen nach der Trend-Engine — deren Gap-Fetch füllt die Candle-DB für PAIRS.
|
||||||
const cycle = async () => {
|
const cycle = async () => {
|
||||||
await engine.runCycle();
|
await engine.runCycle();
|
||||||
await gridEngine.runCycle();
|
await gridEngine.runCycle();
|
||||||
|
await trumpEngine.runCycle();
|
||||||
};
|
};
|
||||||
void cycle();
|
void cycle();
|
||||||
setInterval(() => void cycle(), CYCLE_MS);
|
setInterval(() => void cycle(), CYCLE_MS);
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function processGridCycle(
|
|||||||
}
|
}
|
||||||
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), next4h };
|
return { pair, c4h, atr: atr(c4h, p.atrPeriod), adx: adx(c4h, p.atrPeriod), 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 }[] = [];
|
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
|
||||||
for (const ctx of contexts) {
|
for (const ctx of contexts) {
|
||||||
@@ -98,7 +98,7 @@ export function processGridCycle(
|
|||||||
if (candle.ts > state.cursorTs) timeline.push({ ts: candle.ts, pair: ctx.pair, candle });
|
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]));
|
||||||
|
|
||||||
const sell = (pair: Pair, ts: number, price: number, lot: GridLot, reason: ClosedTrade['exitReason']): void => {
|
const sell = (pair: Pair, ts: number, price: number, lot: GridLot, reason: ClosedTrade['exitReason']): void => {
|
||||||
const fill = price * (1 - exec.slippage);
|
const fill = price * (1 - exec.slippage);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function processCycle(
|
|||||||
}
|
}
|
||||||
return { pair, c4h, ind: computeIndicators(c4h, cfg.params), next4h };
|
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 }[] = [];
|
const timeline: { ts: number; pair: Pair; candle: Candle }[] = [];
|
||||||
for (const ctx of contexts) {
|
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 });
|
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 cursorTs = state.cursorTs;
|
||||||
let lastEquityBucket = -1;
|
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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { PAIRS } from '../types';
|
import { ALL_PAIRS } from '../types';
|
||||||
import { fetchCandles } from '../market/cryptocom';
|
import { fetchCandles } from '../market/cryptocom';
|
||||||
import { insertCandles, getCoverage } from '../market/candle-store';
|
import { insertCandles, getCoverage } from '../market/candle-store';
|
||||||
import { sql } from '../db/client';
|
import { sql } from '../db/client';
|
||||||
@@ -7,7 +7,7 @@ const M15 = 15 * 60 * 1000;
|
|||||||
const TARGET_MONTHS = 36;
|
const TARGET_MONTHS = 36;
|
||||||
const since = Date.now() - TARGET_MONTHS * 30 * 24 * 60 * 60 * 1000;
|
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 endTs: number | undefined = undefined;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
let prevOldest: number | undefined = undefined;
|
let prevOldest: number | undefined = undefined;
|
||||||
|
|||||||
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 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