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:
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>
|
||||
Reference in New Issue
Block a user