fix: validate scraper metric semantics
This commit is contained in:
+2
-1
@@ -530,8 +530,9 @@ def score_all(metrics):
|
|||||||
vdd = metrics.get("vdd_multiple", {})
|
vdd = metrics.get("vdd_multiple", {})
|
||||||
vdd_score, vdd_desc = score_momentum_pct(vdd.get("value"))
|
vdd_score, vdd_desc = score_momentum_pct(vdd.get("value"))
|
||||||
results.append({
|
results.append({
|
||||||
"name": "VDD Multiple",
|
"name": "VDD 30-Period Momentum",
|
||||||
"key": "vdd_multiple",
|
"key": "vdd_multiple",
|
||||||
|
"transform": "30_period_return",
|
||||||
"value": vdd.get("value"),
|
"value": vdd.get("value"),
|
||||||
"display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A",
|
"display_value": f"{vdd.get('value') * 100:.1f}%" if vdd.get("value") is not None else "N/A",
|
||||||
"score": vdd_score,
|
"score": vdd_score,
|
||||||
|
|||||||
@@ -114,8 +114,15 @@ def collect_onchain_history(progress_cb=None):
|
|||||||
|
|
||||||
for metric_key, trace_name in cfg["traces"].items():
|
for metric_key, trace_name in cfg["traces"].items():
|
||||||
if trace_name is None:
|
if trace_name is None:
|
||||||
# Grab first trace with numeric data
|
if metric_key == "lth_supply":
|
||||||
for candidate in traces:
|
from scrapers.lookintobitcoin import _find_lth_supply_trace
|
||||||
|
candidates = [_find_lth_supply_trace(traces)]
|
||||||
|
else:
|
||||||
|
candidates = traces
|
||||||
|
# Grab the first validated trace with numeric data.
|
||||||
|
for candidate in candidates:
|
||||||
|
if not candidate:
|
||||||
|
continue
|
||||||
y = candidate.get("y", [])
|
y = candidate.get("y", [])
|
||||||
if y and any(v is not None for v in y[-10:]):
|
if y and any(v is not None for v in y[-10:]):
|
||||||
dates, values = _extract_series(candidate)
|
dates, values = _extract_series(candidate)
|
||||||
|
|||||||
@@ -115,6 +115,30 @@ def _find_trace(traces, name):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_signal_is_active(trace):
|
||||||
|
"""Return true only when the signal trace is active at its latest point."""
|
||||||
|
if not trace:
|
||||||
|
return False
|
||||||
|
values = trace.get("y", [])
|
||||||
|
if not values:
|
||||||
|
return False
|
||||||
|
latest = values[-1]
|
||||||
|
try:
|
||||||
|
return latest is not None and float(latest) != 0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return bool(latest)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_lth_supply_trace(traces):
|
||||||
|
"""Select an explicitly named LTH supply series and never a price fallback."""
|
||||||
|
for trace in traces or []:
|
||||||
|
name = str(trace.get("name", "")).lower()
|
||||||
|
is_lth = "long-term holder" in name or "long term holder" in name or "lth" in name
|
||||||
|
if is_lth and "supply" in name and "price" not in name:
|
||||||
|
return trace
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_latest_value(trace):
|
def _get_latest_value(trace):
|
||||||
"""Get the most recent non-null y value from a trace."""
|
"""Get the most recent non-null y value from a trace."""
|
||||||
if not trace:
|
if not trace:
|
||||||
@@ -210,21 +234,16 @@ def scrape_all():
|
|||||||
],
|
],
|
||||||
"value": None,
|
"value": None,
|
||||||
}
|
}
|
||||||
# Try to detect buy signal from trace names/colors
|
# A named signal trace is not itself proof that the signal is active.
|
||||||
for t in traces:
|
for t in traces:
|
||||||
name = t.get("name", "").lower()
|
name = t.get("name", "").lower()
|
||||||
if "buy" in name or "signal" in name:
|
if ("buy" in name or "signal" in name) and _trace_signal_is_active(t):
|
||||||
results[metric_key]["buy_signal"] = True
|
results[metric_key]["buy_signal"] = True
|
||||||
break
|
break
|
||||||
|
|
||||||
elif metric_key == "lth_supply":
|
elif metric_key == "lth_supply":
|
||||||
# Get main supply trace
|
# Require an explicitly named LTH supply trace; price is not supply.
|
||||||
t = traces[0] if traces else None
|
t = _find_lth_supply_trace(traces)
|
||||||
for candidate in traces:
|
|
||||||
name = candidate.get("name", "").lower()
|
|
||||||
if "supply" in name or "lth" in name:
|
|
||||||
t = candidate
|
|
||||||
break
|
|
||||||
recent = _get_recent_values(t, 60)
|
recent = _get_recent_values(t, 60)
|
||||||
# Determine trend: compare recent avg to older avg
|
# Determine trend: compare recent avg to older avg
|
||||||
trend = None
|
trend = None
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from scrapers import lookintobitcoin
|
||||||
|
from scoring import engine
|
||||||
|
|
||||||
|
|
||||||
|
def test_hash_ribbon_signal_requires_a_current_truthy_marker():
|
||||||
|
named_but_inactive = {"name": "Buy Signal", "y": [1, None, None]}
|
||||||
|
active = {"name": "Buy Signal", "y": [None, 0, 1]}
|
||||||
|
|
||||||
|
assert lookintobitcoin._trace_signal_is_active(named_but_inactive) is False
|
||||||
|
assert lookintobitcoin._trace_signal_is_active(active) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_lth_supply_trace_selection_never_falls_back_to_price():
|
||||||
|
traces = [
|
||||||
|
{"name": "BTC Price", "y": [60000, 61000]},
|
||||||
|
{"name": "Long-Term Holder Supply", "y": [14_000_000, 14_100_000]},
|
||||||
|
]
|
||||||
|
|
||||||
|
assert lookintobitcoin._find_lth_supply_trace(traces)["name"] == "Long-Term Holder Supply"
|
||||||
|
assert lookintobitcoin._find_lth_supply_trace(traces[:1]) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_vdd_derived_return_is_labeled_as_momentum_not_raw_multiple():
|
||||||
|
result = engine.score_all({"vdd_multiple": {"value": 0.12}})
|
||||||
|
vdd = next(metric for metric in result["metrics"] if metric["key"] == "vdd_multiple")
|
||||||
|
|
||||||
|
assert vdd["name"] == "VDD 30-Period Momentum"
|
||||||
|
assert vdd["transform"] == "30_period_return"
|
||||||
Reference in New Issue
Block a user