feat: Live-Paper-Engine — 5-min-Loop, API, Dashboard, Dockerfile
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 <noreply@anthropic.com>
This commit is contained in:
163
src/server/api/server.ts
Normal file
163
src/server/api/server.ts
Normal file
@@ -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<Map<Pair, number>> {
|
||||
const map = new Map<Pair, number>();
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
13
src/server/index.ts
Normal file
13
src/server/index.ts
Normal file
@@ -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);
|
||||
226
src/server/live/engine.ts
Normal file
226
src/server/live/engine.ts
Normal file
@@ -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<Record<Pair, string>>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<LiveState> {
|
||||
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<void> {
|
||||
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<Map<Pair, Candle[]>> {
|
||||
const from = Math.floor(cursorTs / H4) * H4 - WARMUP_4H_BARS * H4;
|
||||
const map = new Map<Pair, Candle[]>();
|
||||
for (const pair of PAIRS) {
|
||||
map.set(pair, await getCandles(pair, from));
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private async persist(prev: LiveState, result: CycleResult): Promise<void> {
|
||||
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<void> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/server/live/process-cycle.test.ts
Normal file
103
src/server/live/process-cycle.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
167
src/server/live/process-cycle.ts
Normal file
167
src/server/live/process-cycle.ts
Normal file
@@ -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<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(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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user