import type { Candle, Pair } from '../types'; import { PAIRS } from '../types'; import { aggregate4h, H4 } from '../market/aggregate'; import { computeIndicators, evaluateAt, type StrategyParams } from '../strategy/donchian-trend'; import { updateChandelier } from '../strategy/chandelier'; import { sizePosition, type RiskConfig } from '../engine/sizing'; import { Portfolio, type ClosedTrade, type ExecConfig, type Position } from '../engine/portfolio'; export interface LiveState { cash: number; positions: Position[]; cursorTs: number; // ts der letzten verarbeiteten 15m-Candle } export interface CycleConfig { risk: RiskConfig; exec: ExecConfig; params: StrategyParams; maxPositions: number; } export interface Decision { pair: Pair; barTs: number; // Start der bewerteten 4h-Bar signal: 'long' | null; blockedBy: string | null; close: number; atr: number; adx: number; donchianHigh: number; trendEma: number; } export interface EquitySnapshot { ts: number; // 4h-Bucket equity: number; cash: number; } export interface CycleResult { cash: number; positions: Position[]; cursorTs: number; closedTrades: ClosedTrade[]; decisions: Decision[]; equitySnapshots: EquitySnapshot[]; equity: number; } /** * Verarbeitet alle 15m-Candles mit ts > cursor — identische Semantik wie der * Backtest-Runner: 4h-Bars eines Pairs werden verarbeitet, sobald dessen erste * 15m-Candle eines späteren Buckets eintrifft (Chandelier-Update → Entry-Eval), * danach 15m-Stop-Check. Pure Funktion: gleicher Input → gleiches Ergebnis. * * candles15ByPair muss Warmup-Historie VOR dem Cursor enthalten (≥ trendEmaPeriod * 4h-Bars), sonst blockiert insufficient_data. */ export function processCycle( candles15ByPair: Map, state: LiveState, cfg: CycleConfig, ): CycleResult { const portfolio = new Portfolio(state.cash, cfg.exec); for (const pos of state.positions) portfolio.positions.set(pos.pair, { ...pos }); const decisions: Decision[] = []; const equitySnapshots: EquitySnapshot[] = []; const lastClose = new Map(); const cursorBucket = Math.floor(state.cursorTs / H4) * H4; const contexts = PAIRS.filter((p) => candles15ByPair.has(p)).map((pair) => { const c15 = candles15ByPair.get(pair)!; const c4h = aggregate4h(c15); // 4h-Bars vor dem Cursor-Bucket gelten als in früheren Zyklen verarbeitet let next4h = 0; while (next4h < c4h.length && c4h[next4h].ts < cursorBucket) next4h++; // lastClose mit der letzten Candle ≤ Cursor seeden (für Equity offener Positionen) for (const c of c15) { if (c.ts > state.cursorTs) break; lastClose.set(pair, c.close); } return { pair, c4h, ind: computeIndicators(c4h, cfg.params), next4h }; }); const byPair = new Map(contexts.map((c) => [c.pair, c])); const timeline: { ts: number; pair: Pair; candle: Candle }[] = []; for (const ctx of contexts) { for (const candle of candles15ByPair.get(ctx.pair)!) { if (candle.ts > state.cursorTs) timeline.push({ ts: candle.ts, pair: ctx.pair, candle }); } } timeline.sort((a, b) => a.ts - b.ts || PAIRS.indexOf(a.pair as (typeof PAIRS)[number]) - PAIRS.indexOf(b.pair as (typeof PAIRS)[number])); let cursorTs = state.cursorTs; let lastEquityBucket = -1; for (const { ts, pair, candle } of timeline) { const ctx = byPair.get(pair)!; const bucket = Math.floor(ts / H4) * H4; // 1) Neu abgeschlossene 4h-Bars dieses Pairs while (ctx.next4h < ctx.c4h.length && ctx.c4h[ctx.next4h].ts < bucket) { const i = ctx.next4h++; const bar = ctx.c4h[i]; const pos = portfolio.positions.get(pair); if (pos) { const next = updateChandelier( { highestHigh: pos.trailExtreme, stop: pos.stop }, bar.high, ctx.ind.atr[i], cfg.params.atrMultiplier, ); pos.trailExtreme = next.highestHigh; pos.stop = next.stop; } const ev = evaluateAt(ctx.c4h, ctx.ind, i, cfg.params, false); const signal = ev.signal === 'long' ? 'long' : null; let blockedBy: string | null = ev.blockedBy; if (portfolio.positions.has(pair)) { blockedBy = 'position_open'; } else if (signal) { if (portfolio.positions.size >= cfg.maxPositions) { blockedBy = 'max_positions'; } else { const initialStop = ev.close - cfg.params.atrMultiplier * ev.atr; const equity = portfolio.equity(lastClose); const s = sizePosition(equity, portfolio.cash, ev.close, initialStop, cfg.risk, 'long'); blockedBy = s.blockedBy; if (!s.blockedBy) portfolio.open(pair, bar.ts + H4, ev.close, initialStop, s.qty, s.riskAmount, 'long'); } } decisions.push({ pair, barTs: bar.ts, signal, blockedBy, close: ev.close, atr: ev.atr, adx: ev.adx, donchianHigh: ev.donchianHigh, trendEma: ev.trendEma, }); } // 2) Stop-Check auf der 15m-Candle (auch auf der Entry-Candle, wie im Runner) const pos = portfolio.positions.get(pair); if (pos && candle.low <= pos.stop) { const exitPrice = candle.open < pos.stop ? candle.open : pos.stop; // Gap → schlechterer Fill portfolio.close(pair, ts, exitPrice, 'trailing_stop'); } lastClose.set(pair, candle.close); cursorTs = Math.max(cursorTs, ts); // 3) Equity-Punkt einmal pro 4h-Bucket if (bucket !== lastEquityBucket) { lastEquityBucket = bucket; equitySnapshots.push({ ts: bucket, equity: portfolio.equity(lastClose), cash: portfolio.cash }); } } return { cash: portfolio.cash, positions: [...portfolio.positions.values()], cursorTs, closedTrades: portfolio.trades, decisions, equitySnapshots, equity: portfolio.equity(lastClose), }; }