Files
trade-kuns/src/server/live/process-cycle.ts

168 lines
5.7 KiB
TypeScript

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<Pair, Candle[]>,
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<Pair, number>();
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<Pair, (typeof contexts)[number]>(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),
};
}