From b617740a40238c8eca4a23fc69b37681def0defa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:41:12 +0000 Subject: [PATCH] feat: TrumpEngine als dritte Paper-Engine (bot_state id=3, Poller im Zyklus) Co-Authored-By: Claude Fable 5 --- src/server/live/trump-engine.ts | 172 ++++++++++++++++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/server/live/trump-engine.ts diff --git a/src/server/live/trump-engine.ts b/src/server/live/trump-engine.ts new file mode 100644 index 0000000..7f734d1 --- /dev/null +++ b/src/server/live/trump-engine.ts @@ -0,0 +1,172 @@ +import { and, eq, isNotNull, isNull } 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; + * Candle-Gap-Fetch für die Nicht-Trend-Pairs kommt in Task 10. + */ +export class TrumpEngine { + status: TrumpEngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, cursorTs: null }; + private cycling = false; + + async init(): Promise { + const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID)); + if (row) { + this.status.cursorTs = row.cursorTs.getTime(); + return; + } + const cursor = Math.floor(Date.now() / M15) * M15 - M15; + await db.insert(botState).values({ + id: BOT_STATE_ID, + cash: START_CAPITAL, + startCapital: START_CAPITAL, + cursorTs: new Date(cursor), + }); + this.status.cursorTs = cursor; + } + + async runCycle(): Promise { + if (this.cycling) return; + this.cycling = true; + try { + await pollSignals(); // non-fatal intern; wirft nicht + + const state = await this.loadState(); + const from = state.cursorTs - WARMUP_BARS_15M * M15; + const candles15 = new Map(); + for (const pair of TRUMP_CYCLE_CONFIG.pairs) { + const cs = await getCandles(pair, from); + // Pairs ohne Candle-Daten NICHT in die Map: deren Events bleiben pending, + // bis Gap-Fetch/Backfill liefert (Vertrag von processTrumpCycle) + if (cs.length > 0) candles15.set(pair, cs); + else console.warn(`Trump-Engine: keine Candles für ${pair} — Pair in diesem Zyklus übersprungen`); + } + const events = await this.loadOpenEvents(); + const result = processTrumpCycle(candles15, events, state, TRUMP_CYCLE_CONFIG); + await this.persist(result); + this.status.lastCycleAt = Date.now(); + this.status.lastCycleOk = true; + this.status.lastError = null; + this.status.cursorTs = result.cursorTs; + } catch (err) { + this.status.lastCycleAt = Date.now(); + this.status.lastCycleOk = false; + this.status.lastError = err instanceof Error ? err.message : String(err); + console.error('Trump-Zyklus fehlgeschlagen:', err); + } finally { + this.cycling = false; + } + } + + private async loadState(): Promise { + const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID)); + if (!row) throw new Error('bot_state (trump) fehlt — init() nicht gelaufen?'); + const posRows = await db.select().from(trumpPositions); + return { + cash: row.cash, + cursorTs: row.cursorTs.getTime(), + positions: posRows.map((p) => ({ + pair: p.pair as Pair, + qty: p.qty, + entryTs: p.entryTs.getTime(), + entryPrice: p.entryPrice, + entryCost: p.entryCost, + riskAmount: p.riskAmount, + exitDueTs: p.exitDueTs.getTime(), + eventId: p.eventId, + })), + }; + } + + private async loadOpenEvents(): Promise { + const rows = await db + .select() + .from(trumpEvents) + .where(and(isNull(trumpEvents.consumedAt), isNotNull(trumpEvents.instrument))); + return rows.map((r) => ({ id: r.id, instrument: r.instrument as Pair, eventTs: r.eventTs.getTime() })); + } + + private async persist(result: TrumpCycleResult): Promise { + await db.transaction(async (tx) => { + await tx.delete(trumpPositions); + for (const p of result.positions) { + await tx.insert(trumpPositions).values({ + pair: p.pair, + qty: p.qty, + entryTs: new Date(p.entryTs), + entryPrice: p.entryPrice, + entryCost: p.entryCost, + riskAmount: p.riskAmount, + exitDueTs: new Date(p.exitDueTs), + eventId: p.eventId, + }); + } + if (result.closedTrades.length > 0) { + await tx.insert(paperTrades).values( + result.closedTrades.map((t) => ({ + bot: 'trump', + pair: t.pair, + side: t.side, + entryTs: new Date(t.entryTs), + entryPrice: t.entryPrice, + exitTs: new Date(t.exitTs), + exitPrice: t.exitPrice, + qty: t.qty, + pnl: t.pnl, + r: t.r, + exitReason: t.exitReason, + })), + ); + } + for (const c of result.consumed) { + await tx.update(trumpEvents).set({ consumedAt: new Date(c.consumedAt) }).where(eq(trumpEvents.id, c.eventId)); + } + for (const s of result.equitySnapshots) { + const row = { bot: 'trump', ts: new Date(s.ts), equity: s.equity, cash: s.cash }; + await tx + .insert(equitySnapshots) + .values(row) + .onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row }); + } + await tx + .update(botState) + .set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() }) + .where(eq(botState.id, BOT_STATE_ID)); + }); + } +}