feat: interactive score history chart with time range selector + BTC price overlay

- Time range buttons: 30D, 90D, 6M, 1Y, 2Y, 4Y, ALL
- BTC price overlay on right y-axis (orange dashed line)
- Accumulation zone backgrounds (green/yellow/red shading)
- Threshold lines at 65, 50, 35
- Tooltip shows score + zone label + BTC price
- Uses backtest daily_scores for full history (not just score_history.jsonl)
- Smart downsampling: daily for last 2yr, weekly before that
- Chart height increased to 320px
This commit is contained in:
BizzleBot 2026-03-21 22:41:22 +00:00
parent 5538f666c5
commit ececd65a22
3 changed files with 172 additions and 29 deletions

View File

@ -416,11 +416,17 @@ def run_backtest():
} }
# --- Build time series for charting --- # --- Build time series for charting ---
# Downsample to weekly for chart efficiency # Smart downsampling: daily for last 2 years, weekly before that
chart_data = [] chart_data = []
cutoff_2yr = daily_scores[-1]["date"][:4] # rough 2yr cutoff
try:
from datetime import datetime as dt, timedelta
cutoff_date = (dt.strptime(daily_scores[-1]["date"], "%Y-%m-%d") - timedelta(days=730)).strftime("%Y-%m-%d")
except:
cutoff_date = "2024-01-01"
for i, d in enumerate(daily_scores): for i, d in enumerate(daily_scores):
# Include every 7th day + last day is_recent = d["date"] >= cutoff_date
if i % 7 == 0 or i == len(daily_scores) - 1: if is_recent or i % 7 == 0 or i == len(daily_scores) - 1:
chart_data.append({ chart_data.append({
"date": d["date"], "date": d["date"],
"score": d["score"], "score": d["score"],

View File

@ -480,7 +480,10 @@ DASHBOARD_HTML = """<!DOCTYPE html>
.metric-sparkline{margin-top:8px;height:30px} .metric-sparkline{margin-top:8px;height:30px}
.metric-sparkline canvas{width:100%;height:30px} .metric-sparkline canvas{width:100%;height:30px}
.chart-section{margin-bottom:20px} .chart-section{margin-bottom:20px}
.chart-container{position:relative;height:280px} .chart-container{position:relative;height:320px}
.range-btn{padding:4px 10px;border-radius:6px;border:1px solid var(--border);background:transparent;color:var(--muted);font-size:.75rem;font-family:var(--mono);cursor:pointer;transition:all .2s}
.range-btn:hover{color:var(--text);border-color:var(--accent)}
.range-btn.active{background:var(--accent);color:#000;border-color:var(--accent);font-weight:600}
.status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)} .status-line{display:flex;align-items:center;gap:8px;font-size:.78rem;color:var(--text-dim)}
.status-dot{width:8px;height:8px;border-radius:50%} .status-dot{width:8px;height:8px;border-radius:50%}
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite} .status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
@ -546,7 +549,18 @@ DASHBOARD_HTML = """<!DOCTYPE html>
<!-- Historical Chart --> <!-- Historical Chart -->
<div class="card chart-section"> <div class="card chart-section">
<h2>Composite Score History</h2> <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;margin-bottom:12px">
<h2 style="margin:0">Composite Score History</h2>
<div id="chartRangeBar" style="display:flex;gap:4px">
<button class="range-btn" data-days="30">30D</button>
<button class="range-btn" data-days="90">90D</button>
<button class="range-btn" data-days="180">6M</button>
<button class="range-btn" data-days="365">1Y</button>
<button class="range-btn" data-days="730">2Y</button>
<button class="range-btn" data-days="1460">4Y</button>
<button class="range-btn active" data-days="0">ALL</button>
</div>
</div>
<div class="chart-container"> <div class="chart-container">
<canvas id="historyChart"></canvas> <canvas id="historyChart"></canvas>
</div> </div>
@ -670,51 +684,171 @@ function renderMetrics(metrics) {
} }
let histChart = null; let histChart = null;
let fullDailyScores = null;
let currentRange = 0; // 0 = ALL
function renderHistory(history) { function renderHistory(history) {
// Legacy: still called by loadData but we'll use backtest data instead
if (!fullDailyScores) {
// Fallback to score_history.jsonl if backtest hasn't loaded
renderHistoryFromData(history);
}
}
function renderHistoryFromData(history) {
const ctx = document.getElementById('historyChart').getContext('2d'); const ctx = document.getElementById('historyChart').getContext('2d');
if (!history || !history.length) return; if (!history || !history.length) return;
const labels = history.map(h => { const labels = history.map(h => h.date || h.timestamp);
const d = new Date(h.timestamp); const scores = history.map(h => h.composite_score || h.score);
return (d.getMonth()+1) + '/' + d.getDate(); const prices = history.map(h => h.price || null);
});
const scores = history.map(h => h.composite_score);
if (histChart) histChart.destroy(); if (histChart) histChart.destroy();
const datasets = [{
label: 'Accumulation Score',
data: scores,
borderColor: '#22d3ee',
backgroundColor: 'rgba(34,211,238,0.08)',
borderWidth: 2,
fill: true,
tension: 0.2,
pointRadius: scores.length > 200 ? 0 : 2,
pointBackgroundColor: '#22d3ee',
yAxisID: 'y',
}];
if (prices && prices.some(p => p != null)) {
datasets.push({
label: 'BTC Price',
data: prices,
borderColor: '#f7931a',
borderWidth: 1.5,
borderDash: [4, 2],
fill: false,
tension: 0.2,
pointRadius: 0,
yAxisID: 'y1',
});
}
// Accumulation zone backgrounds
const zonePlugin = {
id: 'zones',
beforeDraw(chart) {
const { ctx, chartArea: { top, bottom, left, right }, scales: { y } } = chart;
const zones = [
{ min: 65, max: 100, color: 'rgba(34,197,94,0.06)' },
{ min: 50, max: 65, color: 'rgba(234,179,8,0.04)' },
{ min: 0, max: 35, color: 'rgba(239,68,68,0.04)' },
];
zones.forEach(z => {
const yTop = y.getPixelForValue(z.max);
const yBot = y.getPixelForValue(z.min);
ctx.fillStyle = z.color;
ctx.fillRect(left, yTop, right - left, yBot - yTop);
});
// Draw threshold lines
[65, 50, 35].forEach(val => {
const yPos = y.getPixelForValue(val);
ctx.beginPath();
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(255,255,255,0.06)';
ctx.lineWidth = 1;
ctx.moveTo(left, yPos);
ctx.lineTo(right, yPos);
ctx.stroke();
ctx.setLineDash([]);
});
}
};
histChart = new Chart(ctx, { histChart = new Chart(ctx, {
type: 'line', type: 'line',
data: { plugins: [zonePlugin],
labels, data: { labels, datasets },
datasets: [{
label: 'Composite Score',
data: scores,
borderColor: '#f7931a',
backgroundColor: 'rgba(247,147,26,0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#f7931a',
}]
},
options: { options: {
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { plugins: {
legend: { labels: { color: '#94a3b8', font: { size: 11 } } }, legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
annotation: null, tooltip: {
backgroundColor: 'rgba(10,10,15,0.95)',
borderColor: 'rgba(255,255,255,0.08)',
borderWidth: 1,
titleFont: { family: 'monospace', size: 11 },
bodyFont: { family: 'monospace', size: 11 },
callbacks: {
label: function(ctx) {
if (ctx.dataset.yAxisID === 'y1') return 'BTC: $' + ctx.raw.toLocaleString();
const s = ctx.raw;
let zone = s >= 80 ? 'Extreme Accum' : s >= 65 ? 'Strong Accum' : s >= 50 ? 'Moderate' : s >= 35 ? 'Neutral' : 'Caution';
return 'Score: ' + s.toFixed(1) + ' (' + zone + ')';
}
}
}
}, },
scales: { scales: {
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15 }, grid: { color: '#1e293b' } }, x: {
y: { min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' }, ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
title: { display: true, text: 'Score (0-100)', color: '#f7931a' } grid: { color: 'rgba(255,255,255,0.03)' }
},
y: {
min: 0, max: 100,
ticks: { color: '#22d3ee', font: { family: 'monospace', size: 10 } },
grid: { color: 'rgba(255,255,255,0.03)' },
title: { display: true, text: 'Score', color: '#22d3ee', font: { family: 'monospace', size: 11 } }
},
y1: {
position: 'right',
ticks: {
color: '#f7931a',
font: { family: 'monospace', size: 10 },
callback: v => '$' + (v >= 1000 ? (v/1000).toFixed(0) + 'k' : v)
},
grid: { drawOnChartArea: false },
title: { display: true, text: 'BTC Price', color: '#f7931a', font: { family: 'monospace', size: 11 } }
} }
} }
} }
}); });
} }
// Load backtest daily scores for the chart
async function loadBacktestChart() {
try {
const r = await fetch('/api/backtest');
const data = await r.json();
if (data.chart_data && data.chart_data.length) {
fullDailyScores = data.chart_data;
applyChartRange(currentRange);
}
} catch(e) { console.error('Backtest chart load failed:', e); }
}
function applyChartRange(days) {
currentRange = days;
if (!fullDailyScores) return;
let filtered = fullDailyScores;
if (days > 0) {
filtered = fullDailyScores.slice(-days);
}
renderHistoryFromData(filtered);
}
// Range button handlers
document.querySelectorAll('.range-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
applyChartRange(parseInt(this.dataset.days));
});
});
// Load backtest data on page load
loadBacktestChart();
function updateStatus(data) { function updateStatus(data) {
const dot = document.getElementById('statusDot'); const dot = document.getElementById('statusDot');
const text = document.getElementById('statusText'); const text = document.getElementById('statusText');

View File

@ -100,3 +100,6 @@
{"timestamp": "2026-03-21T22:04:28.799920+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.251269035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-21T22:04:28.799920+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.251269035533}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:17:53.423421+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-21T22:17:53.423421+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23857868020305}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:21:10.295734+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23064720812182}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}} {"timestamp": "2026-03-21T22:21:10.295734+00:00", "composite_score": 54.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 7, "value": 12}, "puell_multiple": {"score": 5, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 5, "value": 0.5211180167687892}, "drawdown": {"score": 6, "value": 44.23064720812182}, "price_vs_200w_sma": {"score": 6, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 7, "value": 0.22243290955405431}, "lth_realized_price": {"score": 1, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:35:13.975107+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.254441624365484}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:40:38.516771+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}
{"timestamp": "2026-03-21T22:41:18.262393+00:00", "composite_score": 71.0, "scored_count": 10, "metrics": {"fear_greed": {"score": 10, "value": 12}, "puell_multiple": {"score": 8, "value": 0.6602699608966011}, "mvrv_zscore": {"score": 8, "value": 0.5211180167687892}, "drawdown": {"score": 8, "value": 44.20685279187817}, "price_vs_200w_sma": {"score": 7, "value": 58895.78086828114}, "reserve_risk": {"score": 10, "value": 0.0012985709697654493}, "rhodl_ratio": {"score": 4, "value": 1230.6243545314708}, "nupl": {"score": 8, "value": 0.22243290955405431}, "lth_realized_price": {"score": 5, "value": 43346.58756410873}, "hash_ribbons": {"score": 3, "value": null}}}