test: Geld-Pfade im Trump-Cycle (minNotional, Cash-Erschöpfung, Re-Entry) + deutsche Kommentare
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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[] =>
|
||||
|
||||
@@ -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<Pair, Candle[]>,
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
// 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)!) {
|
||||
|
||||
Reference in New Issue
Block a user