From 896a29bd045764cb209d8e4774805c5ce43078cf Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 07:36:43 +0000 Subject: [PATCH] docs: Implementierungsplan Trump-Copy-Strategie (14 Tasks, TDD) Co-Authored-By: Claude Fable 5 --- docs/plans/2026-06-12-trump-copy-strategy.md | 1599 ++++++++++++++++++ 1 file changed, 1599 insertions(+) create mode 100644 docs/plans/2026-06-12-trump-copy-strategy.md diff --git a/docs/plans/2026-06-12-trump-copy-strategy.md b/docs/plans/2026-06-12-trump-copy-strategy.md new file mode 100644 index 0000000..63a91ba --- /dev/null +++ b/docs/plans/2026-06-12-trump-copy-strategy.md @@ -0,0 +1,1599 @@ +# Trump-Copy-Strategie Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Dritte Paper-Engine, die „Trump kauft"-Events (on-chain WLFI-Wallet-Käufe + Truth-Social-Erwähnungen) nachkauft, `holdHours` hält und zum Close verkauft — inkl. Event-Backfill und Event-Study vor Paper-Start. + +**Architecture:** Spiegelt die GridBot-Integration: pure Cycle-Funktion (`live/trump-cycle.ts`) + DB-Engine-Wrapper (`live/trump-engine.ts`, `bot_state` id=3) + dünner Backtest-Wrapper mit Paritätstest. Neu sind die Signal-Module (`signals/`): on-chain `eth_getLogs` über öffentliche RPCs und trumpstruth.org-RSS, Events landen dedupliziert in `trump_events`. + +**Tech Stack:** Bun + TypeScript, Drizzle/Postgres, keine neuen Dependencies (RSS-Parsing per Regex, RPC per `fetch`). + +**Spec:** `docs/specs/2026-06-12-trump-copy-strategy-design.md` + +**Verifizierte Fakten (2026-06-12, in diesem Plan fest verdrahtet):** +- ERC-20-Contracts on-chain verifiziert (symbol()+decimals() per eth_call): WBTC `0x2260fac5e5542a773aa44fbcfedf7c193bc2c599` (8), WETH `0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2` (18), LINK `0x514910771af9ca656af840dff83e8264ecf986ca` (18), AAVE `0x7fc66500c84a76ad7e9c93437bfc5ac33e2ddae9` (18), ONDO `0xfaba6f8e4a5e8ab82f62fe7c39859fa577269be3` (18), ENA `0x57e114b691db790c35207b2e685d4a43181e6061` (18) +- Watchlist v1: `0x5be9a4959308a0d0c7bc0870e319314d8d957dbb` — Etherscan-Label „World Liberty: Multisig", hält ONDO (342k) + Restbestände LINK/AAVE/ENA/WBTC +- Crypto.com USDT-Paare vorhanden: LINK, AAVE, ONDO, ENA, SUI, SEI ✓ — TRX, WBTC, MOVE ✗ +- trumpstruth.org: RSS `/feed` (100 Items, live), Archiv `/?page=N` (10 Posts/Seite, Markup-Klassen `status`, `status-info__body`), Posts unter `/statuses/` +- Öffentlicher RPC `https://ethereum-rpc.publicnode.com` funktioniert (eth_blockNumber, eth_call) + +--- + +### Task 1: Pair-Typ erweitern + +**Files:** +- Modify: `src/server/types.ts` +- Modify: `src/server/scripts/backfill.ts:10` + +- [ ] **Step 1: `types.ts` erweitern** — `PAIRS` bleibt das Trend-Universum, `Pair` wird über `ALL_PAIRS` verbreitert: + +```ts +export const PAIRS = ['BTC_USDT', 'ETH_USDT', 'SOL_USDT', 'XRP_USDT'] as const; + +/** 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([...PAIRS, ...TRUMP_PAIRS])]; +``` + +- [ ] **Step 2: `backfill.ts` auf `ALL_PAIRS` umstellen** — `import { PAIRS } from '../types'` → `import { ALL_PAIRS } from '../types'` und `for (const pair of PAIRS)` → `for (const pair of ALL_PAIRS)`. + +- [ ] **Step 3: Tests laufen lassen** + +Run: `bun test` +Expected: alle bestehenden Tests grün (Pair ist nur breiter geworden; Trend-Engine iteriert weiter `PAIRS`). + +- [ ] **Step 4: Commit** + +```bash +git add src/server/types.ts src/server/scripts/backfill.ts +git commit -m "feat: Pair-Typ um Trump-Universum erweitert (LINK/AAVE/ONDO/ENA/SUI/SEI)" +``` + +--- + +### Task 2: DB-Schema — trump_events, trump_positions, trump_signal_state + exitReason + +**Files:** +- Modify: `src/server/db/schema.ts` +- Modify: `src/server/engine/portfolio.ts:34` (exitReason-Union) +- Create: Migration via `bun run db:generate` + +- [ ] **Step 1: Tabellen in `schema.ts` anhängen** + +```ts +/** „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(), +}); +``` + +- [ ] **Step 2: `exitReason`-Union in `portfolio.ts` erweitern** — Zeile 34: + +```ts + exitReason: 'trailing_stop' | 'end_of_data' | 'rotation' | 'grid_tp' | 'grid_stop' | 'trump_hold'; +``` + +- [ ] **Step 3: Migration generieren und anwenden** + +Run: `bun run db:generate && bun run db:migrate` +Expected: neue Migration unter `drizzle/`, Migrate läuft fehlerfrei gegen `localhost:54320/tradekuns`. + +- [ ] **Step 4: Tests + Commit** + +Run: `bun test` +Expected: grün. + +```bash +git add src/server/db/schema.ts src/server/engine/portfolio.ts drizzle/ +git commit -m "feat: Schema für Trump-Engine (trump_events, trump_positions, trump_signal_state)" +``` + +--- + +### Task 3: signals/watchlist.ts — kuratierte Config + +**Files:** +- Create: `src/server/signals/watchlist.ts` +- Test: `src/server/signals/watchlist.test.ts` + +- [ ] **Step 1: Failing Test schreiben** + +```ts +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('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); + } + }); +}); +``` + +- [ ] **Step 2: Test fails** — Run: `bun test src/server/signals/watchlist.test.ts` → FAIL (Modul fehlt). + +- [ ] **Step 3: `watchlist.ts` implementieren** + +```ts +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'] }, + { 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', +]; + +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 +``` + +- [ ] **Step 4: Test passes** — Run: `bun test src/server/signals/watchlist.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/signals/watchlist.ts src/server/signals/watchlist.test.ts +git commit -m "feat: Trump-Signal-Watchlist (WLFI-Multisig, verifizierte ERC-20-Contracts, Truth-Keywords)" +``` + +--- + +### Task 4: signals/onchain.ts — eth_getLogs-Scanner + +**Files:** +- Create: `src/server/signals/onchain.ts` +- Test: `src/server/signals/onchain.test.ts` + +- [ ] **Step 1: Failing Tests für die puren Teile (Filter-Bau + Log-Decoding)** + +```ts +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); + }); +}); +``` + +- [ ] **Step 2: Test fails** — Run: `bun test src/server/signals/onchain.test.ts` → FAIL. + +- [ ] **Step 3: Implementieren** + +```ts +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 === '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 { + 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; + } + } + throw lastErr; +} + +export async function getBlockNumber(): Promise { + return Number(BigInt(await rpc('eth_blockNumber', []))); +} + +/** Block-Timestamp in Unix ms. */ +export async function getBlockTs(blockNumber: number): Promise { + 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 { + 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; +} +``` + +- [ ] **Step 4: Tests passes** — Run: `bun test src/server/signals/onchain.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/signals/onchain.ts src/server/signals/onchain.test.ts +git commit -m "feat: On-Chain-Scanner für Watchlist-Transfers (eth_getLogs, RPC-Fallback, Chunking)" +``` + +--- + +### Task 5: signals/truth.ts — RSS-Parser + Coin-Matching + +**Files:** +- Create: `src/server/signals/truth.ts` +- Test: `src/server/signals/truth.test.ts` + +- [ ] **Step 1: Failing Tests** + +```ts +import { describe, expect, test } from 'bun:test'; +import { matchCoins, parseTruthFeed } from './truth'; + +const XML = ` +https://trumpstruth.org/statuses/1Fri, 12 Jun 2026 01:49:56 +0000Bitcoin is going to the MOON. Buy BTC!

]]>
+https://trumpstruth.org/statuses/2Thu, 11 Jun 2026 09:00:00 +0000 +
`; + +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('

'); + }); +}); + +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']); + }); +}); +``` + +- [ ] **Step 2: Test fails** — Run: `bun test src/server/signals/truth.test.ts` → FAIL. + +- [ ] **Step 3: Implementieren** + +```ts +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(/[\s\S]*?<\/item>/g) ?? []) { + const url = item.match(/([^<]+)<\/link>/)?.[1]?.trim(); + const pubDate = item.match(/([^<]+)<\/pubDate>/)?.[1]; + const descRaw = item.match(/([\s\S]*?)<\/description>/)?.[1] ?? ''; + if (!url || !pubDate) continue; + const ts = Date.parse(pubDate); + if (Number.isNaN(ts)) continue; + const text = descRaw + .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; +} +``` + +- [ ] **Step 4: Tests passes** — Run: `bun test src/server/signals/truth.test.ts` → PASS. + Hinweis: schlägt der `'I love BITCOIN'`-Fall fehl, prüfen ob `\b`+`i`-Flag korrekt kombiniert ist — Namen matchen case-insensitive, Ticker case-sensitive; das ist die gewollte Asymmetrie. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/signals/truth.ts src/server/signals/truth.test.ts +git commit -m "feat: Truth-Social-RSS-Parser + Coin-Keyword-Matching" +``` + +--- + +### Task 6: signals/poller.ts — Events einsammeln und persistieren + +**Files:** +- Create: `src/server/signals/poller.ts` +- Test: `src/server/signals/poller.test.ts` + +Der Poller ist der einzige unpure Signal-Teil. Die testbare Logik (Notional-Filter, 72h-Dedupe) liegt in exportierten puren Helfern; die DB/HTTP-Orchestrierung bleibt dünn. + +- [ ] **Step 1: Failing Tests für die puren Helfer** + +```ts +import { describe, expect, test } from 'bun:test'; +import { dedupeTruthEvents, passesNotional } 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']); + }); +}); +``` + +- [ ] **Step 2: Test fails** — Run: `bun test src/server/signals/poller.test.ts` → FAIL. + +- [ ] **Step 3: Implementieren** + +```ts +import { and, desc, eq, inArray } 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'; + +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; + +export function passesNotional(amount: number, price: number | null): boolean { + return price !== null && amount * price >= MIN_NOTIONAL_USD; +} + +/** 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, +): { symbol: string; ts: number; url: string }[] { + const lastTs = new Map(existing); + const out: { symbol: string; ts: number; url: string }[] = []; + for (const e of [...batch].sort((a, b) => a.ts - b.ts)) { + const prev = lastTs.get(e.symbol); + if (prev !== undefined && e.ts - prev < TRUTH_DEDUPE_MS) continue; + lastTs.set(e.symbol, e.ts); + out.push(e); + } + return out; +} + +/** Letzter 15m-Close ≤ ts als USD-Proxy (USDT≈USD). null wenn keine Candle vorhanden. */ +async function priceAt(instrument: string, ts: number): Promise { + const candles = await getCandles(instrument as any, ts - 24 * 3600_000, ts + M15); + return candles.length > 0 ? candles[candles.length - 1].close : null; +} + +export async function pollOnchain(): Promise { + const head = await getBlockNumber(); + let [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(); + 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 (!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(); + inserted++; + } + await db.update(trumpSignalState).set({ lastBlock: to, updatedAt: new Date() }).where(eq(trumpSignalState.id, 1)); + return inserted; +} + +export async function pollTruth(): Promise { + 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(); + 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 }) + .onConflictDoNothing(); + inserted++; + } + return inserted; +} + +/** Beide Quellen, Fehler isoliert (eine tote Quelle stoppt die andere nicht). */ +export async function pollSignals(): Promise { + const results = await Promise.allSettled([pollOnchain(), pollTruth()]); + for (const r of results) { + if (r.status === 'rejected') console.error('Signal-Poller-Fehler:', r.reason); + } +} +``` + +- [ ] **Step 4: Tests passes** — Run: `bun test src/server/signals/poller.test.ts` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/server/signals/poller.ts src/server/signals/poller.test.ts +git commit -m "feat: Signal-Poller (on-chain Cursor-Scan + Truth-RSS, Notional-Filter, 72h-Dedupe)" +``` + +--- + +### Task 7: live/trump-cycle.ts — pure Cycle-Funktion (Kernstück) + +**Files:** +- Create: `src/server/live/trump-cycle.ts` +- Test: `src/server/live/trump-cycle.test.ts` + +Semantik (Spec §4): Event mit `eventTs ≤ candle.ts` → Buy zum Open dieser Candle (= „nächster 15m-Open nach dem Event"). Zeit-Exit zum Close der ersten Candle mit `ts + 15min ≥ entryTs + holdHours`. Events werden beim Verarbeiten immer konsumiert — auch wenn kein Slot frei/Pair belegt (verfallen, kein Nachholen). Exits vor Entries je Candle (Slot/Cash wird frei). + +- [ ] **Step 1: Failing Tests** + +```ts +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', 300)]]); + 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 / t.qty / 100 / (1 + DEFAULT_EXEC.slippage) / (1 + DEFAULT_EXEC.feeRate), 3); + }); + + test('Event verfällt, wenn Pair schon belegt (consumed, kein 2. Trade)', () => { + const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 300)]]); + 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', 300)], + ['ETH_USDT' as Pair, flat('ETH_USDT', 300, 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('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(); + 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); + }); +}); +``` + +- [ ] **Step 2: Test fails** — Run: `bun test src/server/live/trump-cycle.test.ts` → FAIL. + +- [ ] **Step 3: Implementieren** + +```ts +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). + */ +export function processTrumpCycle( + candles15ByPair: Map, + events: TrumpEventInput[], + state: TrumpLiveState, + cfg: TrumpCycleConfig, +): TrumpCycleResult { + let cash = state.cash; + const positions = new Map(); + 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(); + const holdMs = cfg.holdHours * 3600_000; + + const pairs = cfg.pairs.filter((p) => candles15ByPair.has(p)); + + const pending = new Map(); + 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); + } + + 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; + }; + + 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(), + }; +} +``` + +Achtung beim `entryCost`-Test in Step 1: mit der `/(1+feeRate)`-Normierung ist `entryCost` exakt das Budget (= 20 % Equity) — `toBeCloseTo(2000, 0)` muss passen. Falls beim zweiten Test die `r`-Formel zickt: `r = pnl / entryCost`, der Erwartungswert im Test ist äquivalent umgeformt; im Zweifel den Test-Erwartungswert auf `t.pnl / (10_000 * 0.2)` vereinfachen. + +- [ ] **Step 4: Tests passes** — Run: `bun test src/server/live/trump-cycle.test.ts` → PASS (insbesondere der Paritätstest). + +- [ ] **Step 5: Commit** + +```bash +git add src/server/live/trump-cycle.ts src/server/live/trump-cycle.test.ts +git commit -m "feat: Pure Trump-Cycle-Strategie (Event-Entry, Zeit-Exit, cursor-idempotent)" +``` + +--- + +### Task 8: backtest/trump.ts — dünner Backtest-Wrapper + +**Files:** +- Create: `src/server/backtest/trump.ts` + +Kein eigener Test nötig: Der Wrapper ruft `processTrumpCycle` einmal von frischem State auf (DRY — eine Implementierung für Live und Backtest); der Paritätstest in Task 7 deckt die Cursor-Semantik ab. + +- [ ] **Step 1: Implementieren** + +```ts +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, + 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); +} +``` + +- [ ] **Step 2: Typecheck + Commit** + +Run: `bunx tsc --noEmit` +Expected: keine Fehler. + +```bash +git add src/server/backtest/trump.ts +git commit -m "feat: Trump-Backtest-Wrapper (gleicher Code-Pfad wie Live)" +``` + +--- + +### Task 9: live/trump-engine.ts — DB-Wrapper (Engine Nr. 3) + +**Files:** +- Create: `src/server/live/trump-engine.ts` + +Spiegelt `grid-engine.ts` (`src/server/live/grid-engine.ts` als Vorlage daneben legen). Kein eigener Unit-Test (wie GridEngine: DB-Glue, die pure Logik ist in Task 7 getestet). + +- [ ] **Step 1: Implementieren** + +```ts +import { eq, inArray, isNull, isNotNull, and } from 'drizzle-orm'; +import { db } from '../db/client'; +import { botState, equitySnapshots, paperTrades, trumpEvents, trumpPositions } from '../db/schema'; +import { getCandles } from '../market/candle-store'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { pollSignals } from '../signals/poller'; +import { 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; + +export const TRUMP_CYCLE_CONFIG: TrumpCycleConfig = { + exec: DEFAULT_EXEC, + holdHours: 60, // Default lt. Spec; finaler Wert kommt aus der Event-Study + 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; + * holt Candles für die Nicht-Trend-Pairs selbst nach (Gap-Fetch der + * Trend-Engine deckt nur PAIRS ab) — siehe Task 10. + */ +export class TrumpEngine { + status: TrumpEngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, cursorTs: null }; + private cycling = false; + + async init(): Promise { + const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID)); + if (row) { + this.status.cursorTs = row.cursorTs.getTime(); + return; + } + const cursor = Math.floor(Date.now() / M15) * M15 - M15; + await db.insert(botState).values({ + id: BOT_STATE_ID, + cash: START_CAPITAL, + startCapital: START_CAPITAL, + cursorTs: new Date(cursor), + }); + this.status.cursorTs = cursor; + } + + async runCycle(): Promise { + if (this.cycling) return; + this.cycling = true; + try { + await pollSignals(); // non-fatal intern; wirft nicht + + const state = await this.loadState(); + const from = state.cursorTs - WARMUP_BARS_15M * M15; + const candles15 = new Map(); + for (const pair of TRUMP_CYCLE_CONFIG.pairs) { + candles15.set(pair, await getCandles(pair, from)); + } + 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 { + 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 { + 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 { + 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)); + }); + } +} +``` + +(Unbenutzte Importe wie `inArray` entfernen, falls der Linter meckert.) + +- [ ] **Step 2: Typecheck + alle Tests** + +Run: `bunx tsc --noEmit && bun test` +Expected: grün. + +- [ ] **Step 3: Commit** + +```bash +git add src/server/live/trump-engine.ts +git commit -m "feat: TrumpEngine als dritte Paper-Engine (bot_state id=3, Poller im Zyklus)" +``` + +--- + +### Task 10: Candle-Nachschub für neue Pairs + Loop-Wiring + API + +**Files:** +- Modify: `src/server/index.ts` +- Modify: `src/server/api/server.ts` + +Die Trend-Engine gap-fetcht nur `PAIRS` — die Trump-Engine braucht 15m-Candles auch für LINK/AAVE/ONDO/ENA/SUI/SEI. Vor dem Wiring prüfen, wie `LiveEngine.runCycle` den Gap-Fetch macht (`src/server/live/engine.ts` lesen) und das Muster für die Trump-Pairs übernehmen — entweder als kleiner Gap-Fetcher in `TrumpEngine.runCycle` (vor `loadState`, per `fetchCandles(pair, '15m', count, …)` + `insertCandles` aus `market/`), oder durch Erweiterung des bestehenden Gap-Fetch auf `ALL_PAIRS`, falls er zentral sitzt. **Entscheidungskriterium:** minimaler Eingriff in die Trend-Engine; im Zweifel eigener Gap-Fetch in der TrumpEngine (Candle-Lücke je Pair seit `cursorTs − Warmup` füllen, wie `backfill.ts` nur vorwärts). + +- [ ] **Step 1: Gap-Fetch für Trump-Pairs implementieren** (Muster aus `live/engine.ts` übernehmen, Methode `private async fillCandleGaps()` in `TrumpEngine`, Aufruf zu Beginn von `runCycle` nach `pollSignals`). + +- [ ] **Step 2: `index.ts` erweitern** + +```ts +import { env } from './config'; +import { LiveEngine } from './live/engine'; +import { GridEngine } from './live/grid-engine'; +import { TrumpEngine } from './live/trump-engine'; +import { createServer } from './api/server'; + +const CYCLE_MS = 5 * 60 * 1000; + +const engine = new LiveEngine(); +const gridEngine = new GridEngine(); +const trumpEngine = new TrumpEngine(); +await engine.init(); +await gridEngine.init(); +await trumpEngine.init(); +createServer(engine, gridEngine, trumpEngine, env.PORT); +console.log(`trade-kuns Live-Paper-Engines (trend + grid + trump) laufen auf :${env.PORT}`); + +// Grid/Trump laufen nach der Trend-Engine — deren Gap-Fetch füllt die Candle-DB für PAIRS. +const cycle = async () => { + await engine.runCycle(); + await gridEngine.runCycle(); + await trumpEngine.runCycle(); +}; +void cycle(); +setInterval(() => void cycle(), CYCLE_MS); +``` + +- [ ] **Step 3: `/api/trump` in `server.ts`** — Signatur `createServer(engine, gridEngine, trumpEngine, port)` erweitern, neuen `case` analog zu `/api/grid` einbauen (bestehendes Muster im File übernehmen): + +```ts +case '/api/trump': { + const [state] = await db.select().from(botState).where(eq(botState.id, 3)); + const positions = await db.select().from(trumpPositions); + const events = await db.select().from(trumpEvents).orderBy(desc(trumpEvents.eventTs)).limit(50); + return Response.json({ + status: trumpEngine.status, + cash: state?.cash ?? null, + startCapital: state?.startCapital ?? null, + positions, + events, + }); +} +``` + +Trades und Equity-Kurve laufen über die bestehenden Endpunkte (`/api/trades?bot=trump`, Equity-Snapshots `bot='trump'`) — prüfen, dass der `bot`-Queryparameter in `/api/trades` generisch ist (bei Grid funktioniert er bereits). + +- [ ] **Step 4: Smoke-Test lokal** + +Run: `bun run start` (kurz laufen lassen), dann `curl -s localhost:8080/api/trump` +Expected: JSON mit `status.lastCycleOk: true`, leere `positions`/`events` (Poller startet ab aktuellem Block; ggf. erste Truth-Events, falls der Feed gerade Coins erwähnt). Server wieder stoppen. +Hinweis: Wenn die curl-Sperre des context-mode-Hooks greift, den Check per `mcp execute` (fetch) machen. + +- [ ] **Step 5: Alle Tests + Commit** + +Run: `bun test` +Expected: grün. + +```bash +git add src/server/index.ts src/server/api/server.ts src/server/live/trump-engine.ts +git commit -m "feat: Trump-Engine in Loop und API verdrahtet (/api/trump, Gap-Fetch neue Pairs)" +``` + +--- + +### Task 11: Dashboard — dritter Tab „Trump" + +**Files:** +- Modify: `public/index.html` + +Bestehende Struktur: `