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:
co-authored by
Claude Opus 4.6 <<EMAIL>>
parent
4647c596b3
commit
8fca6181d5
+154
-13
@@ -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),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user