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
+170
View File
@@ -0,0 +1,170 @@
"""Scraper for static CheckOnChain Plotly chart HTML pages."""
from __future__ import annotations
import array
import base64
import json
import logging
import re
from html import unescape
import requests
log = logging.getLogger(__name__)
CHARTS = {
"sopr": {
"url": "https://charts.checkonchain.com/btconchain/realised/sopr/sopr_light.html",
"traces": ["SOPR"],
},
"sellside_risk": {
"url": "https://charts.checkonchain.com/btconchain/realised/sellsideriskratio_all/sellsideriskratio_all_light.html",
"traces": ["Sell-side Risk Ratio", "Sellside Risk Ratio", "SSR"],
},
"active_address_momentum": {
"url": "https://charts.checkonchain.com/btconchain/adoption/actaddress_momentum/actaddress_momentum_light.html",
"traces": ["30DMA", "30 Day", "Active Address"],
},
"txcount_momentum": {
"url": "https://charts.checkonchain.com/btconchain/adoption/txcount_momentum/txcount_momentum_light.html",
"traces": ["30DMA", "30 Day", "Transaction"],
},
"nvt_price": {
"url": "https://charts.checkonchain.com/btconchain/pricing/pricing_nvtprice/pricing_nvtprice_light.html",
"traces": ["NVT Price", "NVT"],
},
"vdd_multiple": {
"url": "https://charts.checkonchain.com/btconchain/lifespan/vddmultiple/vddmultiple_light.html",
"traces": ["VDD Multiple", "Value Days Destroyed"],
},
}
def _extract_plotly_traces(html_text: str):
"""Extract first Plotly.newPlot trace array from a static Plotly HTML page."""
marker = "Plotly.newPlot("
start = html_text.find(marker)
if start < 0:
return []
first_array = html_text.find("[", start)
if first_array < 0:
return []
depth = 0
in_string = False
escape = False
quote = ""
for idx in range(first_array, len(html_text)):
ch = html_text[idx]
if in_string:
if escape:
escape = False
elif ch == "\\":
escape = True
elif ch == quote:
in_string = False
continue
if ch in {'"', "'"}:
in_string = True
quote = ch
elif ch == "[":
depth += 1
elif ch == "]":
depth -= 1
if depth == 0:
raw = html_text[first_array:idx + 1]
return json.loads(raw)
return []
def scrape_chart(url: str, timeout=30):
resp = requests.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=timeout)
resp.raise_for_status()
return _extract_plotly_traces(unescape(resp.text))
def _find_trace(traces, names):
names = [n.lower() for n in names if n]
# Prefer non-price traces with the requested terms.
for trace in traces:
trace_name = str(trace.get("name", "")).lower()
if "price" in trace_name and not any("price" in n for n in names):
continue
if any(n in trace_name for n in names):
return trace
# Fallback: first numeric non-price trace.
for trace in traces:
trace_name = str(trace.get("name", "")).lower()
if "price" in trace_name:
continue
y = trace.get("y") or []
if any(v is not None for v in y[-30:]):
return trace
return None
def _decode_plotly_array(values):
"""Decode Plotly typed-array JSON ({dtype, bdata}) or return plain values."""
if not isinstance(values, dict) or "bdata" not in values:
return values or []
dtype = values.get("dtype")
typecodes = {
"f8": "d", "float64": "d",
"f4": "f", "float32": "f",
"i8": "q", "int64": "q",
"i4": "i", "int32": "i",
"u8": "Q", "uint64": "Q",
"u4": "I", "uint32": "I",
}
typecode = typecodes.get(dtype)
if not typecode:
return []
decoded = base64.b64decode(values["bdata"])
arr = array.array(typecode)
arr.frombytes(decoded)
if values.get("byteorder") == "big":
arr.byteswap()
return arr.tolist()
def _numeric_values(trace):
values = []
for value in _decode_plotly_array((trace or {}).get("y", [])):
if value is None:
continue
try:
values.append(float(value))
except (TypeError, ValueError):
pass
return values
def _latest(values):
return values[-1] if values else None
def _momentum(values, window=30):
if len(values) <= window or values[-window] == 0:
return None
return (values[-1] - values[-window]) / values[-window]
def scrape_all():
results = {}
for key, cfg in CHARTS.items():
log.info("Scraping CheckOnChain %s ...", key)
try:
traces = scrape_chart(cfg["url"])
trace = _find_trace(traces, cfg.get("traces", []))
values = _numeric_values(trace)
value = _latest(values)
if key in {"active_address_momentum", "txcount_momentum", "vdd_multiple"}:
# The card value is momentum, while the sparkline shows the raw metric.
value = _momentum(values)
results[key] = {"value": value, "recent": values[-30:]}
except Exception as exc:
log.error("CheckOnChain scrape failed for %s: %s", key, exc)
results[key] = {"value": None, "error": str(exc)}
return results