From 204c0541a7af84cdfface86b4b673f26474a8338 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 08:38:10 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Geld-Pfade=20im=20Trump-Cycle=20(minNot?= =?UTF-8?q?ional,=20Cash-Ersch=C3=B6pfung,=20Re-Entry)=20+=20deutsche=20Ko?= =?UTF-8?q?mmentare?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- src/server/live/trump-cycle.test.ts | 71 +++++++++++++++++++++++++++++ src/server/live/trump-cycle.ts | 10 ++-- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/server/live/trump-cycle.test.ts b/src/server/live/trump-cycle.test.ts index aed40a1..578507d 100644 --- a/src/server/live/trump-cycle.test.ts +++ b/src/server/live/trump-cycle.test.ts @@ -72,6 +72,77 @@ describe('processTrumpCycle', () => { expect(r.consumed).toHaveLength(2); }); + test('minNotional-Ablehnung: Budget < minNotionalUsdt → kein Trade, Event konsumiert', () => { + // equityFraction 0.2 × cash 10000 = 2000 < minNotionalUsdt 5000 + const cfg = { ...CFG, minNotionalUsdt: 5000 }; + const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 200)]]); + const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 }]; + const r = processTrumpCycle(candles, events, fresh(), cfg); + expect(r.positions).toHaveLength(0); + expect(r.closedTrades).toHaveLength(0); + expect(r.consumed).toEqual([{ eventId: 1, consumedAt: T0 + M15 }]); + expect(r.cash).toBe(10_000); + }); + + test('Cash-Erschöpfung: budget > minNotional aber cash < cost → kein neuer Trade, Event konsumiert', () => { + // Equity ≈ 100 + 100×100 = 10100, budget ≈ 2020 > minNotional 10 + // aber cash 100 < cost (2020) → Entry wird abgelehnt + const state: TrumpLiveState = { + cash: 100, + positions: [{ + pair: 'ETH_USDT' as Pair, + qty: 100, + entryTs: T0 - 10 * M15, + entryPrice: 99, + entryCost: 9900, + riskAmount: 9900, + exitDueTs: T0 + 1000 * M15, + eventId: 99, + }], + cursorTs: T0, + }; + const candles = new Map([ + ['BTC_USDT' as Pair, flat('BTC_USDT', 200, 100)], + ['ETH_USDT' as Pair, flat('ETH_USDT', 200, 100)], + ]); + const events = [{ id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 1 }]; + const r = processTrumpCycle(candles, events, state, CFG); + // ETH-Position bleibt, kein neuer BTC-Trade + expect(r.positions).toHaveLength(1); + expect(r.positions[0].pair).toBe('ETH_USDT'); + // Event konsumiert + expect(r.consumed).toHaveLength(1); + expect(r.consumed[0].eventId).toBe(1); + // Cash unverändert + expect(r.cash).toBe(100); + }); + + test('Re-Entry nach Exit im selben Pair am selben Bar (Exit vor Entry)', () => { + // holdHours 1 → holdMs = 4×M15 + // Cursor T0, Event id=1 eventTs=T0: Entry an Candle T0+M15, exitDueTs=T0+5*M15 + // Exit-Bedingung: ts+M15 >= T0+5*M15 → ts >= T0+4*M15 → Exit-Candle ts=T0+4*M15 + // Event id=2 eventTs=T0+4*M15: an selber Candle → Exit läuft zuerst, dann Entry + // Candles auf 6 begrenzen (T0..T0+5*M15): zweite Position hat exitDueTs=T0+8*M15 + // → kein zweiter Exit innerhalb der Candle-Fensters → r.positions enthält noch die neue Position + const cfg = { ...CFG, holdHours: 1 }; + const candles = new Map([['BTC_USDT' as Pair, flat('BTC_USDT', 6)]]); + const events = [ + { id: 1, instrument: 'BTC_USDT' as Pair, eventTs: T0 }, + { id: 2, instrument: 'BTC_USDT' as Pair, eventTs: T0 + 4 * M15 }, + ]; + const r = processTrumpCycle(candles, events, fresh(), cfg); + // Erster Trade geschlossen: Exit an ts=T0+4*M15 + expect(r.closedTrades).toHaveLength(1); + expect(r.closedTrades[0].exitTs).toBe(T0 + 4 * M15); + expect(r.closedTrades[0].exitReason).toBe('trump_hold'); + // Re-Entry: neue offene Position mit entryTs=T0+4*M15 und eventId=2 + expect(r.positions).toHaveLength(1); + expect(r.positions[0].entryTs).toBe(T0 + 4 * M15); + expect(r.positions[0].eventId).toBe(2); + // Beide Events konsumiert + expect(r.consumed.map((c) => c.eventId).sort()).toEqual([1, 2]); + }); + test('Cursor-Idempotenz: gesplittete Zyklen ≡ ein Zyklus (Parität)', () => { const n = 300; const wave = (pair: Pair, base: number): Candle[] => diff --git a/src/server/live/trump-cycle.ts b/src/server/live/trump-cycle.ts index 92b35d4..c3ab24d 100644 --- a/src/server/live/trump-cycle.ts +++ b/src/server/live/trump-cycle.ts @@ -53,6 +53,10 @@ export interface TrumpCycleResult { * 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). + * Events für Pairs, die in candles15ByPair fehlen oder nach dem Cursor kein Candle + * haben (fehlender Key oder leeres Array), werden nicht besucht und bleiben unkonsumiert — + * der Aufrufer muss sicherstellen, dass solche Events später Candles bekommen + * (Gap-Fetch/Backfill) oder vom Aufrufer selbst verworfen werden. */ export function processTrumpCycle( candles15ByPair: Map, @@ -71,14 +75,14 @@ export function processTrumpCycle( const pairs = cfg.pairs.filter((p) => candles15ByPair.has(p)); - // Group and sort events by pair + // Events nach Pair gruppieren und sortieren const pending = new Map(); 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); } - // Seed lastClose from candles up to and including cursorTs + // lastClose mit Candles bis einschließlich cursorTs vorbelegen for (const pair of pairs) { for (const c of candles15ByPair.get(pair)!) { if (c.ts > state.cursorTs) break; @@ -92,7 +96,7 @@ export function processTrumpCycle( return eq; }; - // Build timeline of all candles strictly after cursorTs, sorted by ts then by pairs order + // Timeline aller Candles strikt nach cursorTs, sortiert nach ts dann nach pairs-Reihenfolge const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; for (const pair of pairs) { for (const candle of candles15ByPair.get(pair)!) {