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:
2026-06-10 06:11:44 +00:00
parent c5d71bba74
commit 29846e82a7
10 changed files with 858 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
node_modules
docs
.env
*.md

13
Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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",

166
public/index.html Normal file
View File

@@ -0,0 +1,166 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>trade-kuns — Paper Trading</title>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --border: #21262d; --text: #e6edf3;
--muted: #8b949e; --green: #3fb950; --red: #f85149; --accent: #58a6ff;
--mono: ui-monospace, 'JetBrains Mono', 'Fira Code', monospace;
}
* { box-sizing: border-box; margin: 0; }
body { background: var(--bg); color: var(--text); font: 14px/1.5 system-ui, sans-serif; padding: 24px; max-width: 1200px; margin: 0 auto; }
header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
h1 { font-size: 20px; font-weight: 600; }
h1 small { color: var(--muted); font-weight: 400; font-size: 13px; }
#status { margin-left: auto; font-size: 12px; color: var(--muted); }
#status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; }
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 20px; }
.kpi { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
.kpi .label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
.kpi .value { font-size: 20px; font-weight: 600; font-family: var(--mono); margin-top: 2px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 20px; }
.panel h2 { font-size: 14px; font-weight: 600; margin-bottom: 10px; color: var(--muted); }
canvas { width: 100%; height: 220px; display: block; }
table { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12.5px; }
th { text-align: right; color: var(--muted); font-weight: 500; padding: 6px 8px; border-bottom: 1px solid var(--border); }
td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th:first-child, td:first-child { text-align: left; }
tr:last-child td { border-bottom: none; }
.pos { color: var(--green); } .neg { color: var(--red); }
.empty { color: var(--muted); padding: 12px 8px; font-style: italic; }
.tag { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; background: var(--border); color: var(--muted); }
.tag.long { background: rgba(63,185,80,.15); color: var(--green); }
@media (max-width: 700px) { body { padding: 12px; } td, th { padding: 5px 4px; } }
</style>
</head>
<body>
<header>
<h1>trade-kuns <small>Donchian-Trendfolge · Paper · BTC ETH SOL XRP</small></h1>
<div id="status"><span class="dot" style="background:var(--muted)"></span>lade…</div>
</header>
<div class="kpis" id="kpis"></div>
<div class="panel"><h2>Equity-Kurve (4h)</h2><canvas id="chart" height="220"></canvas></div>
<div class="panel"><h2>Offene Positionen</h2><div id="positions"></div></div>
<div class="panel"><h2>Abgeschlossene Trades</h2><div id="trades"></div></div>
<div class="panel"><h2>Letzte Entscheidungen (4h-Bars)</h2><div id="decisions"></div></div>
<script>
const fmt = (n, d = 2) => n == null || Number.isNaN(n) ? '' : n.toLocaleString('de-DE', { minimumFractionDigits: d, maximumFractionDigits: d });
const fmtTs = (ts) => ts == null ? '' : new Date(ts).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
const cls = (n) => n > 0 ? 'pos' : n < 0 ? 'neg' : '';
const sign = (n, d = 2) => (n > 0 ? '+' : '') + fmt(n, d);
function kpi(label, value, klass = '') {
return `<div class="kpi"><div class="label">${label}</div><div class="value ${klass}">${value}</div></div>`;
}
function table(headers, rows, emptyMsg) {
if (!rows.length) return `<div class="empty">${emptyMsg}</div>`;
return `<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>${rows.join('')}</tbody></table>`;
}
function drawChart(curve, startCapital) {
const cv = document.getElementById('chart');
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = 220;
cv.width = w * dpr; cv.height = h * dpr;
const ctx = cv.getContext('2d');
ctx.scale(dpr, dpr);
ctx.clearRect(0, 0, w, h);
if (curve.length < 2) {
ctx.fillStyle = '#8b949e'; ctx.font = '13px system-ui';
ctx.fillText('Noch keine Equity-Historie — der erste 4h-Bucket kommt.', 10, h / 2);
return;
}
const pad = { l: 54, r: 8, t: 10, b: 22 };
const xs = curve.map(p => p.ts), ys = curve.map(p => p.equity);
const xmin = xs[0], xmax = xs[xs.length - 1];
let ymin = Math.min(...ys, startCapital), ymax = Math.max(...ys, startCapital);
const yspan = (ymax - ymin) || 1; ymin -= yspan * .08; ymax += yspan * .08;
const X = t => pad.l + (t - xmin) / (xmax - xmin) * (w - pad.l - pad.r);
const Y = v => h - pad.b - (v - ymin) / (ymax - ymin) * (h - pad.t - pad.b);
ctx.strokeStyle = '#21262d'; ctx.fillStyle = '#8b949e'; ctx.font = '11px system-ui';
for (let i = 0; i <= 4; i++) {
const v = ymin + (ymax - ymin) * i / 4, y = Y(v);
ctx.beginPath(); ctx.moveTo(pad.l, y); ctx.lineTo(w - pad.r, y); ctx.stroke();
ctx.fillText(fmt(v, 0), 6, y + 4);
}
ctx.strokeStyle = '#8b949e'; ctx.setLineDash([4, 4]);
ctx.beginPath(); ctx.moveTo(pad.l, Y(startCapital)); ctx.lineTo(w - pad.r, Y(startCapital)); ctx.stroke();
ctx.setLineDash([]);
const last = ys[ys.length - 1];
ctx.strokeStyle = last >= startCapital ? '#3fb950' : '#f85149'; ctx.lineWidth = 1.8;
ctx.beginPath();
curve.forEach((p, i) => i ? ctx.lineTo(X(p.ts), Y(p.equity)) : ctx.moveTo(X(p.ts), Y(p.equity)));
ctx.stroke();
ctx.fillStyle = '#8b949e';
ctx.fillText(fmtTs(xmin), pad.l, h - 6);
const endLabel = fmtTs(xmax);
ctx.fillText(endLabel, w - pad.r - ctx.measureText(endLabel).width, h - 6);
}
async function refresh() {
try {
const [pf, stats, trades, decisions] = await Promise.all([
fetch('/api/portfolio').then(r => r.json()),
fetch('/api/stats').then(r => r.json()),
fetch('/api/trades?limit=30').then(r => r.json()),
fetch('/api/decisions?limit=12').then(r => r.json()),
]);
const pnl = pf.equity - pf.startCapital;
const pnlPct = pf.startCapital ? pnl / pf.startCapital * 100 : 0;
document.getElementById('kpis').innerHTML =
kpi('Equity', fmt(pf.equity) + ' $') +
kpi('PnL gesamt', `${sign(pnl)} $ (${sign(pnlPct, 1)} %)`, cls(pnl)) +
kpi('Cash', fmt(pf.cash) + ' $') +
kpi('Trades', stats.trades) +
kpi('Profit Factor', stats.trades ? fmt(stats.profitFactor) : '') +
kpi('Win Rate', stats.trades ? fmt(stats.winRate * 100, 0) + ' %' : '') +
kpi('Max DD', fmt(stats.maxDrawdownPct * 100, 1) + ' %') +
kpi('BTC Buy&Hold', stats.btcBuyHoldPct == null ? '' : sign(stats.btcBuyHoldPct, 1) + ' %', cls(stats.btcBuyHoldPct));
drawChart(stats.equityCurve || [], stats.startCapital);
document.getElementById('positions').innerHTML = table(
['Pair', 'Entry', 'Entry-Preis', 'Letzter', 'Stop', 'Wert $', 'PnL $'],
pf.positions.map(p => `<tr><td><span class="tag long">${p.pair}</span></td><td>${fmtTs(p.entryTs)}</td><td>${fmt(p.entryPrice, 4)}</td><td>${fmt(p.lastPrice, 4)}</td><td>${fmt(p.stop, 4)}</td><td>${fmt(p.value)}</td><td class="${cls(p.unrealizedPnl)}">${sign(p.unrealizedPnl)}</td></tr>`),
'Keine offene Position — warten auf Donchian-Breakout. Das ist Normalbetrieb.');
document.getElementById('trades').innerHTML = table(
['Pair', 'Entry', 'Exit', 'Entry-Preis', 'Exit-Preis', 'PnL $', 'R', 'Grund'],
trades.map(t => `<tr><td><span class="tag long">${t.pair}</span></td><td>${fmtTs(new Date(t.entryTs).getTime())}</td><td>${fmtTs(new Date(t.exitTs).getTime())}</td><td>${fmt(t.entryPrice, 4)}</td><td>${fmt(t.exitPrice, 4)}</td><td class="${cls(t.pnl)}">${sign(t.pnl)}</td><td class="${cls(t.r)}">${sign(t.r, 2)}</td><td>${t.exitReason}</td></tr>`),
'Noch keine abgeschlossenen Trades.');
document.getElementById('decisions').innerHTML = table(
['Pair', '4h-Bar', 'Close', 'Donchian-High', 'EMA-200', 'ADX', 'Ergebnis'],
decisions.map(d => {
const res = d.signal === 'long' && !d.blockedBy ? '<span class="tag long">ENTRY</span>'
: d.signal === 'long' ? `<span class="tag">long, ${d.blockedBy}</span>`
: `<span class="tag">${d.blockedBy ?? ''}</span>`;
return `<tr><td>${d.pair}</td><td>${fmtTs(new Date(d.barTs).getTime())}</td><td>${fmt(d.close, 4)}</td><td>${fmt(d.donchianHigh, 4)}</td><td>${fmt(d.trendEma, 4)}</td><td>${fmt(d.adx, 1)}</td><td style="text-align:left">${res}</td></tr>`;
}),
'Noch keine Entscheidungen — die erste 4h-Bar schließt demnächst.');
const eng = pf.engine || {};
const ok = eng.lastCycleOk !== false;
document.getElementById('status').innerHTML =
`<span class="dot" style="background:${ok ? 'var(--green)' : 'var(--red)'}"></span>` +
(eng.lastCycleAt ? `Zyklus ${fmtTs(eng.lastCycleAt)}${ok ? '' : ' — FEHLER: ' + (eng.lastError ?? '?')}` : 'erster Zyklus läuft…');
} catch (e) {
document.getElementById('status').innerHTML = `<span class="dot" style="background:var(--red)"></span>API nicht erreichbar`;
}
}
refresh();
setInterval(refresh, 30000);
</script>
</body>
</html>

163
src/server/api/server.ts Normal file
View 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);
}
},
});
}

View File

@@ -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
View 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
View 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));
}
}
}
}

View 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);
});
});

View 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),
};
}