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:
co-authored by
Claude Opus 4.6
parent
f1d38f9abb
commit
4647c596b3
+62
-11
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user