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 ---
|
# --- 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"],
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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}}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user