feat: TrumpEngine als dritte Paper-Engine (bot_state id=3, Poller im Zyklus)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:41:12 +00:00
parent 22c84187b2
commit b617740a40

View File

@@ -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<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) {
const cs = await getCandles(pair, from);
// Pairs ohne Candle-Daten NICHT in die Map: deren Events bleiben pending,
// bis Gap-Fetch/Backfill liefert (Vertrag von processTrumpCycle)
if (cs.length > 0) candles15.set(pair, cs);
else console.warn(`Trump-Engine: keine Candles für ${pair} — Pair in diesem Zyklus übersprungen`);
}
const events = await this.loadOpenEvents();
const result = processTrumpCycle(candles15, events, state, TRUMP_CYCLE_CONFIG);
await this.persist(result);
this.status.lastCycleAt = Date.now();
this.status.lastCycleOk = true;
this.status.lastError = null;
this.status.cursorTs = result.cursorTs;
} catch (err) {
this.status.lastCycleAt = Date.now();
this.status.lastCycleOk = false;
this.status.lastError = err instanceof Error ? err.message : String(err);
console.error('Trump-Zyklus fehlgeschlagen:', err);
} finally {
this.cycling = false;
}
}
private async loadState(): Promise<TrumpLiveState> {
const [row] = await db.select().from(botState).where(eq(botState.id, BOT_STATE_ID));
if (!row) throw new Error('bot_state (trump) fehlt — init() nicht gelaufen?');
const posRows = await db.select().from(trumpPositions);
return {
cash: row.cash,
cursorTs: row.cursorTs.getTime(),
positions: posRows.map((p) => ({
pair: p.pair as Pair,
qty: p.qty,
entryTs: p.entryTs.getTime(),
entryPrice: p.entryPrice,
entryCost: p.entryCost,
riskAmount: p.riskAmount,
exitDueTs: p.exitDueTs.getTime(),
eventId: p.eventId,
})),
};
}
private async loadOpenEvents(): Promise<TrumpEventInput[]> {
const rows = await db
.select()
.from(trumpEvents)
.where(and(isNull(trumpEvents.consumedAt), isNotNull(trumpEvents.instrument)));
return rows.map((r) => ({ id: r.id, instrument: r.instrument as Pair, eventTs: r.eventTs.getTime() }));
}
private async persist(result: TrumpCycleResult): Promise<void> {
await db.transaction(async (tx) => {
await tx.delete(trumpPositions);
for (const p of result.positions) {
await tx.insert(trumpPositions).values({
pair: p.pair,
qty: p.qty,
entryTs: new Date(p.entryTs),
entryPrice: p.entryPrice,
entryCost: p.entryCost,
riskAmount: p.riskAmount,
exitDueTs: new Date(p.exitDueTs),
eventId: p.eventId,
});
}
if (result.closedTrades.length > 0) {
await tx.insert(paperTrades).values(
result.closedTrades.map((t) => ({
bot: 'trump',
pair: t.pair,
side: t.side,
entryTs: new Date(t.entryTs),
entryPrice: t.entryPrice,
exitTs: new Date(t.exitTs),
exitPrice: t.exitPrice,
qty: t.qty,
pnl: t.pnl,
r: t.r,
exitReason: t.exitReason,
})),
);
}
for (const c of result.consumed) {
await tx.update(trumpEvents).set({ consumedAt: new Date(c.consumedAt) }).where(eq(trumpEvents.id, c.eventId));
}
for (const s of result.equitySnapshots) {
const row = { bot: 'trump', ts: new Date(s.ts), equity: s.equity, cash: s.cash };
await tx
.insert(equitySnapshots)
.values(row)
.onConflictDoUpdate({ target: [equitySnapshots.bot, equitySnapshots.ts], set: row });
}
await tx
.update(botState)
.set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() })
.where(eq(botState.id, BOT_STATE_ID));
});
}
}