feat: ML-optimized accumulation scoring with dashboard toggle

Train GradientBoostedClassifier on 2,601 days of historical data
(2018-2025) to find optimal metric weights for identifying the best
long-term buying opportunities. Uses time-series cross-validation
to prevent look-ahead bias.

Key results:
- pct_above_200w_sma: 50.7% weight (was 11.1% equal)
- drawdown: 14.6%, lth_rp: 10.9%, rhodl: 8.9%
- fear_greed demoted from 11.1% to 5.1%
- nupl/mvrv nearly eliminated (0.7-1.8%)

ML Strong Accumulation bracket: avg +210% 1yr (vs +176% classic)

New files: ml/optimizer.py, config/ml_weights.json
Modified: scoring/engine.py (score_all_ml), backtesting/engine.py
(ml_mode), dashboard/server.py (Classic/ML toggle)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BizzleBot
2026-03-21 23:18:29 +00:00
co-authored by Claude Opus 4.6
parent f1d38f9abb
commit 4647c596b3
6 changed files with 942 additions and 18 deletions
+62 -11
View File
@@ -192,10 +192,17 @@ def run_scrape(force_full=False):
if "_onchain_timestamp" in existing_cache:
metrics["_onchain_timestamp"] = existing_cache["_onchain_timestamp"]
# 4. Score everything
# 4. Score everything (classic + ML)
log.info("Scoring metrics...")
scored = engine.score_all(metrics)
metrics["_scored"] = scored
# ML-optimized scoring (parallel)
try:
scored_ml = engine.score_all_ml(metrics)
metrics["_scored_ml"] = scored_ml
except Exception as e:
log.warning("ML scoring failed (non-critical): %s", e)
metrics["_timestamp"] = datetime.now(timezone.utc).isoformat()
save_cache(metrics)
@@ -335,10 +342,15 @@ def _fetch_models(provider, providers):
# ── API Routes ────────────────────────────────────────────────────────────
@app.get("/api/data")
def api_data():
"""Return current cached metrics + scores."""
def api_data(mode: str = "classic"):
"""Return current cached metrics + scores.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
cache = load_cache()
scored = cache.get("_scored", {})
if mode == "ml":
scored = cache.get("_scored_ml", cache.get("_scored", {}))
else:
scored = cache.get("_scored", {})
price_data = cache.get("price", {})
drawdown_data = cache.get("drawdown", {})
extras = cache.get("price_extras", {})
@@ -352,6 +364,7 @@ def api_data():
"last_update": cache.get("_timestamp"),
"scraper_running": _scraper_running,
"last_error": _last_error,
"mode": mode,
}
@@ -513,6 +526,13 @@ DASHBOARD_HTML = """<!DOCTYPE html>
.status-dot.stale{background:var(--yellow)}
.status-dot.error{background:var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
.mode-toggle{display:flex;border-radius:6px;overflow:hidden;border:1px solid var(--border)}
.mode-btn{padding:6px 14px;border:none;background:transparent;color:var(--text-dim);font-family:inherit;font-weight:600;font-size:.8rem;cursor:pointer;transition:all .15s}
.mode-btn:hover{color:var(--text)}
.mode-btn.active[data-mode="classic"]{background:var(--accent);color:#000}
.mode-btn.active[data-mode="ml"]{background:#8b5cf6;color:#fff}
.ml-badge{display:inline-block;font-size:.6rem;font-weight:700;padding:2px 6px;border-radius:3px;background:#8b5cf6;color:#fff;vertical-align:super;margin-left:4px}
.ml-weight{font-size:.65rem;color:#8b5cf6;font-family:var(--mono);margin-top:2px}
</style>
</head>
<body>
@@ -530,6 +550,10 @@ DASHBOARD_HTML = """<!DOCTYPE html>
<span class="status-dot" id="statusDot"></span>
<span id="statusText">Loading...</span>
</div>
<div class="mode-toggle" id="modeToggle" title="Switch between Classic (equal-weight) and ML-optimized scoring">
<button class="mode-btn active" data-mode="classic" onclick="setMode('classic')">Classic</button>
<button class="mode-btn" data-mode="ml" onclick="setMode('ml')">ML</button>
</div>
<button class="btn btn-accent" onclick="doRefresh(false)" id="btnRefresh">⚡ Quick Refresh</button>
<button class="btn btn-secondary" onclick="doRefresh(true)" id="btnFullRefresh" title="Re-scrape on-chain metrics from LookIntoBitcoin (~2-3 min)">🔄 Full Refresh</button>
</div>
@@ -689,6 +713,11 @@ function renderMetrics(metrics) {
html += '</div></div>';
html += '<div class="metric-value">' + (m.display_value || 'N/A') + '</div>';
html += '<div class="metric-desc">' + (m.description || '') + '</div>';
if (currentMode === 'ml' && m.ml_weight != null) {
const wpct = (m.ml_weight * 100).toFixed(1);
const contrib = m.ml_contribution != null ? m.ml_contribution.toFixed(1) : '--';
html += '<div class="ml-weight">ML weight: ' + wpct + '% · contribution: ' + contrib + ' pts</div>';
}
if (hasSparkline) {
html += '<div class="metric-sparkline"><canvas id="spark-' + idx + '"></canvas></div>';
}
@@ -841,7 +870,7 @@ function renderHistoryFromData(history) {
// Load backtest daily scores for the chart
async function loadBacktestChart() {
try {
const r = await fetch('/api/backtest');
const r = await fetch('/api/backtest?mode=' + currentMode);
const data = await r.json();
if (data.chart_data && data.chart_data.length) {
fullDailyScores = data.chart_data;
@@ -894,7 +923,7 @@ function updateStatus(data) {
async function poll() {
try {
const [dataRes, histRes] = await Promise.all([
fetch('/api/data'), fetch('/api/history')
fetch('/api/data?mode=' + currentMode), fetch('/api/history')
]);
const data = await dataRes.json();
const history = await histRes.json();
@@ -906,7 +935,12 @@ async function poll() {
// Assessment
const el = document.getElementById('assessment');
el.textContent = scored.assessment || 'Loading...';
let assessText = scored.assessment || 'Loading...';
if (currentMode === 'ml') {
el.innerHTML = assessText + '<span class="ml-badge">ML</span>';
} else {
el.textContent = assessText;
}
el.style.color = assessmentColor(composite);
// Price
@@ -925,7 +959,11 @@ async function poll() {
if (data.mayer_multiple) document.getElementById('mayerDisplay').textContent = data.mayer_multiple.toFixed(2);
if (data.sma_200d) document.getElementById('sma200dDisplay').textContent = '$' + Math.round(data.sma_200d).toLocaleString();
if (scored.scored_count != null) {
document.getElementById('scoredCount').textContent = scored.scored_count + '/' + scored.total_count + ' metrics active';
let countText = scored.scored_count + '/' + scored.total_count + ' metrics active';
if (currentMode === 'ml' && scored.classic_score != null) {
countText += ' · Classic: ' + scored.classic_score;
}
document.getElementById('scoredCount').textContent = countText;
}
// Metrics
@@ -954,6 +992,17 @@ async function doRefresh(full) {
setTimeout(() => { btn.disabled = false; btn.textContent = origText; }, delay);
}
let currentMode = 'classic';
function setMode(mode) {
currentMode = mode;
document.querySelectorAll('.mode-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
poll(); // Refresh with new mode
loadBacktestChart(); // Reload chart with new mode
}
drawScoreRing(0);
poll();
setInterval(poll, 30000);
@@ -1174,11 +1223,13 @@ _history_collector_progress = {}
@app.get("/api/backtest")
def api_backtest():
"""Run backtest and return full results."""
def api_backtest(mode: str = "classic"):
"""Run backtest and return full results.
mode=classic (default) or mode=ml for ML-optimized scoring.
"""
try:
from backtesting.engine import run_backtest
return run_backtest()
return run_backtest(ml_mode=(mode == "ml"))
except Exception as e:
log.error("Backtest error: %s", traceback.format_exc())
return JSONResponse({"error": str(e)}, status_code=500)