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:
172
src/server/live/trump-engine.ts
Normal file
172
src/server/live/trump-engine.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user