feat: per-metric historical exploration with click-to-select context

- Click any metric card to see historical periods where it was at a similar level
- Purple dot highlighting on chart shows matching periods
- Metric overlay line plotted on chart (dashed purple)
- Metric Context panel shows percentile, comparable days, avg forward returns,
  and historical examples from different market cycles
- New /api/metric-context endpoint for per-metric similarity analysis
- Backtest chart_data now includes per-metric raw values
- score_day() returns raw metric values alongside scores
- Fixed JS SyntaxError from broken inline onclick escaping (uses addEventListener)

Co-Authored-By: Claude Opus 4.6 <<EMAIL>>
This commit is contained in:
Hermes Agent
2026-06-28 22:49:15 +00:00
co-authored by Claude Opus 4.6 <<EMAIL>>
parent 4647c596b3
commit 8fca6181d5
4 changed files with 873 additions and 48 deletions
+154 -13
View File
@@ -259,6 +259,65 @@ def score_hash_ribbons(data, thresholds=None):
return 3, "Normal mining activity"
def score_sopr(value, thresholds=None):
if value is None:
return None, "No data"
if value < 0.98:
return 10, "Deep loss realization — capitulation, strong accumulation"
if value < 1.0:
return 8, "Below breakeven — capitulation, good accumulation"
if value < 1.02:
return 5, "Near breakeven — neutral"
if value < 1.05:
return 2, "Moderate profit taking"
return 0, "Elevated profit taking — caution"
def score_sellside_risk(value, thresholds=None):
if value is None:
return None, "No data"
if value < 0.001:
return 10, "Very low sell-side risk — strong accumulation"
if value < 0.002:
return 8, "Low sell-side risk — good accumulation"
if value < 0.005:
return 5, "Moderate sell-side risk"
if value < 0.01:
return 2, "Elevated sell-side risk"
return 0, "High sell-side risk"
def score_momentum_pct(value):
if value is None:
return None, "No data"
pct = value * 100
if pct >= 20:
return 10, f"Strong positive momentum (+{pct:.0f}%)"
if pct >= 0:
return 6, f"Mild positive momentum (+{pct:.0f}%)"
if pct >= -10:
return 4, f"Slightly negative momentum ({pct:.0f}%)"
if pct >= -25:
return 2, f"Weak momentum ({pct:.0f}%)"
return 1, f"Strong negative momentum ({pct:.0f}%)"
def score_nvt_price(nvt_price, spot_price):
if nvt_price is None or spot_price is None or spot_price <= 0:
return None, "No data"
premium = (nvt_price - spot_price) / spot_price * 100
if premium < -25:
return 10, f"NVT price {abs(premium):.0f}% below spot — deep value"
if premium < -10:
return 8, f"NVT price {abs(premium):.0f}% below spot — undervalued"
if premium < 10:
relation = "below" if premium < 0 else "above"
return 5, f"NVT price {abs(premium):.0f}% {relation} spot — fair value"
if premium < 30:
return 2, f"NVT price {premium:.0f}% above spot — extended"
return 0, f"NVT price {premium:.0f}% above spot — overheated"
def score_all(metrics):
"""Score all metrics and return individual + composite scores."""
thresholds = load_thresholds()
@@ -399,6 +458,84 @@ def score_all(metrics):
"recent": [],
})
# SOPR
sopr = metrics.get("sopr", {})
sopr_score, sopr_desc = score_sopr(sopr.get("value"), thresholds)
results.append({
"name": "SOPR",
"key": "sopr",
"value": sopr.get("value"),
"display_value": f"{sopr.get('value', 'N/A'):.4f}" if sopr.get("value") is not None else "N/A",
"score": sopr_score,
"description": sopr_desc,
"recent": sopr.get("recent", []),
})
# Sell-side Risk Ratio
ssr = metrics.get("sellside_risk", {})
ssr_score, ssr_desc = score_sellside_risk(ssr.get("value"), thresholds)
results.append({
"name": "Sell-side Risk Ratio",
"key": "sellside_risk",
"value": ssr.get("value"),
"display_value": f"{ssr.get('value', 'N/A'):.6f}" if ssr.get("value") is not None else "N/A",
"score": ssr_score,
"description": ssr_desc,
"recent": ssr.get("recent", []),
})
# Active Address Momentum
aam = metrics.get("active_address_momentum", {})
aam_score, aam_desc = score_momentum_pct(aam.get("value"))
results.append({
"name": "Active Address Momentum",
"key": "active_address_momentum",
"value": aam.get("value"),
"display_value": f"{aam.get('value') * 100:.1f}%" if aam.get("value") is not None else "N/A",
"score": aam_score,
"description": aam_desc,
"recent": aam.get("recent", []),
})
# Transaction Count Momentum
txm = metrics.get("txcount_momentum", {})
txm_score, txm_desc = score_momentum_pct(txm.get("value"))
results.append({
"name": "Transaction Count Momentum",
"key": "txcount_momentum",
"value": txm.get("value"),
"display_value": f"{txm.get('value') * 100:.1f}%" if txm.get("value") is not None else "N/A",
"score": txm_score,
"description": txm_desc,
"recent": txm.get("recent", []),
})
# NVT Price
nvt = metrics.get("nvt_price", {})
nvt_score, nvt_desc = score_nvt_price(nvt.get("value"), current_price)
results.append({
"name": "NVT Price",
"key": "nvt_price",
"value": nvt.get("value"),
"display_value": f"${nvt.get('value'):,.0f}" if nvt.get("value") is not None else "N/A",
"score": nvt_score,
"description": nvt_desc,
"recent": nvt.get("recent", []),
})
# VDD Multiple
vdd = metrics.get("vdd_multiple", {})
vdd_score, vdd_desc = score_momentum_pct(vdd.get("value"))
results.append({
"name": "VDD Multiple",
"key": "vdd_multiple",
"value": vdd.get("value"),
"display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A",
"score": vdd_score,
"description": vdd_desc,
"recent": vdd.get("recent", []),
})
# Compute composite
valid_scores = [r["score"] for r in results if r["score"] is not None]
if valid_scores:
@@ -481,31 +618,34 @@ def score_all_ml(metrics):
results = classic["metrics"]
# Compute ML-weighted composite
weighted_sum = 0.0
weight_total = 0.0
# Compute raw ML weights first, then normalize across only the currently
# scored metrics. This keeps the dashboard's displayed per-metric weights and
# contribution points consistent with the normalized composite score even
# when optional metrics are missing or hash ribbons receives its fallback.
weighted_metrics = []
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
raw_weight = 0.01
else:
w = ml_weights.get(ml_key, 0.0)
raw_weight = ml_weights.get(ml_key, 0.0)
weighted_metrics.append((m, raw_weight))
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)
weight_total = sum(raw_weight for _, raw_weight in weighted_metrics)
if weight_total > 0:
composite = weighted_sum / weight_total * 10
composite = sum(m["score"] * raw_weight for m, raw_weight in weighted_metrics) / weight_total * 10
else:
composite = 0
for m, raw_weight in weighted_metrics:
effective_weight = raw_weight / weight_total if weight_total > 0 else 0.0
m["ml_raw_weight"] = round(raw_weight, 4)
m["ml_weight"] = round(effective_weight, 4)
m["ml_contribution"] = round(m["score"] * effective_weight * 10, 2)
# Assessment text (same thresholds as classic)
if composite >= 80:
assessment = "EXTREME ACCUMULATION ZONE"
@@ -528,4 +668,5 @@ def score_all_ml(metrics):
"total_count": classic["total_count"],
"ml_mode": True,
"classic_score": classic["composite_score"],
"ml_weight_total": round(weight_total, 4),
}