168 lines
5.7 KiB
TypeScript
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),
|
|
};
|
|
}
|