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:
parent
5538f666c5
commit
ececd65a22
@ -416,11 +416,17 @@ def run_backtest():
|
||||
}
|
||||
|
||||
# --- Build time series for charting ---
|
||||
# Downsample to weekly for chart efficiency
|
||||
# Smart downsampling: daily for last 2 years, weekly before that
|
||||
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):
|
||||
# Include every 7th day + last day
|
||||
if i % 7 == 0 or i == len(daily_scores) - 1:
|
||||
is_recent = d["date"] >= cutoff_date
|
||||
if is_recent or i % 7 == 0 or i == len(daily_scores) - 1:
|
||||
chart_data.append({
|
||||
"date": d["date"],
|
||||
"score": d["score"],
|
||||
|
||||
@ -480,7 +480,10 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
||||
.metric-sparkline{margin-top:8px;height:30px}
|
||||
.metric-sparkline canvas{width:100%;height:30px}
|
||||
.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-dot{width:8px;height:8px;border-radius:50%}
|
||||
.status-dot.live{background:var(--green);animation:pulse 1.5s infinite}
|
||||
@ -546,7 +549,18 @@ DASHBOARD_HTML = """<!DOCTYPE html>
|
||||
|
||||
<!-- Historical Chart -->
|
||||
<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">
|
||||
<canvas id="historyChart"></canvas>
|
||||
</div>
|
||||
@ -670,51 +684,171 @@ function renderMetrics(metrics) {
|
||||
}
|
||||
|
||||
let histChart = null;
|
||||
let fullDailyScores = null;
|
||||
let currentRange = 0; // 0 = ALL
|
||||
|
||||
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');
|
||||
if (!history || !history.length) return;
|
||||
|
||||
const labels = history.map(h => {
|
||||
const d = new Date(h.timestamp);
|
||||
return (d.getMonth()+1) + '/' + d.getDate();
|
||||
});
|
||||
const scores = history.map(h => h.composite_score);
|
||||
const labels = history.map(h => h.date || h.timestamp);
|
||||
const scores = history.map(h => h.composite_score || h.score);
|
||||
const prices = history.map(h => h.price || null);
|
||||
|
||||
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, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
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',
|
||||
}]
|
||||
},
|
||||
plugins: [zonePlugin],
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { labels: { color: '#94a3b8', font: { size: 11 } } },
|
||||
annotation: null,
|
||||
legend: { labels: { color: '#94a3b8', font: { size: 11, family: 'monospace' } } },
|
||||
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: {
|
||||
x: { ticks: { color: '#94a3b8', maxTicksLimit: 15 }, grid: { color: '#1e293b' } },
|
||||
y: { min: 0, max: 100, ticks: { color: '#f7931a' }, grid: { color: '#1e293b' },
|
||||
title: { display: true, text: 'Score (0-100)', color: '#f7931a' }
|
||||
x: {
|
||||
ticks: { color: '#64748b', maxTicksLimit: 12, font: { family: 'monospace', size: 10 } },
|
||||
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) {
|
||||
const dot = document.getElementById('statusDot');
|
||||
const text = document.getElementById('statusText');
|
||||
|
||||
@ -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: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: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}}}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user