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

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>