diff --git a/backtesting/engine.py b/backtesting/engine.py index 1a161a4..1ce03fb 100644 --- a/backtesting/engine.py +++ b/backtesting/engine.py @@ -146,9 +146,10 @@ _BT_ML_KEY_MAP = { def score_day(date, index, drawdowns, ml_weights=None): - """Score a single day using all available metrics. Returns (composite_score, individual_scores, n_metrics). + """Score a single day using all available metrics. Returns (composite_score, details, n_metrics). If ml_weights is provided, uses ML-optimized weighting instead of equal weights. + details includes both "score" and "raw" (the actual metric value before scoring). """ scores = [] details = {} @@ -160,7 +161,7 @@ def score_day(date, index, drawdowns, ml_weights=None): s = _score_range(val, cfg["ranges"]) if s is not None: scores.append(s) - details[metric_key] = {"value": val, "score": s} + details[metric_key] = {"value": val, "score": s, "raw": val} # Ratio-based metrics (price vs reference) for metric_key, cfg in RATIO_SCORERS.items(): @@ -177,7 +178,7 @@ def score_day(date, index, drawdowns, ml_weights=None): s = _score_range(pct_above, cfg["ranges"]) if s is not None: scores.append(s) - details[metric_key] = {"value": pct_above, "score": s} + details[metric_key] = {"value": pct_above, "score": s, "raw": pct_above} # Drawdown dd = drawdowns.get(date) @@ -185,7 +186,7 @@ def score_day(date, index, drawdowns, ml_weights=None): s = _score_range(dd, DRAWDOWN_RANGES) if s is not None: scores.append(s) - details["drawdown"] = {"value": dd, "score": s} + details["drawdown"] = {"value": dd, "score": s, "raw": dd} if not scores: return None, details, 0 @@ -297,12 +298,19 @@ def run_backtest(ml_mode=False): composite, details, n_metrics = score_day(d, index, drawdowns, ml_weights=ml_weights) if composite is not None and n_metrics >= 3: # Require at least 3 metrics price = price_lookup.get(d) + # Collect raw metric values for per-metric historical exploration + metric_values = {} + for mk, info in details.items(): + raw = info.get("raw") + if raw is not None: + metric_values[mk] = round(raw, 6) if isinstance(raw, float) else raw entry = { "date": d, "score": composite, "n_metrics": n_metrics, "price": price, "forward_returns": fwd_returns.get(d, {}), + "metric_values": metric_values, } daily_scores.append(entry) @@ -462,6 +470,7 @@ def run_backtest(ml_mode=False): # --- Build time series for charting --- # Smart downsampling: daily for last 2 years, weekly before that + # Include per-metric values so the frontend can plot any metric. chart_data = [] import datetime as _dt try: @@ -469,14 +478,25 @@ def run_backtest(ml_mode=False): cutoff_date = (last_date - _dt.timedelta(days=730)).strftime("%Y-%m-%d") except Exception: cutoff_date = "2024-01-01" + + # Collect all metric keys that were ever scored (for per-metric series) + all_metric_keys = set() + for d in daily_scores: + all_metric_keys.update(d.get("metric_values", {}).keys()) + for i, d in enumerate(daily_scores): is_recent = d["date"] >= cutoff_date if is_recent or i % 7 == 0 or i == len(daily_scores) - 1: - chart_data.append({ + entry = { "date": d["date"], "score": d["score"], "price": d["price"], - }) + } + # Include per-metric values (raw metric value, not score) + metric_vals = d.get("metric_values", {}) + if metric_vals: + entry["metrics"] = metric_vals + chart_data.append(entry) result = { "date_range": {"start": daily_scores[0]["date"], "end": daily_scores[-1]["date"]}, diff --git a/dashboard/server.py b/dashboard/server.py index a3d55bc..ffc076f 100644 --- a/dashboard/server.py +++ b/dashboard/server.py @@ -165,7 +165,9 @@ def run_scrape(force_full=False): # 3. On-chain metrics — use cached values (historical data is permanent) onchain_keys = ["puell_multiple", "mvrv_zscore", "reserve_risk", "rhodl_ratio", "nupl", "200w_sma", "lth_realized_price", "hash_ribbons", - "pi_cycle_bottom", "lth_supply"] + "pi_cycle_bottom", "lth_supply", "sopr", "sellside_risk", + "active_address_momentum", "txcount_momentum", "nvt_price", + "vdd_multiple"] has_cached_onchain = any(existing_cache.get(k, {}).get("value") is not None for k in onchain_keys) @@ -176,6 +178,12 @@ def run_scrape(force_full=False): from scrapers import lookintobitcoin onchain = lookintobitcoin.scrape_all() metrics.update(onchain) + try: + from scrapers import checkonchain + metrics.update(checkonchain.scrape_all()) + except Exception as e: + log.error("CheckOnChain scraping failed: %s\n%s", e, traceback.format_exc()) + _last_error = f"CheckOnChain scraping failed: {e}" metrics["_onchain_timestamp"] = datetime.now(timezone.utc).isoformat() except Exception as e: log.error("LookIntoBitcoin scraping failed: %s\n%s", e, traceback.format_exc()) @@ -341,6 +349,46 @@ def _fetch_models(provider, providers): # ── API Routes ──────────────────────────────────────────────────────────── +def _with_informational_onchain_metrics(scored, cache): + """Add non-scored on-chain data cards without changing composite scoring.""" + if not isinstance(scored, dict): + return scored + + enriched = dict(scored) + metrics = [dict(m) for m in scored.get("metrics", [])] + existing_keys = {m.get("key") for m in metrics} + + lth_supply = cache.get("lth_supply", {}) + lth_value = lth_supply.get("value") + if lth_value is not None and "lth_supply" not in existing_keys: + trend = lth_supply.get("trend") + trend_text = f" — {trend}" if trend else "" + metrics.append({ + "name": "Long-Term Holder Supply", + "key": "lth_supply", + "value": lth_value, + "display_value": f"{lth_value:,.0f} BTC", + "score": None, + "description": "Informational on-chain metric; not included in the composite score" + trend_text, + "recent": lth_supply.get("recent", []), + }) + + pi_cycle = cache.get("pi_cycle_bottom", {}) + pi_value = pi_cycle.get("value") + if pi_value is not None and "pi_cycle_bottom" not in existing_keys: + metrics.append({ + "name": "Pi Cycle Bottom", + "key": "pi_cycle_bottom", + "value": pi_value, + "display_value": f"{pi_value:,.2f}" if isinstance(pi_value, (int, float)) else str(pi_value), + "score": None, + "description": "Informational on-chain cycle metric; not included in the composite score", + "recent": pi_cycle.get("recent", []), + }) + + enriched["metrics"] = metrics + return enriched + @app.get("/api/data") def api_data(mode: str = "classic"): """Return current cached metrics + scores. @@ -351,6 +399,7 @@ def api_data(mode: str = "classic"): scored = cache.get("_scored_ml", cache.get("_scored", {})) else: scored = cache.get("_scored", {}) + scored = _with_informational_onchain_metrics(scored, cache) price_data = cache.get("price", {}) drawdown_data = cache.get("drawdown", {}) extras = cache.get("price_extras", {}) @@ -503,8 +552,17 @@ DASHBOARD_HTML = """ .meta-row{display:flex;gap:16px;flex-wrap:wrap;margin-top:8px;font-size:.8rem;color:var(--text-dim)} .meta-row span{display:flex;align-items:center;gap:4px} .metrics-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px;margin-bottom:20px} -.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s} +.metric-card{background:var(--card);border-radius:10px;padding:14px;border:1px solid var(--border);transition:border-color .15s;cursor:pointer} .metric-card:hover{border-color:var(--text-dim)} +.metric-card.selected{border-color:#a78bfa;box-shadow:0 0 0 1px #a78bfa,0 0 12px rgba(167,139,250,0.15)} +.metric-click-hint{font-size:.6rem;margin-left:4px;opacity:0;transition:opacity .15s} +.metric-card:hover .metric-click-hint{opacity:.5} +.metric-card.selected .metric-click-hint{opacity:1} +.mc-examples-title{font-size:.75rem;color:#94a3b8;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px} +.mc-example{font-size:.8rem;font-family:var(--mono);padding:4px 0;border-bottom:1px solid rgba(255,255,255,0.03)} +.mc-ex-date{color:#e2e8f0} +.mc-ex-cycle{color:#a78bfa;font-size:.7rem} +.mc-ex-price{color:#94a3b8} .metric-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:8px} .metric-name{font-size:.85rem;font-weight:600} .metric-score{display:flex;align-items:center;gap:6px} @@ -588,6 +646,20 @@ DASHBOARD_HTML = """ View full backtest → + +
+