From 29846e82a71dda6b720723632f521aabab05582d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 06:11:44 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20Live-Paper-Engine=20=E2=80=94=205-min-L?= =?UTF-8?q?oop,=20API,=20Dashboard,=20Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit processCycle spiegelt Runner-Semantik exakt (Paritätstest gegen runBacktest), Restart-Recovery über Cursor, DecisionLog mit Outcome-Backfill, Bun.serve-API + statisches Dashboard, Deploy-Ziel trading.kuns.dev. Co-Authored-By: Claude Fable 5 --- .dockerignore | 5 + Dockerfile | 13 ++ package.json | 1 + public/index.html | 166 +++++++++++++++++++ src/server/api/server.ts | 163 +++++++++++++++++++ src/server/config.ts | 1 + src/server/index.ts | 13 ++ src/server/live/engine.ts | 226 ++++++++++++++++++++++++++ src/server/live/process-cycle.test.ts | 103 ++++++++++++ src/server/live/process-cycle.ts | 167 +++++++++++++++++++ 10 files changed, 858 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 public/index.html create mode 100644 src/server/api/server.ts create mode 100644 src/server/index.ts create mode 100644 src/server/live/engine.ts create mode 100644 src/server/live/process-cycle.test.ts create mode 100644 src/server/live/process-cycle.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6da5cbc --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +node_modules +docs +.env +*.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0b71fc0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM oven/bun:1.3 + +WORKDIR /app +COPY package.json bun.lock ./ +RUN bun install --frozen-lockfile --production + +COPY . . + +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s \ + CMD bun -e "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" + +CMD ["bun", "run", "start"] diff --git a/package.json b/package.json index e3c045a..f2b9d9a 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "type": "module", "scripts": { + "start": "bun run db:migrate && bun run src/server/index.ts", "test": "bun test", "backfill": "bun run src/server/scripts/backfill.ts", "walkforward": "bun run src/server/scripts/walkforward.ts", diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..63d847e --- /dev/null +++ b/public/index.html @@ -0,0 +1,166 @@ + + + + + +trade-kuns — Paper Trading + + + +
+

trade-kuns Donchian-Trendfolge · Paper · BTC ETH SOL XRP

+
lade…
+
+ +
+ +

Equity-Kurve (4h)

+ +

Offene Positionen

+ +

Abgeschlossene Trades

+ +

Letzte Entscheidungen (4h-Bars)

+ + + + diff --git a/src/server/api/server.ts b/src/server/api/server.ts new file mode 100644 index 0000000..ff9b454 --- /dev/null +++ b/src/server/api/server.ts @@ -0,0 +1,163 @@ +import { and, desc, eq, gte } from 'drizzle-orm'; +import { db } from '../db/client'; +import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema'; +import { aggregate4h } from '../market/aggregate'; +import { computeMetrics, type EquityPoint } from '../backtest/metrics'; +import type { ClosedTrade } from '../engine/portfolio'; +import type { Pair } from '../types'; +import { PAIRS } from '../types'; +import type { LiveEngine } from '../live/engine'; + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } }); +} + +function clampLimit(url: URL, def: number, max: number): number { + const n = Number(url.searchParams.get('limit') ?? def); + return Number.isFinite(n) ? Math.min(Math.max(1, Math.floor(n)), max) : def; +} + +async function latestCloses(): Promise> { + const map = new Map(); + for (const pair of PAIRS) { + const [row] = await db + .select({ close: candles.close }) + .from(candles) + .where(eq(candles.pair, pair)) + .orderBy(desc(candles.ts)) + .limit(1); + if (row) map.set(pair, row.close); + } + return map; +} + +async function getPortfolio(engine: LiveEngine) { + const [state] = await db.select().from(botState).where(eq(botState.id, 1)); + const posRows = await db.select().from(positions); + const closes = await latestCloses(); + let equity = state?.cash ?? 0; + const pos = posRows.map((p) => { + const last = closes.get(p.pair as Pair) ?? p.entryPrice; + const value = p.qty * last; + equity += value; + return { + pair: p.pair, + side: p.side, + qty: p.qty, + entryTs: p.entryTs.getTime(), + entryPrice: p.entryPrice, + stop: p.stop, + initialStop: p.initialStop, + lastPrice: last, + value, + unrealizedPnl: value - p.entryCost, + riskAmount: p.riskAmount, + }; + }); + return { + equity, + cash: state?.cash ?? 0, + startCapital: state?.startCapital ?? 0, + cursorTs: state?.cursorTs.getTime() ?? null, + positions: pos, + engine: engine.status, + }; +} + +async function getStats() { + const [state] = await db.select().from(botState).where(eq(botState.id, 1)); + const tradeRows = await db.select().from(paperTrades); + const trades: ClosedTrade[] = tradeRows.map((t) => ({ + pair: t.pair as Pair, + entryTs: t.entryTs.getTime(), + entryPrice: t.entryPrice, + exitTs: t.exitTs.getTime(), + exitPrice: t.exitPrice, + qty: t.qty, + pnl: t.pnl, + r: t.r, + exitReason: t.exitReason as ClosedTrade['exitReason'], + side: t.side as 'long' | 'short', + })); + const curveRows = await db.select().from(equitySnapshots).orderBy(equitySnapshots.ts); + const curve: EquityPoint[] = curveRows.map((r) => ({ ts: r.ts.getTime(), equity: r.equity })); + const start = state?.startCapital ?? 1000; + const metrics = computeMetrics(trades, curve, start); + + // Buy&Hold-BTC über denselben Zeitraum als Benchmark + let btcBuyHoldPct: number | null = null; + if (curve.length > 1) { + const [first] = await db + .select({ close: candles.close }) + .from(candles) + .where(and(eq(candles.pair, 'BTC_USDT'), gte(candles.ts, new Date(curve[0].ts)))) + .orderBy(candles.ts) + .limit(1); + const [last] = await db + .select({ close: candles.close }) + .from(candles) + .where(eq(candles.pair, 'BTC_USDT')) + .orderBy(desc(candles.ts)) + .limit(1); + if (first && last) btcBuyHoldPct = (last.close / first.close - 1) * 100; + } + return { ...metrics, startCapital: start, equityCurve: curve, btcBuyHoldPct }; +} + +export function createServer(engine: LiveEngine, port: number) { + const indexHtml = Bun.file(new URL('../../../public/index.html', import.meta.url)); + + return Bun.serve({ + port, + hostname: '0.0.0.0', + async fetch(req) { + const url = new URL(req.url); + try { + switch (url.pathname) { + case '/': + return new Response(indexHtml, { headers: { 'content-type': 'text/html; charset=utf-8' } }); + case '/health': { + const ok = engine.status.lastCycleOk; + return json({ ok, lastCycleAt: engine.status.lastCycleAt, error: engine.status.lastError }, ok ? 200 : 503); + } + case '/api/portfolio': + return json(await getPortfolio(engine)); + case '/api/trades': { + const limit = clampLimit(url, 100, 500); + const rows = await db.select().from(paperTrades).orderBy(desc(paperTrades.exitTs)).limit(limit); + return json(rows); + } + case '/api/decisions': { + const limit = clampLimit(url, 50, 500); + const pair = url.searchParams.get('pair'); + const where = pair ? eq(decisionLogs.pair, pair) : undefined; + const rows = await db.select().from(decisionLogs).where(where).orderBy(desc(decisionLogs.barTs)).limit(limit); + return json(rows); + } + case '/api/stats': + return json(await getStats()); + case '/api/candles': { + const pair = url.searchParams.get('pair') ?? 'BTC_USDT'; + if (!(PAIRS as readonly string[]).includes(pair)) return json({ error: 'unbekanntes Pair' }, 400); + const tf = url.searchParams.get('tf') ?? '4h'; + const limit = clampLimit(url, 500, 2000); + const raw = tf === '4h' ? limit * 16 : limit; + const rows = await db + .select() + .from(candles) + .where(eq(candles.pair, pair)) + .orderBy(desc(candles.ts)) + .limit(raw); + const c15 = rows.reverse().map((r) => ({ ts: r.ts.getTime(), open: r.open, high: r.high, low: r.low, close: r.close, volume: r.volume })); + return json(tf === '4h' ? aggregate4h(c15).slice(-limit) : c15); + } + default: + return json({ error: 'not found' }, 404); + } + } catch (err) { + console.error('API-Fehler:', url.pathname, err); + return json({ error: 'internal error' }, 500); + } + }, + }); +} diff --git a/src/server/config.ts b/src/server/config.ts index f009788..5919ea7 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -2,6 +2,7 @@ import { z } from 'zod'; const Env = z.object({ DATABASE_URL: z.string().url(), + PORT: z.coerce.number().default(8080), }); export const env = Env.parse(process.env); diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..ec7af79 --- /dev/null +++ b/src/server/index.ts @@ -0,0 +1,13 @@ +import { env } from './config'; +import { LiveEngine } from './live/engine'; +import { createServer } from './api/server'; + +const CYCLE_MS = 5 * 60 * 1000; + +const engine = new LiveEngine(); +await engine.init(); +createServer(engine, env.PORT); +console.log(`trade-kuns Live-Paper-Engine läuft auf :${env.PORT}`); + +void engine.runCycle(); +setInterval(() => void engine.runCycle(), CYCLE_MS); diff --git a/src/server/live/engine.ts b/src/server/live/engine.ts new file mode 100644 index 0000000..6fd2176 --- /dev/null +++ b/src/server/live/engine.ts @@ -0,0 +1,226 @@ +import { and, eq, isNull, lte, sql as dsql } from 'drizzle-orm'; +import { db } from '../db/client'; +import { botState, candles, decisionLogs, equitySnapshots, paperTrades, positions } from '../db/schema'; +import { fetchCandles } from '../market/cryptocom'; +import { getCandles, insertCandles } from '../market/candle-store'; +import { H4 } from '../market/aggregate'; +import { DEFAULT_PARAMS } from '../strategy/donchian-trend'; +import { DEFAULT_RISK } from '../engine/sizing'; +import { DEFAULT_EXEC, type Position } from '../engine/portfolio'; +import type { Candle, Pair } from '../types'; +import { PAIRS } from '../types'; +import { processCycle, type CycleConfig, type CycleResult, type LiveState } from './process-cycle'; + +const M15 = 15 * 60 * 1000; +const START_CAPITAL = 1000; +/** Warmup: 600 4h-Bars (~100 Tage) — EMA-200 braucht 200 + Konvergenz-Puffer. */ +const WARMUP_4H_BARS = 600; + +export const CYCLE_CONFIG: CycleConfig = { + risk: DEFAULT_RISK, + exec: DEFAULT_EXEC, + params: DEFAULT_PARAMS, + maxPositions: 4, +}; + +export interface EngineStatus { + lastCycleAt: number | null; + lastCycleOk: boolean; + lastError: string | null; + pairErrors: Partial>; + cursorTs: number | null; +} + +export class LiveEngine { + status: EngineStatus = { lastCycleAt: null, lastCycleOk: true, lastError: null, pairErrors: {}, cursorTs: null }; + private cycling = false; + + /** Legt bot_state beim allerersten Start an: 1000 USDT, Cursor = jüngste abgeschlossene 15m-Candle. */ + async init(): Promise { + const [row] = await db.select().from(botState).where(eq(botState.id, 1)); + if (row) { + this.status.cursorTs = row.cursorTs.getTime(); + return; + } + const now = Date.now(); + const cursor = Math.floor(now / M15) * M15 - M15; // letzte sicher abgeschlossene 15m-Candle + await db.insert(botState).values({ + id: 1, + cash: START_CAPITAL, + startCapital: START_CAPITAL, + cursorTs: new Date(cursor), + }); + this.status.cursorTs = cursor; + } + + async runCycle(): Promise { + if (this.cycling) return; + this.cycling = true; + try { + const state = await this.loadState(); + await this.fetchGaps(state.cursorTs); + const candles15 = await this.loadCandles(state.cursorTs); + const result = processCycle(candles15, state, CYCLE_CONFIG); + await this.persist(state, result); + await this.backfillOutcomes(); + this.status.lastCycleAt = Date.now(); + this.status.lastCycleOk = true; + this.status.lastError = null; + this.status.cursorTs = result.cursorTs; + } catch (err) { + this.status.lastCycleAt = Date.now(); + this.status.lastCycleOk = false; + this.status.lastError = err instanceof Error ? err.message : String(err); + console.error('Zyklus fehlgeschlagen:', err); + } finally { + this.cycling = false; + } + } + + private async loadState(): Promise { + const [row] = await db.select().from(botState).where(eq(botState.id, 1)); + if (!row) throw new Error('bot_state fehlt — init() nicht gelaufen?'); + const posRows = await db.select().from(positions); + const pos: Position[] = posRows.map((p) => ({ + pair: p.pair as Pair, + qty: p.qty, + entryTs: p.entryTs.getTime(), + entryPrice: p.entryPrice, + entryCost: p.entryCost, + initialStop: p.initialStop, + stop: p.stop, + trailExtreme: p.trailExtreme, + riskAmount: p.riskAmount, + side: p.side as 'long' | 'short', + })); + return { cash: row.cash, positions: pos, cursorTs: row.cursorTs.getTime() }; + } + + /** Holt fehlende 15m-Candles seit Cursor je Pair; Pair-Fehler überspringen den Rest nicht. */ + private async fetchGaps(cursorTs: number): Promise { + const now = Date.now(); + this.status.pairErrors = {}; + for (const pair of PAIRS) { + try { + const fresh: Candle[] = []; + let endTs: number | undefined; + // rückwärts paginieren bis der Cursor abgedeckt ist (Normalfall: 1 Request) + for (let i = 0; i < 40; i++) { + const batch = await fetchCandles(pair, '15m', 300, endTs); + if (batch.length === 0) break; + fresh.push(...batch); + const oldest = Math.min(...batch.map((c) => c.ts)); + if (oldest <= cursorTs) break; + endTs = oldest - 1; + } + const closed = fresh.filter((c) => c.ts + M15 <= now && c.ts > cursorTs); + if (closed.length > 0) await insertCandles(pair, closed); + } catch (err) { + this.status.pairErrors[pair] = err instanceof Error ? err.message : String(err); + } + } + } + + private async loadCandles(cursorTs: number): Promise> { + const from = Math.floor(cursorTs / H4) * H4 - WARMUP_4H_BARS * H4; + const map = new Map(); + for (const pair of PAIRS) { + map.set(pair, await getCandles(pair, from)); + } + return map; + } + + private async persist(prev: LiveState, result: CycleResult): Promise { + await db.transaction(async (tx) => { + const keep = new Set(result.positions.map((p) => p.pair)); + for (const p of prev.positions) { + if (!keep.has(p.pair)) await tx.delete(positions).where(eq(positions.pair, p.pair)); + } + for (const p of result.positions) { + const row = { + pair: p.pair, + side: p.side, + qty: p.qty, + entryTs: new Date(p.entryTs), + entryPrice: p.entryPrice, + entryCost: p.entryCost, + initialStop: p.initialStop, + stop: p.stop, + trailExtreme: p.trailExtreme, + riskAmount: p.riskAmount, + }; + await tx.insert(positions).values(row).onConflictDoUpdate({ target: positions.pair, set: row }); + } + if (result.closedTrades.length > 0) { + await tx.insert(paperTrades).values( + result.closedTrades.map((t) => ({ + pair: t.pair, + side: t.side, + entryTs: new Date(t.entryTs), + entryPrice: t.entryPrice, + exitTs: new Date(t.exitTs), + exitPrice: t.exitPrice, + qty: t.qty, + pnl: t.pnl, + r: t.r, + exitReason: t.exitReason, + })), + ); + } + if (result.decisions.length > 0) { + await tx + .insert(decisionLogs) + .values( + result.decisions.map((d) => ({ + pair: d.pair, + barTs: new Date(d.barTs), + signal: d.signal, + blockedBy: d.blockedBy, + close: d.close, + atr: Number.isNaN(d.atr) ? null : d.atr, + adx: Number.isNaN(d.adx) ? null : d.adx, + donchianHigh: Number.isNaN(d.donchianHigh) ? null : d.donchianHigh, + trendEma: Number.isNaN(d.trendEma) ? null : d.trendEma, + })), + ) + .onConflictDoNothing(); + } + for (const s of result.equitySnapshots) { + const row = { ts: new Date(s.ts), equity: s.equity, cash: s.cash }; + await tx.insert(equitySnapshots).values(row).onConflictDoUpdate({ target: equitySnapshots.ts, set: row }); + } + await tx + .update(botState) + .set({ cash: result.cash, cursorTs: new Date(result.cursorTs), updatedAt: new Date() }) + .where(eq(botState.id, 1)); + }); + } + + /** Füllt price_after_4h/24h/72h in decision_logs, sobald die Candles vorliegen. */ + private async backfillOutcomes(): Promise { + const now = Date.now(); + const horizons = [ + { col: decisionLogs.priceAfter4h, key: 'priceAfter4h' as const, ms: 4 * 60 * 60 * 1000 }, + { col: decisionLogs.priceAfter24h, key: 'priceAfter24h' as const, ms: 24 * 60 * 60 * 1000 }, + { col: decisionLogs.priceAfter72h, key: 'priceAfter72h' as const, ms: 72 * 60 * 60 * 1000 }, + ]; + for (const h of horizons) { + const due = await db + .select({ id: decisionLogs.id, pair: decisionLogs.pair, barTs: decisionLogs.barTs }) + .from(decisionLogs) + .where(and(isNull(h.col), lte(decisionLogs.barTs, new Date(now - H4 - h.ms)))) + .limit(200); + for (const d of due) { + // Entscheidung fällt am Bar-Close (barTs + 4h); Ziel = 15m-Close bei +Horizont + const target = d.barTs.getTime() + H4 + h.ms - M15; + const [c] = await db + .select({ close: candles.close }) + .from(candles) + .where(and(eq(candles.pair, d.pair), lte(candles.ts, new Date(target)))) + .orderBy(dsql`${candles.ts} desc`) + .limit(1); + if (c) await db.update(decisionLogs).set({ [h.key]: c.close }).where(eq(decisionLogs.id, d.id)); + } + } + } +} diff --git a/src/server/live/process-cycle.test.ts b/src/server/live/process-cycle.test.ts new file mode 100644 index 0000000..a9f38a1 --- /dev/null +++ b/src/server/live/process-cycle.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test'; +import type { Candle, Pair } from '../types'; +import { DEFAULT_PARAMS } from '../strategy/donchian-trend'; +import { DEFAULT_RISK } from '../engine/sizing'; +import { DEFAULT_EXEC } from '../engine/portfolio'; +import { runBacktest } from '../backtest/runner'; +import { processCycle, type CycleConfig, type LiveState } from './process-cycle'; + +const M15 = 15 * 60 * 1000; +const CFG: CycleConfig = { risk: DEFAULT_RISK, exec: DEFAULT_EXEC, params: DEFAULT_PARAMS, maxPositions: 4 }; + +/** + * Synthetische 15m-Serie: langer Aufwärtstrend (löst Donchian-Breakout + EMA + ADX aus), + * dann scharfer Absturz (löst Trailing-Stop aus), dann erneuter Anstieg. + */ +function syntheticCandles(): Candle[] { + const out: Candle[] = []; + const t0 = Date.UTC(2025, 0, 1); + let price = 100; + const bars = 16 * 320; // 320 4h-Bars + for (let k = 0; k < bars; k++) { + const phase = Math.floor(k / 16); + let drift: number; + if (phase < 240) drift = 0.05; // Warmup + Trend aufwärts + else if (phase < 260) drift = -1.5; // Crash → Stop + else drift = 0.08; // Erholung + const open = price; + price = Math.max(10, price + drift + 0.3 * Math.sin(k / 7)); + const close = price; + out.push({ + ts: t0 + k * M15, + open, + high: Math.max(open, close) + 0.2, + low: Math.min(open, close) - 0.2, + close, + volume: 1, + }); + } + return out; +} + +const PAIR: Pair = 'BTC_USDT'; + +function freshState(candles: Candle[]): LiveState { + return { cash: 1000, positions: [], cursorTs: candles[0].ts - 1 }; +} + +describe('processCycle', () => { + test('erzeugt Trades und Decisions auf der synthetischen Serie', () => { + const c15 = syntheticCandles(); + const res = processCycle(new Map([[PAIR, c15]]), freshState(c15), CFG); + expect(res.closedTrades.length).toBeGreaterThan(0); + expect(res.decisions.length).toBeGreaterThan(100); + expect(res.cursorTs).toBe(c15[c15.length - 1].ts); + }); + + test('Parität mit Backtest-Runner (identische Trades)', () => { + const c15 = syntheticCandles(); + const map = new Map([[PAIR, c15]]); + const live = processCycle(map, freshState(c15), CFG); + const bt = runBacktest(map, { + startCapital: 1000, + risk: DEFAULT_RISK, + exec: DEFAULT_EXEC, + maxPositions: 4, + params: DEFAULT_PARAMS, + tradeFrom: 0, + tradeTo: c15[c15.length - 1].ts + M15, + allowShort: false, + }); + const btStops = bt.trades.filter((t) => t.exitReason === 'trailing_stop'); + expect(live.closedTrades).toEqual(btStops); + }); + + test('Split-Äquivalenz: ein Lauf ≡ zwei Läufe mit Cut dazwischen', () => { + const c15 = syntheticCandles(); + const map = new Map([[PAIR, c15]]); + const full = processCycle(map, freshState(c15), CFG); + + const cut = c15[Math.floor(c15.length * 0.8)].ts; + const firstHalf = new Map([[PAIR, c15.filter((c) => c.ts <= cut)]]); + const r1 = processCycle(firstHalf, freshState(c15), CFG); + const r2 = processCycle(map, { cash: r1.cash, positions: r1.positions, cursorTs: r1.cursorTs }, CFG); + + expect(r2.cursorTs).toBe(full.cursorTs); + expect(r2.cash).toBeCloseTo(full.cash, 8); + expect(r2.positions).toEqual(full.positions); + expect([...r1.closedTrades, ...r2.closedTrades]).toEqual(full.closedTrades); + expect([...r1.decisions, ...r2.decisions]).toEqual(full.decisions); + }); + + test('Idempotenz: zweiter Lauf ohne neue Candles ist ein No-op', () => { + const c15 = syntheticCandles(); + const map = new Map([[PAIR, c15]]); + const r1 = processCycle(map, freshState(c15), CFG); + const r2 = processCycle(map, { cash: r1.cash, positions: r1.positions, cursorTs: r1.cursorTs }, CFG); + expect(r2.closedTrades).toEqual([]); + expect(r2.decisions).toEqual([]); + expect(r2.cash).toBe(r1.cash); + expect(r2.cursorTs).toBe(r1.cursorTs); + expect(r2.positions).toEqual(r1.positions); + }); +}); diff --git a/src/server/live/process-cycle.ts b/src/server/live/process-cycle.ts new file mode 100644 index 0000000..ce7bb7d --- /dev/null +++ b/src/server/live/process-cycle.ts @@ -0,0 +1,167 @@ +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) - PAIRS.indexOf(b.pair)); + + 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), + }; +}