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}}}