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:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
node_modules
|
||||||
|
docs
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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"]
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"start": "bun run db:migrate && bun run src/server/index.ts",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"backfill": "bun run src/server/scripts/backfill.ts",
|
"backfill": "bun run src/server/scripts/backfill.ts",
|
||||||
"walkforward": "bun run src/server/scripts/walkforward.ts",
|
"walkforward": "bun run src/server/scripts/walkforward.ts",
|
||||||
|
|||||||
166
public/index.html
Normal file
166
public/index.html
Normal 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
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({
|
const Env = z.object({
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
|
PORT: z.coerce.number().default(8080),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const env = Env.parse(process.env);
|
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