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
+101
View File
@@ -428,3 +428,104 @@ def score_all(metrics):
"scored_count": len(valid_scores),
"total_count": len(results),
}
# ── ML-Optimized Scoring ──────────────────────────────────────────────
ML_WEIGHTS_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"config",
"ml_weights.json",
)
# Maps scoring engine metric keys to ML weight keys
_ML_KEY_MAP = {
"fear_greed": "fear_greed",
"puell_multiple": "puell_multiple",
"mvrv_zscore": "mvrv_zscore",
"drawdown": "drawdown",
"price_vs_200w_sma": "pct_above_200w_sma",
"reserve_risk": "reserve_risk",
"rhodl_ratio": "rhodl_ratio",
"nupl": "nupl",
"lth_realized_price": "pct_above_lth_rp",
}
def load_ml_weights():
"""Load ML-optimized weights from config."""
try:
with open(ML_WEIGHTS_PATH) as f:
data = json.load(f)
return data.get("weights", {})
except Exception:
return {}
def score_all_ml(metrics):
"""Score all metrics using ML-optimized weights.
Same output format as score_all() but uses learned weights
instead of equal weighting. Each metric still shows its
individual 0-10 score plus the ML weight applied to it.
"""
# Get classic scores first (reuses all individual scoring logic)
classic = score_all(metrics)
ml_weights = load_ml_weights()
if not ml_weights:
# Fallback to classic if no ML weights available
classic["ml_mode"] = False
classic["ml_error"] = "ML weights not found — run ml/optimizer.py"
return classic
results = classic["metrics"]
# Compute ML-weighted composite
weighted_sum = 0.0
weight_total = 0.0
for m in results:
if m["score"] is None:
continue
ml_key = _ML_KEY_MAP.get(m["key"])
if ml_key is None:
# Hash ribbons or unknown metric — use small default weight
w = 0.01
else:
w = ml_weights.get(ml_key, 0.0)
m["ml_weight"] = round(w, 4)
m["ml_contribution"] = round(m["score"] * w * 10, 2)
weighted_sum += m["score"] * w
weight_total += w
# Normalize if weights don't sum to 1 (e.g., missing metrics)
if weight_total > 0:
composite = weighted_sum / weight_total * 10
else:
composite = 0
# Assessment text (same thresholds as classic)
if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
elif composite >= 65:
assessment = "STRONG ACCUMULATION ZONE"
elif composite >= 50:
assessment = "MODERATE OPPORTUNITY"
elif composite >= 35:
assessment = "NEUTRAL"
elif composite >= 20:
assessment = "CAUTION — OVERHEATED"
else:
assessment = "EXTREME CAUTION"
return {
"metrics": results,
"composite_score": round(composite, 1),
"assessment": assessment,
"scored_count": classic["scored_count"],
"total_count": classic["total_count"],
"ml_mode": True,
"classic_score": classic["composite_score"],
}