feat: add historical backtest engine and dashboard page
- scrapers/history_collector.py: scrapes full time series from 8 LookIntoBitcoin charts + Fear & Greed API, stores to data/history.json (~5700 days back to 2010) - backtesting/engine.py: scores each historical day using same thresholds as live scoring, computes 30d/90d/180d/1yr forward returns, bracket stats, signal events - dashboard/server.py: adds /backtest page with dual-axis score vs price chart, bracket performance table, signal event list, current context box; adds backtest nav link and historical context box on main dashboard; 4 new API endpoints Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e3c5aa9f32
commit
5b3b3811ec
@@ -389,6 +389,7 @@ SHARED_HEAD = """<meta charset="UTF-8">
|
||||
|
||||
NAV_HTML = """<div class="nav">
|
||||
<a href="/" id="nav-dashboard">Dashboard</a>
|
||||
<a href="/backtest" id="nav-backtest">📊 Backtest</a>
|
||||
<a href="/settings" id="nav-settings">⚙ Settings</a>
|
||||
</div>"""
|
||||
|
||||
@@ -488,6 +489,13 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Historical Context (from backtest) -->
|
||||
<div class="card" id="histContext" style="margin-bottom:20px;display:none;border-color:#22d3ee">
|
||||
<h2 style="color:#22d3ee">Historical Context</h2>
|
||||
<div id="histContextText" style="font-size:.9rem;font-family:var(--mono);line-height:1.6"></div>
|
||||
<a href="/backtest" style="font-size:.8rem;color:#22d3ee;text-decoration:none;margin-top:8px;display:inline-block">View full backtest →</a>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<h2>On-Chain Metrics</h2>
|
||||
<div class="metrics-grid" id="metricsGrid">
|
||||
@@ -748,6 +756,28 @@ async function doRefresh() {
|
||||
drawScoreRing(0);
|
||||
poll();
|
||||
setInterval(poll, 30000);
|
||||
|
||||
// Load historical context from backtest
|
||||
(async function() {
|
||||
try {
|
||||
const r = await fetch('/api/backtest/status');
|
||||
const s = await r.json();
|
||||
if (!s.exists) return;
|
||||
const br = await fetch('/api/backtest');
|
||||
const bt = await br.json();
|
||||
if (bt.error || !bt.current_context) return;
|
||||
const ctx = bt.current_context;
|
||||
const el = document.getElementById('histContext');
|
||||
const txt = document.getElementById('histContextText');
|
||||
let html = 'Score <strong>' + ctx.current_score + '</strong> is in the <strong style="color:#22d3ee">top ' + (100 - ctx.percentile).toFixed(1) + '%</strong> historically.';
|
||||
if (ctx.avg_1yr_return != null) {
|
||||
const c = ctx.avg_1yr_return >= 0 ? '#22c55e' : '#ef4444';
|
||||
html += ' Average 1-year return from this level: <strong style="color:' + c + '">' + (ctx.avg_1yr_return >= 0 ? '+' : '') + ctx.avg_1yr_return.toFixed(1) + '%</strong>';
|
||||
}
|
||||
txt.innerHTML = html;
|
||||
el.style.display = 'block';
|
||||
} catch(e) { /* backtest data not available yet */ }
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -929,11 +959,461 @@ loadSettings();
|
||||
</html>"""
|
||||
|
||||
|
||||
# ── Backtest API ───────────────────────────────────────────────────────
|
||||
|
||||
_history_collector_running = False
|
||||
_history_collector_progress = {}
|
||||
|
||||
|
||||
@app.get("/api/backtest")
|
||||
def api_backtest():
|
||||
"""Run backtest and return full results."""
|
||||
try:
|
||||
from backtesting.engine import run_backtest
|
||||
return run_backtest()
|
||||
except Exception as e:
|
||||
log.error("Backtest error: %s", traceback.format_exc())
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.get("/api/backtest/history")
|
||||
def api_backtest_history():
|
||||
"""Return historical daily scores + prices for charting."""
|
||||
try:
|
||||
from backtesting.engine import run_backtest
|
||||
result = run_backtest()
|
||||
return {"chart_data": result.get("chart_data", []), "date_range": result.get("date_range")}
|
||||
except Exception as e:
|
||||
return JSONResponse({"error": str(e)}, status_code=500)
|
||||
|
||||
|
||||
@app.post("/api/backtest/collect")
|
||||
def api_backtest_collect():
|
||||
"""Trigger historical data collection."""
|
||||
global _history_collector_running, _history_collector_progress
|
||||
if _history_collector_running:
|
||||
return JSONResponse({"error": "Collection already in progress", "progress": _history_collector_progress}, status_code=409)
|
||||
|
||||
def _run_collector():
|
||||
global _history_collector_running, _history_collector_progress
|
||||
_history_collector_running = True
|
||||
_history_collector_progress = {"status": "starting", "current": "", "step": 0, "total": 0}
|
||||
try:
|
||||
from scrapers.history_collector import collect_all_history
|
||||
|
||||
def progress_cb(metric, step, total):
|
||||
_history_collector_progress = {"status": "scraping", "current": metric, "step": step + 1, "total": total}
|
||||
|
||||
collect_all_history(progress_cb=progress_cb)
|
||||
_history_collector_progress = {"status": "complete"}
|
||||
except Exception as e:
|
||||
log.error("History collection error: %s", traceback.format_exc())
|
||||
_history_collector_progress = {"status": "error", "error": str(e)}
|
||||
finally:
|
||||
_history_collector_running = False
|
||||
|
||||
t = threading.Thread(target=_run_collector, daemon=True)
|
||||
t.start()
|
||||
return {"ok": True, "message": "Collection started"}
|
||||
|
||||
|
||||
@app.get("/api/backtest/status")
|
||||
def api_backtest_status():
|
||||
"""Check if historical data exists and collection status."""
|
||||
from scrapers.history_collector import history_status
|
||||
status = history_status()
|
||||
status["collecting"] = _history_collector_running
|
||||
status["progress"] = _history_collector_progress
|
||||
return status
|
||||
|
||||
|
||||
# ── Backtest HTML Page ─────────────────────────────────────────────────
|
||||
|
||||
BACKTEST_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
""" + SHARED_HEAD + """
|
||||
<title>Historical Backtest — Bitcoin Accumulation Zone Monitor</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
""" + SHARED_CSS + """
|
||||
.section{margin-bottom:24px}
|
||||
.section h2{margin-bottom:12px}
|
||||
.collect-banner{background:var(--card);border:1px solid var(--border);border-radius:10px;padding:24px;text-align:center;margin-bottom:24px}
|
||||
.collect-banner p{color:var(--text-dim);margin:8px 0 16px}
|
||||
.progress-bar{width:100%;height:8px;background:var(--bg);border-radius:4px;overflow:hidden;margin:12px 0}
|
||||
.progress-fill{height:100%;background:var(--cyan);border-radius:4px;transition:width .3s}
|
||||
.progress-text{font-size:.8rem;color:var(--text-dim);font-family:var(--mono)}
|
||||
table{width:100%;border-collapse:collapse;font-size:.82rem;font-family:var(--mono)}
|
||||
th{text-align:left;padding:10px 8px;border-bottom:2px solid var(--border);color:var(--text-dim);font-size:.7rem;text-transform:uppercase;letter-spacing:.06em;white-space:nowrap}
|
||||
td{padding:8px;border-bottom:1px solid var(--border)}
|
||||
tr:hover td{background:var(--card-hover)}
|
||||
.t-green{color:var(--green)}.t-red{color:var(--red)}.t-yellow{color:var(--yellow)}.t-cyan{color:var(--cyan)}
|
||||
.chart-dual{position:relative;height:400px}
|
||||
.context-box{background:var(--card);border:1px solid var(--cyan);border-radius:10px;padding:20px}
|
||||
.context-score{font-size:2.5rem;font-weight:800;font-family:var(--mono);margin-bottom:4px}
|
||||
.context-percentile{font-size:1rem;color:var(--cyan);margin-bottom:12px}
|
||||
.context-return{font-size:1.1rem;color:var(--green);font-weight:600;margin-bottom:12px}
|
||||
.comparable-list{margin-top:12px}
|
||||
.comparable-item{display:flex;justify-content:space-between;padding:6px 0;border-bottom:1px solid var(--border);font-size:.82rem;font-family:var(--mono)}
|
||||
.signal-card{background:var(--card);border-radius:8px;padding:12px;border:1px solid var(--border);margin-bottom:8px}
|
||||
.signal-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
|
||||
.signal-date{font-weight:700;color:var(--accent);font-family:var(--mono)}
|
||||
.signal-score{font-weight:800;font-family:var(--mono);padding:2px 8px;border-radius:4px;font-size:.85rem}
|
||||
.signal-returns{display:flex;gap:16px;font-size:.8rem;font-family:var(--mono)}
|
||||
.loading-spinner{display:inline-block;width:20px;height:20px;border:3px solid var(--border);border-top-color:var(--cyan);border-radius:50%;animation:spin .6s linear infinite}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h1><span class="btc">₿</span> Accumulation Zone Monitor</h1>
|
||||
<div style="margin-top:8px;display:flex;align-items:center;gap:12px">
|
||||
""" + NAV_HTML + """
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection Banner (shown if no data) -->
|
||||
<div id="collectBanner" class="collect-banner" style="display:none">
|
||||
<h2>Historical Data Required</h2>
|
||||
<p>Scrape full historical time series from LookIntoBitcoin charts, CoinGecko, and Fear & Greed Index.<br>This takes several minutes (10+ charts to scrape).</p>
|
||||
<button class="btn btn-cyan" id="btnCollect" onclick="startCollection()">Collect Historical Data</button>
|
||||
<div id="progressArea" style="display:none;margin-top:16px">
|
||||
<div class="progress-bar"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
|
||||
<div class="progress-text" id="progressText">Starting...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content (shown after data exists) -->
|
||||
<div id="mainContent" style="display:none">
|
||||
|
||||
<!-- Section 1: Current Signal Context -->
|
||||
<div class="section">
|
||||
<div class="context-box" id="contextBox">
|
||||
<h2>Current Signal Context</h2>
|
||||
<div class="context-score" id="ctxScore">--</div>
|
||||
<div class="context-percentile" id="ctxPercentile">Loading...</div>
|
||||
<div class="context-return" id="ctxReturn"></div>
|
||||
<div class="comparable-list" id="ctxComparables"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 2: Historical Score vs Price Chart -->
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<h2>Historical Score vs BTC Price</h2>
|
||||
<div class="chart-dual">
|
||||
<canvas id="dualChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 3: Score Bracket Performance -->
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<h2>Score Bracket Performance</h2>
|
||||
<div style="overflow-x:auto">
|
||||
<table id="bracketTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Score Range</th><th>Label</th><th>Days</th>
|
||||
<th>Avg 30d</th><th>Avg 90d</th><th>Avg 1yr</th>
|
||||
<th>Win Rate (1yr)</th><th>Max Gain (1yr)</th><th>Max Loss (1yr)</th>
|
||||
<th>Avg Max DD</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bracketBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section 4: Major Signal Events -->
|
||||
<div class="section">
|
||||
<div class="card">
|
||||
<h2>Major Signal Events (Score Crossed 70/80/90+)</h2>
|
||||
<div id="signalEvents"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /mainContent -->
|
||||
|
||||
<div class="footer">Bitcoin Accumulation Zone Monitor — Historical Backtest Engine</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('nav-backtest').classList.add('active');
|
||||
""" + TOAST_JS + """
|
||||
|
||||
let backtestData = null;
|
||||
|
||||
function fmtPct(v) { if (v == null) return '--'; return (v >= 0 ? '+' : '') + v.toFixed(1) + '%'; }
|
||||
function fmtPrice(v) { if (v == null) return '--'; return '$' + Math.round(v).toLocaleString(); }
|
||||
function retClass(v) { if (v == null) return ''; return v >= 0 ? 't-green' : 't-red'; }
|
||||
|
||||
function scoreColorCSS(score) {
|
||||
if (score >= 71) return '#22c55e';
|
||||
if (score >= 51) return '#4ade80';
|
||||
if (score >= 31) return '#eab308';
|
||||
if (score >= 15) return '#f97316';
|
||||
return '#ef4444';
|
||||
}
|
||||
|
||||
async function checkStatus() {
|
||||
try {
|
||||
const r = await fetch('/api/backtest/status');
|
||||
const s = await r.json();
|
||||
if (s.exists && !s.collecting) {
|
||||
document.getElementById('collectBanner').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'block';
|
||||
loadBacktest();
|
||||
} else if (s.collecting) {
|
||||
document.getElementById('collectBanner').style.display = 'block';
|
||||
document.getElementById('progressArea').style.display = 'block';
|
||||
document.getElementById('btnCollect').disabled = true;
|
||||
document.getElementById('btnCollect').textContent = 'Collecting...';
|
||||
const p = s.progress || {};
|
||||
if (p.total > 0) {
|
||||
const pct = Math.round((p.step / (p.total + 2)) * 100);
|
||||
document.getElementById('progressFill').style.width = pct + '%';
|
||||
document.getElementById('progressText').textContent = p.current + ' (' + p.step + '/' + p.total + ')';
|
||||
} else {
|
||||
document.getElementById('progressText').textContent = p.status || 'Working...';
|
||||
}
|
||||
if (p.status === 'complete') {
|
||||
document.getElementById('collectBanner').style.display = 'none';
|
||||
document.getElementById('mainContent').style.display = 'block';
|
||||
loadBacktest();
|
||||
return;
|
||||
}
|
||||
setTimeout(checkStatus, 3000);
|
||||
} else {
|
||||
document.getElementById('collectBanner').style.display = 'block';
|
||||
}
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
async function startCollection() {
|
||||
document.getElementById('btnCollect').disabled = true;
|
||||
document.getElementById('btnCollect').textContent = 'Starting...';
|
||||
document.getElementById('progressArea').style.display = 'block';
|
||||
try {
|
||||
const r = await fetch('/api/backtest/collect', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (d.error) { showToast(d.error, 'error'); return; }
|
||||
showToast('Collection started — this will take several minutes', 'success');
|
||||
setTimeout(checkStatus, 3000);
|
||||
} catch(e) { showToast('Failed: ' + e, 'error'); }
|
||||
}
|
||||
|
||||
async function loadBacktest() {
|
||||
try {
|
||||
document.getElementById('mainContent').innerHTML = '<div style="text-align:center;padding:60px;color:var(--text-dim)"><div class="loading-spinner"></div><p style="margin-top:16px">Running backtest analysis...</p></div>';
|
||||
const r = await fetch('/api/backtest');
|
||||
backtestData = await r.json();
|
||||
if (backtestData.error) {
|
||||
document.getElementById('mainContent').innerHTML = '<div class="card" style="text-align:center;padding:40px;color:var(--red)">' + backtestData.error + '</div>';
|
||||
return;
|
||||
}
|
||||
renderAll();
|
||||
} catch(e) { console.error(e); }
|
||||
}
|
||||
|
||||
function renderAll() {
|
||||
// Rebuild the main content
|
||||
document.getElementById('mainContent').innerHTML = `
|
||||
<div class="section"><div class="context-box" id="contextBox"><h2>Current Signal Context</h2><div class="context-score" id="ctxScore">--</div><div class="context-percentile" id="ctxPercentile"></div><div class="context-return" id="ctxReturn"></div><div class="comparable-list" id="ctxComparables"></div></div></div>
|
||||
<div class="section"><div class="card"><h2>Historical Score vs BTC Price</h2><div class="chart-dual"><canvas id="dualChart"></canvas></div></div></div>
|
||||
<div class="section"><div class="card"><h2>Score Bracket Performance</h2><div style="overflow-x:auto"><table><thead><tr><th>Score Range</th><th>Label</th><th>Days</th><th>Avg 30d</th><th>Avg 90d</th><th>Avg 1yr</th><th>Win Rate (1yr)</th><th>Max Gain</th><th>Max Loss</th><th>Avg Max DD</th></tr></thead><tbody id="bracketBody"></tbody></table></div></div></div>
|
||||
<div class="section"><div class="card"><h2>Major Signal Events (Score Crossed 70/80/90+)</h2><div id="signalEvents"></div></div></div>
|
||||
`;
|
||||
renderContext();
|
||||
renderDualChart();
|
||||
renderBracketTable();
|
||||
renderSignalEvents();
|
||||
}
|
||||
|
||||
function renderContext() {
|
||||
const ctx = backtestData.current_context;
|
||||
if (!ctx) return;
|
||||
const el = document.getElementById('ctxScore');
|
||||
el.textContent = ctx.current_score + ' / 100';
|
||||
el.style.color = scoreColorCSS(ctx.current_score);
|
||||
document.getElementById('ctxPercentile').textContent =
|
||||
'Historical percentile: top ' + (100 - ctx.percentile).toFixed(1) + '% of all days (' + ctx.comparable_days + ' comparable days found)';
|
||||
if (ctx.avg_1yr_return != null) {
|
||||
document.getElementById('ctxReturn').textContent =
|
||||
'Average 1-year return from this score level: ' + fmtPct(ctx.avg_1yr_return);
|
||||
document.getElementById('ctxReturn').className = 'context-return ' + retClass(ctx.avg_1yr_return);
|
||||
}
|
||||
// Examples
|
||||
const list = document.getElementById('ctxComparables');
|
||||
if (ctx.examples && ctx.examples.length) {
|
||||
let html = '<h2 style="margin-top:12px">Comparable Historical Periods</h2>';
|
||||
for (const ex of ctx.examples) {
|
||||
const fr = ex.forward_returns || {};
|
||||
html += '<div class="comparable-item"><span>' + ex.date + ' — Score ' + ex.score + ' — ' + fmtPrice(ex.price) + '</span>';
|
||||
html += '<span>';
|
||||
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span> ';
|
||||
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
|
||||
html += '</span></div>';
|
||||
}
|
||||
list.innerHTML = html;
|
||||
}
|
||||
}
|
||||
|
||||
function renderDualChart() {
|
||||
const chart = backtestData.chart_data;
|
||||
if (!chart || !chart.length) return;
|
||||
const ctx = document.getElementById('dualChart').getContext('2d');
|
||||
const labels = chart.map(d => d.date);
|
||||
const scores = chart.map(d => d.score);
|
||||
const prices = chart.map(d => d.price);
|
||||
|
||||
// Zone backgrounds via plugin
|
||||
const zonePlugin = {
|
||||
id: 'zoneBackground',
|
||||
beforeDraw(chart) {
|
||||
const { ctx: c, chartArea: {left, right, top, bottom}, scales: {y} } = chart;
|
||||
if (!y) return;
|
||||
const zones = [
|
||||
{ min: 0, max: 40, color: 'rgba(239,68,68,0.06)' },
|
||||
{ min: 40, max: 70, color: 'rgba(234,179,8,0.06)' },
|
||||
{ min: 70, max: 100, color: 'rgba(34,197,94,0.08)' },
|
||||
];
|
||||
for (const z of zones) {
|
||||
const yTop = y.getPixelForValue(Math.min(z.max, 100));
|
||||
const yBot = y.getPixelForValue(z.min);
|
||||
c.fillStyle = z.color;
|
||||
c.fillRect(left, yTop, right - left, yBot - yTop);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
plugins: [zonePlugin],
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Accumulation Score',
|
||||
data: scores,
|
||||
borderColor: '#f7931a',
|
||||
backgroundColor: 'rgba(247,147,26,0.1)',
|
||||
borderWidth: 1.5,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y',
|
||||
},
|
||||
{
|
||||
label: 'BTC Price (USD)',
|
||||
data: prices,
|
||||
borderColor: '#22d3ee',
|
||||
borderWidth: 1.5,
|
||||
fill: false,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
yAxisID: 'y1',
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(ctx) {
|
||||
if (ctx.datasetIndex === 1) return 'BTC: $' + Math.round(ctx.parsed.y).toLocaleString();
|
||||
return 'Score: ' + ctx.parsed.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: '#94a3b8', maxTicksLimit: 20, maxRotation: 45 }, grid: { color: '#1e293b' } },
|
||||
y: { position: 'left', min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' },
|
||||
title: { display: true, text: 'Score (0-100)', color: '#f7931a' } },
|
||||
y1: { position: 'right', type: 'logarithmic', ticks: { color: '#22d3ee', callback: v => '$' + v.toLocaleString() },
|
||||
grid: { drawOnChartArea: false }, title: { display: true, text: 'BTC Price (log)', color: '#22d3ee' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderBracketTable() {
|
||||
const brackets = backtestData.bracket_stats;
|
||||
if (!brackets) return;
|
||||
const tbody = document.getElementById('bracketBody');
|
||||
let html = '';
|
||||
for (const b of brackets) {
|
||||
const rowColor = b.days === 0 ? '' : (b.avg_365d > 50 ? 'style="background:rgba(34,197,94,0.08)"' : b.avg_365d < 0 ? 'style="background:rgba(239,68,68,0.08)"' : '');
|
||||
html += '<tr ' + rowColor + '>';
|
||||
html += '<td style="font-weight:700">' + b.range + '</td>';
|
||||
html += '<td>' + b.label + '</td>';
|
||||
html += '<td>' + b.days + '</td>';
|
||||
html += '<td class="' + retClass(b.avg_30d) + '">' + fmtPct(b.avg_30d) + '</td>';
|
||||
html += '<td class="' + retClass(b.avg_90d) + '">' + fmtPct(b.avg_90d) + '</td>';
|
||||
html += '<td class="' + retClass(b.avg_365d) + '">' + fmtPct(b.avg_365d) + '</td>';
|
||||
html += '<td>' + (b.win_rate_365d != null ? b.win_rate_365d + '%' : '--') + '</td>';
|
||||
html += '<td class="t-green">' + fmtPct(b.max_gain_365d) + '</td>';
|
||||
html += '<td class="t-red">' + fmtPct(b.max_loss_365d) + '</td>';
|
||||
html += '<td>' + (b.avg_max_drawdown_90d != null ? b.avg_max_drawdown_90d + '%' : '--') + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSignalEvents() {
|
||||
const events = backtestData.signal_events;
|
||||
if (!events || !events.length) return;
|
||||
const el = document.getElementById('signalEvents');
|
||||
let html = '';
|
||||
// Show most recent events first, limit to 30
|
||||
const shown = events.slice(-30).reverse();
|
||||
for (const ev of shown) {
|
||||
const color = scoreColorCSS(ev.score);
|
||||
html += '<div class="signal-card">';
|
||||
html += '<div class="signal-header">';
|
||||
html += '<span class="signal-date">' + ev.date + ' — ' + fmtPrice(ev.price) + '</span>';
|
||||
html += '<span class="signal-score" style="background:' + color + ';color:#000">Score: ' + ev.score + ' (crossed ' + ev.threshold + ')</span>';
|
||||
html += '</div>';
|
||||
html += '<div class="signal-returns">';
|
||||
const fr = ev.forward_returns || {};
|
||||
if (fr['30d'] != null) html += '<span class="' + retClass(fr['30d']) + '">30d: ' + fmtPct(fr['30d']) + '</span>';
|
||||
if (fr['90d'] != null) html += '<span class="' + retClass(fr['90d']) + '">90d: ' + fmtPct(fr['90d']) + '</span>';
|
||||
if (fr['365d'] != null) html += '<span class="' + retClass(fr['365d']) + '">1yr: ' + fmtPct(fr['365d']) + '</span>';
|
||||
if (ev.price_365d) html += '<span style="color:var(--text-dim)">Price 1yr: ' + fmtPrice(ev.price_365d) + '</span>';
|
||||
html += '</div></div>';
|
||||
}
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
// Init
|
||||
checkStatus();
|
||||
</script>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def dashboard():
|
||||
return DASHBOARD_HTML
|
||||
|
||||
|
||||
@app.get("/backtest", response_class=HTMLResponse)
|
||||
def backtest_page():
|
||||
return BACKTEST_HTML
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
def settings_page():
|
||||
return SETTINGS_HTML
|
||||
|
||||
Reference in New Issue
Block a user