- 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>>
171 lines
5.3 KiB
Python
171 lines
5.3 KiB
Python
"""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
|