diff --git a/backtesting/engine.py b/backtesting/engine.py
index 1b6f3d0..c746acd 100644
--- a/backtesting/engine.py
+++ b/backtesting/engine.py
@@ -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"],
diff --git a/dashboard/server.py b/dashboard/server.py
index b2e988e..787a8a8 100644
--- a/dashboard/server.py
+++ b/dashboard/server.py
@@ -480,7 +480,10 @@ DASHBOARD_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 = """
-
Composite Score History
+
+
Composite Score History
+
+
+
+
+
+
+
+
+
+
@@ -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');
diff --git a/data/score_history.jsonl b/data/score_history.jsonl
index 7025814..e2a3253 100644
--- a/data/score_history.jsonl
+++ b/data/score_history.jsonl
@@ -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}}}