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
+26 -6
View File
@@ -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"]},