diff --git a/scoring/engine.py b/scoring/engine.py index 74aa901..42fe548 100644 --- a/scoring/engine.py +++ b/scoring/engine.py @@ -530,8 +530,9 @@ def score_all(metrics): vdd = metrics.get("vdd_multiple", {}) vdd_score, vdd_desc = score_momentum_pct(vdd.get("value")) results.append({ - "name": "VDD Multiple", + "name": "VDD 30-Period Momentum", "key": "vdd_multiple", + "transform": "30_period_return", "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, diff --git a/scrapers/history_collector.py b/scrapers/history_collector.py index 8d9725c..46a3bea 100644 --- a/scrapers/history_collector.py +++ b/scrapers/history_collector.py @@ -114,8 +114,15 @@ def collect_onchain_history(progress_cb=None): for metric_key, trace_name in cfg["traces"].items(): if trace_name is None: - # Grab first trace with numeric data - for candidate in traces: + if metric_key == "lth_supply": + 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", []) if y and any(v is not None for v in y[-10:]): dates, values = _extract_series(candidate) diff --git a/scrapers/lookintobitcoin.py b/scrapers/lookintobitcoin.py index 9ebf617..5706120 100644 --- a/scrapers/lookintobitcoin.py +++ b/scrapers/lookintobitcoin.py @@ -115,6 +115,30 @@ def _find_trace(traces, name): 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): """Get the most recent non-null y value from a trace.""" if not trace: @@ -210,21 +234,16 @@ def scrape_all(): ], "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: 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 break elif metric_key == "lth_supply": - # Get main supply trace - t = traces[0] if traces else None - for candidate in traces: - name = candidate.get("name", "").lower() - if "supply" in name or "lth" in name: - t = candidate - break + # Require an explicitly named LTH supply trace; price is not supply. + t = _find_lth_supply_trace(traces) recent = _get_recent_values(t, 60) # Determine trend: compare recent avg to older avg trend = None diff --git a/tests/test_scraper_semantics.py b/tests/test_scraper_semantics.py new file mode 100644 index 0000000..1db9c78 --- /dev/null +++ b/tests/test_scraper_semantics.py @@ -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"