Files
trade-kuns/docs/plans/2026-06-12-trump-copy-strategy.md
Claude a297d83849 fix: Testdaten-Arithmetik im Trump-Cycle-Test (200 Candles = 50h < 60h Hold)
Plan-Bug: 300 Candles = 75h > holdHours — Position schloss innerhalb der
Testdaten, Tests 1/3/4 erwarteten aber offene Positionen. Implementierung war korrekt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 08:29:35 +00:00

64 KiB
Raw Blame History

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/<id>
  • Ö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 erweiternPAIRS bleibt das Trend-Universum, Pair wird über ALL_PAIRS verbreitert:

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<Pair>([...PAIRS, ...TRUMP_PAIRS])];
  • Step 2: backfill.ts auf ALL_PAIRS umstellenimport { 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
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

/** „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:
  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.

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

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

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

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)

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

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<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;
    }
  }
  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;
}
  • Step 4: Tests passes — Run: bun test src/server/signals/onchain.test.ts → PASS.

  • Step 5: Commit

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

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>');
  });
});

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

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(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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

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
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

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<string, number>,
): { 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<number | null> {
  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<number> {
  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<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 (!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<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 })
      .onConflictDoNothing();
    inserted++;
  }
  return inserted;
}

/** Beide Quellen, Fehler isoliert (eine tote Quelle stoppt die andere nicht). */
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);
  }
}
  • Step 4: Tests passes — Run: bun test src/server/signals/poller.test.ts → PASS.

  • Step 5: Commit

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
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 / 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', 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('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);
  });
});
  • Step 2: Test fails — Run: bun test src/server/live/trump-cycle.test.ts → FAIL.

  • Step 3: Implementieren

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<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));

  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);
  }

  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

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
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);
}
  • Step 2: Typecheck + Commit

Run: bunx tsc --noEmit Expected: keine Fehler.

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
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<void> {
    const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
    if (row) {
      this.status.cursorTs = row.cursorTs.getTime();
      return;
    }
    const cursor = Math.floor(Date.now() / M15) * M15 - M15;
    await db.insert(botState).values({
      id: BOT_STATE_ID,
      cash: START_CAPITAL,
      startCapital: START_CAPITAL,
      cursorTs: new Date(cursor),
    });
    this.status.cursorTs = cursor;
  }

  async runCycle(): Promise<void> {
    if (this.cycling) return;
    this.cycling = true;
    try {
      await pollSignals(); // non-fatal intern; wirft nicht

      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) {
        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<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));
    });
  }
}

(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
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

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):
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.

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: <nav class="tabs"> (Zeile ~60), Sections #tab-trading/#tab-grid, showTab() (Zeile ~352), Daten-Loader mit Promise.all-fetch (Zeile ~403). Exakt das Grid-Muster kopieren:

  • Step 1: Tab-Button + Section ergänzen
<button class="tab" data-tab="trump">Trump</button>
<section id="tab-trump" hidden>
  <div class="cards" id="trump-cards"></div>
  <h2>Offene Positionen</h2>
  <div id="trump-positions"></div>
  <h2>Events</h2>
  <div id="trump-events"></div>
  <h2>Trades</h2>
  <div id="trump-trades"></div>
</section>

(.cards-Markup an die bestehenden Karten im Trading-Tab angleichen — Klassen aus dem File übernehmen.)

  • Step 2: showTab erweitern
document.getElementById('tab-trump').hidden = name !== 'trump';
  • Step 3: Loader erweitern — im Promise.all zusätzlich fetch('/api/trump').then(r => r.json()) und fetch('/api/trades?bot=trump&limit=200').then(r => r.json()); Render-Code analog Grid-Tab:
// ── Trump-Tab ──
document.getElementById('trump-cards').innerHTML = cards([
  ['Equity', fmtUsd(trump.cash + trumpPositionsValue(trump.positions))],
  ['Cash', fmtUsd(trump.cash)],
  ['Offene Positionen', trump.positions.length],
  ['Events gesamt', trump.events.length >= 50 ? '50+' : trump.events.length],
]);
document.getElementById('trump-positions').innerHTML = table(
  ['Pair', 'Entry', 'Preis', 'Qty', 'Exit fällig'],
  trump.positions.map(p => `<tr><td>${p.pair}</td><td>${fmtTs(p.entryTs)}</td><td>${fmtNum(p.entryPrice)}</td><td>${fmtNum(p.qty)}</td><td>${fmtTs(p.exitDueTs)}</td></tr>`),
  'Keine offenen Positionen',
);
document.getElementById('trump-events').innerHTML = table(
  ['Zeit', 'Quelle', 'Coin', 'Instrument', 'Notional', 'Ref'],
  trump.events.map(e => `<tr><td>${fmtTs(e.eventTs)}</td><td>${e.source}</td><td>${e.token}</td><td>${e.instrument ?? '—'}</td><td>${e.notionalUsd ? fmtUsd(e.notionalUsd) : '—'}</td><td><a href="${e.source === 'onchain' ? 'https://etherscan.io/tx/' + e.ref : e.ref}" target="_blank">↗</a></td></tr>`),
  'Noch keine Events',
);

Helfer (cards, table, fmtUsd, fmtTs, fmtNum) existieren bzw. sind den vorhandenen Funktionen im File nachzubilden — vorhandene Namen verwenden, nichts duplizieren. Trades-Tabelle wie im Grid-Tab aus /api/trades?bot=trump rendern.

  • Step 4: Visuell prüfenbun run start, Browser/Playwright auf localhost:8080, Tab „Trump" öffnen: Karten + leere Tabellen sichtbar, keine JS-Konsole-Fehler.

  • Step 5: Commit

git add public/index.html
git commit -m "feat: Dashboard-Tab Trump (Events, Positionen, Trades)"

Task 12: Candle- und Event-Backfill (Phase-1-Daten)

Files:

  • Create: src/server/scripts/trump-backfill.ts

  • Modify: package.json (Script trump:backfill)

  • Step 1: Candle-Backfill für neue Pairs laufen lassen (nutzt Task 1)

Run: bun run backfill Expected: Coverage-Zeilen auch für LINK/AAVE/ONDO/ENA/SUI/SEI_USDT (jüngere Coins haben kürzere Historie — ENA/ONDO ab 2024, das ist okay). Dauert wegen Rate-Limit-Sleeps einige Minuten.

  • Step 2: trump-backfill.ts schreiben — historische Events rekonstruieren:
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' },
  },
});

// ── On-chain ──
const fromBlock = Number(args['from-block']);
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, letzte N Archiv-Seiten à 10 Posts) ──
const pages = Number(args['truth-pages']);
const candidates: { symbol: string; ts: number; url: string }[] = [];
let oldestTs = Infinity;
for (let p = 1; p <= pages; p++) {
  const res = await fetch(`https://trumpstruth.org/?page=${p}`, { signal: AbortSignal.timeout(20_000) });
  if (!res.ok) { console.warn(`Seite ${p}: HTTP ${res.status} — Truth-Scan endet hier`); break; }
  const html = await res.text();
  // Status-Blöcke: Link + Text; Markup-Klassen am 2026-06-12 verifiziert (status, status-info__body)
  const blocks = html.split(/class="status\b/).slice(1);
  for (const block of blocks) {
    const url = block.match(/href="(https:\/\/trumpstruth\.org\/statuses\/\d+)"/)?.[1];
    const timeRaw = block.match(/datetime="([^"]+)"/)?.[1] ?? block.match(/<time[^>]*>([^<]+)<\/time>/)?.[1];
    const body = block.match(/status-info__body[^>]*>([\s\S]*?)<\/div>/)?.[1] ?? '';
    if (!url || !timeRaw) continue;
    const ts = Date.parse(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 });
  }
  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();

Wichtig: Die History-Events sind nach dem Backfill consumedAt = null — damit die Live-Engine sie nicht alle „nachkauft", direkt nach dem Backfill als konsumiert markieren (Teil von Step 4). Die Event-Study (Task 13) liest Events unabhängig von consumedAt.

Falls das Archiv-Markup vom Parser abweicht (Scrape liefert 0 Kandidaten trotz HTTP 200): eine Seite dumpen (console.log(html.slice(0, 3000))), Selektoren anpassen — die Klassen status, status-info__body und /statuses/<id>-Links existieren (verifiziert), nur die genaue Verschachtelung kann abweichen.

  • Step 3: package.json-Script ergänzen
"trump:backfill": "bun run src/server/scripts/trump-backfill.ts",
  • Step 4: Backfill ausführen + History als konsumiert markieren

Run: bun run trump:backfill (dauert: ~1.1k getLogs-Requests + 300 Archiv-Seiten ≈ 1020 min) Expected: On-chain-Events mit plausiblen WLFI-Käufen (ETH/WBTC/LINK/AAVE/ONDO/ENA, Notional ≥ 50k), Truth-Events für BTC etc. Danach (psql oder bun-Skript-Einzeiler):

PGPASSWORD=… psql "$DATABASE_URL" -c "UPDATE trump_events SET consumed_at = now() WHERE consumed_at IS NULL AND event_ts < now() - interval '1 hour';"

Plausibilitätscheck: 3 Stichproben-Tx-Hashes auf etherscan.io öffnen — sind das echte WLFI-Eingänge? Anzahl Events pro Quelle notieren (fließt in die Study-Doku). Wenn 0 on-chain Events gefunden werden: Watchlist zu schmal (Treasury nutzt weitere Wallets) — dann auf Etherscan die eingehenden Token-Tx der Multisig ansehen, dokumentierte Absender-/Schwester-Wallets (z. B. weitere „World Liberty"-Labels) ergänzen und Backfill erneut laufen lassen. Jede Ergänzung mit Label-Quelle als Kommentar in watchlist.ts.

  • Step 5: Commit
git add src/server/scripts/trump-backfill.ts package.json
git commit -m "feat: Trump-Event-Backfill (on-chain History + Truth-Archiv best effort)"

Task 13: Event-Study + Haltedauer-Entscheid

Files:

  • Create: src/server/scripts/trump-event-study.ts

  • Create: docs/event-study-trump-2026-06-12.md (Output)

  • Modify: package.json (Script trump:study)

  • Modify: src/server/live/trump-engine.ts (finales holdHours)

  • Step 1: Study-Skript schreiben

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();
  • Step 2: package.json-Script + ausführen
"trump:study": "bun run src/server/scripts/trump-event-study.ts",

Run: bun run trump:study Expected: Markdown-Tabellen je Quelle × Horizont, Baselines je Instrument. Ergebnis ehrlich lesen: kleine n sind indikativ, nicht signifikant.

  • Step 3: Haltedauer festlegenholdHours in TRUMP_CYCLE_CONFIG (Task 9) auf den Horizont mit dem besten Mean-minus-Baseline setzen (bei Gleichstand oder n < 5: Default 60 behalten). Entscheidung + Begründung als Abschnitt „Entscheid" ans Ende des Study-Docs schreiben.

  • Step 4: Commit

git add src/server/scripts/trump-event-study.ts docs/event-study-trump-2026-06-12.md package.json src/server/live/trump-engine.ts
git commit -m "feat: Event-Study Trump-Copy + Haltedauer-Entscheid"

Task 14: Deploy + Doku

Files:

  • Modify: CLAUDE.md (Engine Nr. 3 dokumentieren)

  • Step 1: CLAUDE.md ergänzen — bei den Engines:

3. **Trump-Copy** (BTC/ETH/SOL/XRP/LINK/AAVE/ONDO/ENA/SUI/SEI_USDT): Event-Copy — on-chain Käufe der WLFI-Watchlist + Truth-Social-Erwähnungen (trumpstruth-RSS), Buy am nächsten 15m-Open, Zeit-Exit nach `holdHours`, 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`, Event-Study: `docs/event-study-trump-2026-06-12.md`.
  • Step 2: Letzter Gesamtlauf

Run: bun test && bunx tsc --noEmit Expected: alles grün.

  • Step 3: Commit + Deploy
git add CLAUDE.md
git commit -m "docs: Trump-Copy-Engine in CLAUDE.md"
redeploy j7xbvfezctcxmeuon6gh6v5z "feat: Trump-Copy-Strategie als dritte Paper-Engine"
  • Step 4: Prod-Verifikation — nach dem Coolify-Build: https://trading.kuns.dev/api/trump liefert lastCycleOk: true; Dashboard-Tab „Trump" lädt. Achtung: Prod-DB braucht den Event-Backfill nicht (Live-Poller startet ab aktuellem Block; consumedAt-Markierung der History entfällt dort, weil keine History-Events existieren). Die Migration läuft automatisch über bun run start.

Offene Risiken (bewusst akzeptiert, Spec §7)

  • Wenig historische Events → Study indikativ; Paper-Lauf ist der eigentliche Test.
  • trumpstruth.org ist ein Dritt-Mirror; fällt er weg, läuft die Engine onchain-only weiter (Fehler-Isolation in pollSignals).
  • Watchlist-Kuration manuell; Erweiterung dokumentiert in Task 12 Step 4.