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

1600 lines
64 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 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<Pair>([...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<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**
```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 = `<?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**
```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(/<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**
```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<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**
```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', 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**
```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<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**
```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<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.
```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<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**
```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: `<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**
```html
<button class="tab" data-tab="trump">Trump</button>
```
```html
<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**
```js
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:
```js
// ── 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üfen**`bun run start`, Browser/Playwright auf `localhost:8080`, Tab „Trump" öffnen: Karten + leere Tabellen sichtbar, keine JS-Konsole-Fehler.
- [ ] **Step 5: Commit**
```bash
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:
```ts
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**
```json
"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):
```bash
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**
```bash
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**
```ts
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**
```json
"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 festlegen**`holdHours` 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**
```bash
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:
```markdown
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**
```bash
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.