876 lines
42 KiB
Python
876 lines
42 KiB
Python
|
|
"""
|
|||
|
|
ACBv6 HZ Status, Recency, Frequency & Statistical Integrity Tests
|
|||
|
|
==================================================================
|
|||
|
|
Tests the live operational state of the ACBv6 pipeline:
|
|||
|
|
|
|||
|
|
- HZ connectivity and key presence
|
|||
|
|
- exf_latest update recency (max staleness per indicator)
|
|||
|
|
- ExF daemon push frequency (must be ~0.5 s; verified against push_seq timestamps)
|
|||
|
|
- acb_boost update recency and consistency with exf_latest
|
|||
|
|
- NPZ vs HZ factor value agreement (within expected lag window)
|
|||
|
|
- ACBv6 statistical integrity: known-date regression anchors
|
|||
|
|
- Path auto-resolution (Linux/Windows platform detection)
|
|||
|
|
- Signal integrity: fng confirmation logic, taker thresholds
|
|||
|
|
- Boost formula invariants: monotone, bounded, log_0.5 curve
|
|||
|
|
- Beta invariants: only two legal values (BETA_HIGH / BETA_LOW), except midpoint
|
|||
|
|
- Aggregate stats over full NPZ archive: distribution sanity checks
|
|||
|
|
- Sentinel values detection: all-default responses that indicate broken data path
|
|||
|
|
|
|||
|
|
Run:
|
|||
|
|
source /home/dolphin/siloqy_env/bin/activate
|
|||
|
|
pytest prod/tests/test_acb_hz_status_integrity.py -v -p no:cacheprovider
|
|||
|
|
"""
|
|||
|
|
import sys
|
|||
|
|
import json
|
|||
|
|
import math
|
|||
|
|
import time
|
|||
|
|
import pytest
|
|||
|
|
import numpy as np
|
|||
|
|
from pathlib import Path
|
|||
|
|
from datetime import datetime, timezone, timedelta
|
|||
|
|
from unittest.mock import patch, MagicMock
|
|||
|
|
|
|||
|
|
HCM_DIR = Path(__file__).parent.parent.parent
|
|||
|
|
sys.path.insert(0, str(HCM_DIR / 'nautilus_dolphin'))
|
|||
|
|
sys.path.insert(0, str(HCM_DIR))
|
|||
|
|
|
|||
|
|
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import (
|
|||
|
|
AdaptiveCircuitBreaker, ACBConfig, _STALE_WARN_S, _STALE_FALLBACK_S,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ── Paths & constants ────────────────────────────────────────────────────────────
|
|||
|
|
SCANS_DIR = None
|
|||
|
|
try:
|
|||
|
|
from dolphin_paths import get_eigenvalues_path
|
|||
|
|
_p = get_eigenvalues_path()
|
|||
|
|
if _p.exists():
|
|||
|
|
SCANS_DIR = _p
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
NPZ_AVAILABLE = SCANS_DIR is not None
|
|||
|
|
|
|||
|
|
# All dates in the NPZ archive (sorted)
|
|||
|
|
_NPZ_DATES = []
|
|||
|
|
if NPZ_AVAILABLE:
|
|||
|
|
_NPZ_DATES = sorted(
|
|||
|
|
d.name for d in SCANS_DIR.iterdir()
|
|||
|
|
if d.is_dir() and len(d.name) == 10 and d.name.startswith('20')
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Known ground-truth anchor values (from careful NPZ probe)
|
|||
|
|
KNOWN_ANCHORS = {
|
|||
|
|
'2026-01-13': {'boost': 1.0000, 'signals': 0.0, 'funding_btc': 2.245e-05, 'dvol_btc': 41.69, 'fng': 9.0},
|
|||
|
|
'2026-02-05': {'boost': 1.5493, 'signals': 2.0, 'funding_btc': 9.173e-05, 'dvol_btc': 82.62, 'fng': 9.0},
|
|||
|
|
'2026-02-07': {'boost': 1.6264, 'signals': 2.5, 'funding_btc': -1.518e-04, 'dvol_btc': 59.35, 'fng': 9.0},
|
|||
|
|
'2026-02-26': {'boost': 1.0000, 'signals': 0.5, 'funding_btc': -1.998e-05, 'dvol_btc': 52.19, 'fng': 9.0},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# ── HZ availability ──────────────────────────────────────────────────────────────
|
|||
|
|
HZ_AVAILABLE = False
|
|||
|
|
HZ_CLIENT = None
|
|||
|
|
try:
|
|||
|
|
import hazelcast
|
|||
|
|
_c = hazelcast.HazelcastClient(
|
|||
|
|
cluster_name='dolphin', cluster_members=['localhost:5701'],
|
|||
|
|
connection_timeout=2.0,
|
|||
|
|
)
|
|||
|
|
_c.shutdown()
|
|||
|
|
HZ_AVAILABLE = True
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _hz_client():
|
|||
|
|
"""Create a fresh HZ client (caller must .shutdown())."""
|
|||
|
|
import hazelcast
|
|||
|
|
return hazelcast.HazelcastClient(
|
|||
|
|
cluster_name='dolphin', cluster_members=['localhost:5701'],
|
|||
|
|
connection_timeout=5.0,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _hz_features_map():
|
|||
|
|
"""Return (client, fmap) — caller must client.shutdown()."""
|
|||
|
|
c = _hz_client()
|
|||
|
|
return c, c.get_map('DOLPHIN_FEATURES').blocking()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_exf(fmap):
|
|||
|
|
raw = fmap.get('exf_latest')
|
|||
|
|
return json.loads(raw) if raw else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_acb():
|
|||
|
|
"""Return a fully initialised ACB (path auto-resolved)."""
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
if _NPZ_DATES:
|
|||
|
|
acb.preload_w750(_NPZ_DATES[-60:])
|
|||
|
|
return acb
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 1 — Path auto-resolution (no HZ needed)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class TestPathAutoResolution:
|
|||
|
|
def test_default_init_resolves_valid_path(self):
|
|||
|
|
"""ACB must auto-resolve to an existing path on Linux/Windows."""
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
assert acb.config.EIGENVALUES_PATH.exists(), (
|
|||
|
|
f"EIGENVALUES_PATH {acb.config.EIGENVALUES_PATH} does not exist. "
|
|||
|
|
"Check _LINUX_EIGEN_PATHS or mount the data volume."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_explicit_path_not_overridden(self):
|
|||
|
|
"""If caller supplies a valid path, auto-resolution must not override it."""
|
|||
|
|
cfg = ACBConfig()
|
|||
|
|
if SCANS_DIR:
|
|||
|
|
cfg.EIGENVALUES_PATH = SCANS_DIR
|
|||
|
|
acb = AdaptiveCircuitBreaker(config=cfg)
|
|||
|
|
assert acb.config.EIGENVALUES_PATH == SCANS_DIR
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive")
|
|||
|
|
def test_auto_resolved_path_contains_date_dirs(self):
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
dirs = list(acb.config.EIGENVALUES_PATH.iterdir())
|
|||
|
|
date_dirs = [d for d in dirs if d.is_dir() and len(d.name) == 10]
|
|||
|
|
assert len(date_dirs) >= 10, "Expected at least 10 date directories in eigenvalues/"
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive")
|
|||
|
|
def test_known_anchor_dates_present(self):
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
for ds in KNOWN_ANCHORS:
|
|||
|
|
p = acb.config.EIGENVALUES_PATH / ds
|
|||
|
|
assert p.exists(), f"Anchor date {ds} not found in {acb.config.EIGENVALUES_PATH}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 2 — NPZ archive regression anchors (known values)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive available")
|
|||
|
|
class TestNpzRegressionAnchors:
|
|||
|
|
"""Validate that ACBv6 returns the exact documented gold values from NPZ."""
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope='class')
|
|||
|
|
def acb(self):
|
|||
|
|
return _make_acb()
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("date_str,expected", KNOWN_ANCHORS.items())
|
|||
|
|
def test_boost_anchor(self, acb, date_str, expected):
|
|||
|
|
result = acb.get_dynamic_boost_for_date(date_str)
|
|||
|
|
assert result['boost'] == pytest.approx(expected['boost'], rel=0.01), \
|
|||
|
|
f"{date_str}: boost {result['boost']:.4f} != {expected['boost']:.4f}"
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("date_str,expected", KNOWN_ANCHORS.items())
|
|||
|
|
def test_signals_anchor(self, acb, date_str, expected):
|
|||
|
|
result = acb.get_dynamic_boost_for_date(date_str)
|
|||
|
|
assert result['signals'] == pytest.approx(expected['signals'], abs=0.01), \
|
|||
|
|
f"{date_str}: signals {result['signals']:.2f} != {expected['signals']:.2f}"
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("date_str,expected", KNOWN_ANCHORS.items())
|
|||
|
|
def test_raw_factor_funding(self, acb, date_str, expected):
|
|||
|
|
result = acb.get_dynamic_boost_for_date(date_str)
|
|||
|
|
f = result['factors']
|
|||
|
|
# Funding may differ by up to 10% (median of multiple scans)
|
|||
|
|
assert f['funding_btc'] == pytest.approx(expected['funding_btc'], rel=0.10), \
|
|||
|
|
f"{date_str}: funding_btc {f['funding_btc']:.6g} != {expected['funding_btc']:.6g}"
|
|||
|
|
|
|||
|
|
@pytest.mark.parametrize("date_str,expected", KNOWN_ANCHORS.items())
|
|||
|
|
def test_raw_factor_dvol(self, acb, date_str, expected):
|
|||
|
|
result = acb.get_dynamic_boost_for_date(date_str)
|
|||
|
|
f = result['factors']
|
|||
|
|
assert f['dvol_btc'] == pytest.approx(expected['dvol_btc'], rel=0.05), \
|
|||
|
|
f"{date_str}: dvol_btc {f['dvol_btc']:.2f} != {expected['dvol_btc']:.2f}"
|
|||
|
|
|
|||
|
|
def test_2026_02_05_not_degraded_to_defaults(self, acb):
|
|||
|
|
"""Verify 2026-02-05 does NOT return the all-defaults sentinel (boost=1, signals=0)
|
|||
|
|
when it should return boost=1.5493 (dvol=82.6 extreme)."""
|
|||
|
|
result = acb.get_dynamic_boost_for_date('2026-02-05')
|
|||
|
|
assert result['boost'] > 1.0, (
|
|||
|
|
"2026-02-05 returned boost=1.0 (defaults) — likely broken NPZ path"
|
|||
|
|
)
|
|||
|
|
assert result['factors'].get('available', False), \
|
|||
|
|
"factors['available']=False on 2026-02-05 — NPZ file not read"
|
|||
|
|
|
|||
|
|
def test_2026_02_07_extreme_funding_captured(self, acb):
|
|||
|
|
"""2026-02-07 funding=-0.000152: must trigger VERY_BEARISH (+1.0 signal)."""
|
|||
|
|
result = acb.get_dynamic_boost_for_date('2026-02-07')
|
|||
|
|
funding = result['factors']['funding_btc']
|
|||
|
|
assert funding < ACBConfig.FUNDING_VERY_BEARISH, \
|
|||
|
|
f"2026-02-07 funding={funding:.6g} not < FUNDING_VERY_BEARISH={ACBConfig.FUNDING_VERY_BEARISH}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 3 — Boost formula invariants
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive available")
|
|||
|
|
class TestBoostFormulaInvariants:
|
|||
|
|
"""Mathematical invariants that must hold across all archived dates."""
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope='class')
|
|||
|
|
def all_results(self):
|
|||
|
|
acb = _make_acb()
|
|||
|
|
results = []
|
|||
|
|
for ds in _NPZ_DATES:
|
|||
|
|
try:
|
|||
|
|
results.append((ds, acb.get_dynamic_boost_for_date(ds)))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
def test_boost_always_gte_1(self, all_results):
|
|||
|
|
bad = [(ds, r['boost']) for ds, r in all_results if r['boost'] < 1.0]
|
|||
|
|
assert not bad, f"boost < 1.0 on dates: {bad}"
|
|||
|
|
|
|||
|
|
def test_boost_log05_formula(self, all_results):
|
|||
|
|
"""boost = 1.0 + 0.5*ln(1+signals) when signals >= 1, else 1.0."""
|
|||
|
|
for ds, r in all_results:
|
|||
|
|
sig = r['signals']
|
|||
|
|
if sig >= 1.0:
|
|||
|
|
expected = 1.0 + 0.5 * math.log1p(sig)
|
|||
|
|
assert r['boost'] == pytest.approx(expected, rel=1e-6), \
|
|||
|
|
f"{ds}: boost={r['boost']:.6f} != formula({sig:.2f})={expected:.6f}"
|
|||
|
|
else:
|
|||
|
|
assert r['boost'] == pytest.approx(1.0, rel=1e-9), \
|
|||
|
|
f"{ds}: signals={sig:.2f}<1 but boost={r['boost']:.6f} != 1.0"
|
|||
|
|
|
|||
|
|
def test_boost_monotone_in_signals(self, all_results):
|
|||
|
|
"""Higher signal count must produce higher or equal boost."""
|
|||
|
|
pairs = sorted(all_results, key=lambda x: x[1]['signals'])
|
|||
|
|
for i in range(1, len(pairs)):
|
|||
|
|
ds_prev, r_prev = pairs[i-1]
|
|||
|
|
ds_curr, r_curr = pairs[i]
|
|||
|
|
assert r_curr['boost'] >= r_prev['boost'] - 1e-9, (
|
|||
|
|
f"Boost not monotone: {ds_prev} signals={r_prev['signals']:.2f} "
|
|||
|
|
f"boost={r_prev['boost']:.4f} > {ds_curr} signals={r_curr['signals']:.2f} "
|
|||
|
|
f"boost={r_curr['boost']:.4f}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_boost_upper_bound(self, all_results):
|
|||
|
|
"""With at most ~5 signals, boost <= 1 + 0.5*ln(6) ≈ 1.896."""
|
|||
|
|
max_theoretical = 1.0 + 0.5 * math.log1p(10.0)
|
|||
|
|
bad = [(ds, r['boost']) for ds, r in all_results if r['boost'] > max_theoretical]
|
|||
|
|
assert not bad, f"Implausibly large boost: {bad}"
|
|||
|
|
|
|||
|
|
def test_no_nan_inf_boost(self, all_results):
|
|||
|
|
bad = [(ds, r['boost']) for ds, r in all_results
|
|||
|
|
if not math.isfinite(r['boost'])]
|
|||
|
|
assert not bad, f"NaN/Inf boost: {bad}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 4 — Beta invariants
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive available")
|
|||
|
|
class TestBetaInvariants:
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope='class')
|
|||
|
|
def acb_and_results(self):
|
|||
|
|
acb = _make_acb()
|
|||
|
|
results = [(ds, acb.get_dynamic_boost_for_date(ds)) for ds in _NPZ_DATES]
|
|||
|
|
return acb, results
|
|||
|
|
|
|||
|
|
def test_beta_only_legal_values(self, acb_and_results):
|
|||
|
|
"""Beta must be BETA_HIGH, BETA_LOW, or midpoint (when threshold=None)."""
|
|||
|
|
acb, results = acb_and_results
|
|||
|
|
mid = (ACBConfig.BETA_HIGH + ACBConfig.BETA_LOW) / 2.0
|
|||
|
|
legal = {ACBConfig.BETA_HIGH, ACBConfig.BETA_LOW, mid}
|
|||
|
|
bad = [(ds, r['beta']) for ds, r in results
|
|||
|
|
if not any(abs(r['beta'] - v) < 1e-9 for v in legal)]
|
|||
|
|
assert not bad, f"Illegal beta values (not HIGH/LOW/mid): {bad}"
|
|||
|
|
|
|||
|
|
def test_threshold_computed_when_data_available(self, acb_and_results):
|
|||
|
|
acb, _ = acb_and_results
|
|||
|
|
# Threshold may be 0.0 if w750_vel is always 0 in these files — OK
|
|||
|
|
# but it must be set (not None)
|
|||
|
|
assert acb._w750_threshold is not None, \
|
|||
|
|
"w750_threshold is None after preload_w750() — preload not called?"
|
|||
|
|
|
|||
|
|
def test_beta_matches_w750_gate(self, acb_and_results):
|
|||
|
|
"""For each date, verify beta matches the threshold gate logic."""
|
|||
|
|
acb, results = acb_and_results
|
|||
|
|
if acb._w750_threshold is None:
|
|||
|
|
pytest.skip("w750_threshold not set")
|
|||
|
|
for ds, r in results:
|
|||
|
|
w750 = acb._w750_vel_cache.get(ds, 0.0)
|
|||
|
|
expected_beta = (ACBConfig.BETA_HIGH if w750 >= acb._w750_threshold
|
|||
|
|
else ACBConfig.BETA_LOW)
|
|||
|
|
assert r['beta'] == pytest.approx(expected_beta), \
|
|||
|
|
f"{ds}: w750={w750:.6f} threshold={acb._w750_threshold:.6f} " \
|
|||
|
|
f"expected_beta={expected_beta} got {r['beta']}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 5 — Signal logic integrity
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class TestSignalLogicIntegrity:
|
|||
|
|
"""White-box tests for _calculate_signals() edge cases and thresholds."""
|
|||
|
|
|
|||
|
|
def _sig(self, **kwargs):
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
defaults = dict(funding_btc=0.0, dvol_btc=50.0, fng=50.0,
|
|||
|
|
taker=1.0, fund_dbt_btc=0.0, available=True)
|
|||
|
|
defaults.update(kwargs)
|
|||
|
|
return acb._calculate_signals(defaults)
|
|||
|
|
|
|||
|
|
def test_all_neutral_zero_signals(self):
|
|||
|
|
r = self._sig()
|
|||
|
|
assert r['signals'] == pytest.approx(0.0)
|
|||
|
|
assert r['severity'] == 0
|
|||
|
|
|
|||
|
|
def test_funding_very_bearish_exact_threshold(self):
|
|||
|
|
r_below = self._sig(funding_btc=ACBConfig.FUNDING_VERY_BEARISH - 1e-9)
|
|||
|
|
r_at = self._sig(funding_btc=ACBConfig.FUNDING_VERY_BEARISH)
|
|||
|
|
# strictly below -0.0001 → very bearish (+1.0)
|
|||
|
|
assert r_below['signals'] == pytest.approx(1.0)
|
|||
|
|
# at exactly -0.0001: NOT very bearish (condition is `<`), but IS bearish (< 0) → +0.5
|
|||
|
|
assert r_at['signals'] == pytest.approx(0.5)
|
|||
|
|
|
|||
|
|
def test_funding_slightly_bearish(self):
|
|||
|
|
# Between -0.0001 and 0.0
|
|||
|
|
r = self._sig(funding_btc=-0.00005)
|
|||
|
|
assert r['signals'] == pytest.approx(0.5)
|
|||
|
|
|
|||
|
|
def test_funding_positive_no_signal(self):
|
|||
|
|
r = self._sig(funding_btc=0.0001)
|
|||
|
|
assert r['signals'] == pytest.approx(0.0)
|
|||
|
|
|
|||
|
|
def test_dvol_extreme_threshold(self):
|
|||
|
|
r_above = self._sig(dvol_btc=ACBConfig.DVOL_EXTREME + 1) # > 80 → extreme +1.0
|
|||
|
|
r_at = self._sig(dvol_btc=ACBConfig.DVOL_EXTREME) # = 80 (not > 80)
|
|||
|
|
assert r_above['signals'] == pytest.approx(1.0)
|
|||
|
|
# at exactly 80: NOT extreme (condition is `>`), but IS elevated (> 55) → +0.5
|
|||
|
|
assert r_at['signals'] == pytest.approx(0.5)
|
|||
|
|
|
|||
|
|
def test_dvol_elevated_threshold(self):
|
|||
|
|
r = self._sig(dvol_btc=ACBConfig.DVOL_ELEVATED + 1) # > 55, <= 80
|
|||
|
|
assert r['signals'] == pytest.approx(0.5)
|
|||
|
|
|
|||
|
|
def test_fng_extreme_requires_prior_signal(self):
|
|||
|
|
"""fng < 25 only counts if signals >= 1 at the time of fng check."""
|
|||
|
|
# With dvol extreme (1.0 signal) + fng extreme → total 2.0
|
|||
|
|
r_with_prior = self._sig(dvol_btc=90.0, fng=ACBConfig.FNG_EXTREME_FEAR - 1)
|
|||
|
|
# Without prior signal → fng doesn't count
|
|||
|
|
r_without_prior = self._sig(dvol_btc=50.0, fng=ACBConfig.FNG_EXTREME_FEAR - 1)
|
|||
|
|
assert r_with_prior['signals'] == pytest.approx(2.0)
|
|||
|
|
assert r_without_prior['signals'] == pytest.approx(0.0)
|
|||
|
|
|
|||
|
|
def test_fng_fear_requires_half_signal(self):
|
|||
|
|
"""fng < 40 only counts if signals >= 0.5."""
|
|||
|
|
# Half signal from funding + fng fear → 1.0
|
|||
|
|
r_with = self._sig(funding_btc=-0.00005, fng=35.0)
|
|||
|
|
# No prior signal → no fng
|
|||
|
|
r_without = self._sig(fng=35.0)
|
|||
|
|
assert r_with['signals'] == pytest.approx(1.0)
|
|||
|
|
assert r_without['signals'] == pytest.approx(0.0)
|
|||
|
|
|
|||
|
|
def test_taker_selling_threshold(self):
|
|||
|
|
"""taker < 0.8 = +1.0; 0.8 <= taker < 0.9 = +0.5; >= 0.9 = 0."""
|
|||
|
|
r_strong = self._sig(taker=ACBConfig.TAKER_SELLING - 0.01) # < 0.8
|
|||
|
|
r_mild = self._sig(taker=ACBConfig.TAKER_SELLING + 0.05) # 0.85 ∈ [0.8, 0.9)
|
|||
|
|
r_none = self._sig(taker=ACBConfig.TAKER_MILD_SELLING) # = 0.9 (not < 0.9)
|
|||
|
|
assert r_strong['signals'] == pytest.approx(1.0)
|
|||
|
|
assert r_mild['signals'] == pytest.approx(0.5)
|
|||
|
|
assert r_none['signals'] == pytest.approx(0.0)
|
|||
|
|
|
|||
|
|
def test_fund_dbt_fallback_when_funding_btc_zero(self):
|
|||
|
|
"""fund_dbt_btc is used if funding_btc key not present."""
|
|||
|
|
factors = {'fund_dbt_btc': -0.0002, 'dvol_btc': 50.0, 'fng': 50.0,
|
|||
|
|
'taker': 1.0, 'available': True}
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
r = acb._calculate_signals(factors)
|
|||
|
|
# funding_btc absent → falls back to fund_dbt_btc=-0.0002 < -0.0001
|
|||
|
|
assert r['signals'] == pytest.approx(1.0)
|
|||
|
|
|
|||
|
|
def test_full_stress_max_signals(self):
|
|||
|
|
"""All four indicators at extreme levels → ~4.0 signals."""
|
|||
|
|
r = self._sig(
|
|||
|
|
funding_btc=-0.0002, # very bearish +1.0
|
|||
|
|
dvol_btc=90.0, # extreme +1.0 (now signals=2.0)
|
|||
|
|
fng=20.0, # extreme fear +1.0 (signals>=1, now 3.0)
|
|||
|
|
taker=0.70, # selling +1.0 (now 4.0)
|
|||
|
|
)
|
|||
|
|
assert r['signals'] == pytest.approx(4.0)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 6 — Archive statistics & sentinel detection
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not NPZ_AVAILABLE, reason="No NPZ archive available")
|
|||
|
|
class TestArchiveStatistics:
|
|||
|
|
"""Statistical sanity checks over the full NPZ archive."""
|
|||
|
|
|
|||
|
|
@pytest.fixture(scope='class')
|
|||
|
|
def archive(self):
|
|||
|
|
acb = _make_acb()
|
|||
|
|
results = []
|
|||
|
|
for ds in _NPZ_DATES:
|
|||
|
|
try:
|
|||
|
|
r = acb.get_dynamic_boost_for_date(ds)
|
|||
|
|
results.append((ds, r))
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
return results
|
|||
|
|
|
|||
|
|
def test_no_all_defaults_responses(self, archive):
|
|||
|
|
"""No date should return all-default factors (funding=0, dvol=50, fng=50).
|
|||
|
|
This pattern indicates the NPZ path is broken (Windows path on Linux)."""
|
|||
|
|
all_default = [
|
|||
|
|
ds for ds, r in archive
|
|||
|
|
if (r['factors'].get('funding_btc', 0.0) == 0.0
|
|||
|
|
and r['factors'].get('dvol_btc', 50.0) == 50.0
|
|||
|
|
and r['factors'].get('fng', 50) == 50
|
|||
|
|
and r['factors'].get('available', False) is False)
|
|||
|
|
]
|
|||
|
|
# Allow at most 2 dates with defaults (2026-03-18 has no indicators in npz format)
|
|||
|
|
assert len(all_default) <= 2, (
|
|||
|
|
f"{len(all_default)} dates returned all-default factors: {all_default[:5]}...\n"
|
|||
|
|
"This likely means acb.config.EIGENVALUES_PATH is pointing to a non-existent path."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_factors_available_for_all_good_dates(self, archive):
|
|||
|
|
"""All dates with Indicator NPZ files should have available=True."""
|
|||
|
|
unavailable = [ds for ds, r in archive if not r['factors'].get('available', False)]
|
|||
|
|
# 2026-03-18 has no indicators in the new format
|
|||
|
|
skip = {'2026-03-18'}
|
|||
|
|
bad = [ds for ds in unavailable if ds not in skip]
|
|||
|
|
assert len(bad) <= 3, \
|
|||
|
|
f"factors['available']=False on {len(bad)} dates: {bad[:10]}"
|
|||
|
|
|
|||
|
|
def test_dvol_range_plausible(self, archive):
|
|||
|
|
"""dvol_btc values should be in [20, 200] for all available dates."""
|
|||
|
|
bad = [
|
|||
|
|
(ds, r['factors']['dvol_btc'])
|
|||
|
|
for ds, r in archive
|
|||
|
|
if r['factors'].get('available') and
|
|||
|
|
not (10.0 < r['factors']['dvol_btc'] < 300.0)
|
|||
|
|
]
|
|||
|
|
assert not bad, f"Implausible dvol_btc values: {bad}"
|
|||
|
|
|
|||
|
|
def test_signals_count_distribution(self, archive):
|
|||
|
|
"""Over 40+ dates, at least some dates should have signals > 0."""
|
|||
|
|
with_signals = [(ds, r['signals']) for ds, r in archive if r['signals'] > 0]
|
|||
|
|
assert len(with_signals) >= 5, (
|
|||
|
|
f"Only {len(with_signals)} dates have signals>0. "
|
|||
|
|
f"Expected ≥5 stress days in the archive. "
|
|||
|
|
f"Full distribution: {sorted(set(r['signals'] for _, r in archive))}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_boost_range_plausible(self, archive):
|
|||
|
|
"""Boost values should all be in [1.0, 2.5]."""
|
|||
|
|
bad = [(ds, r['boost']) for ds, r in archive
|
|||
|
|
if not (1.0 <= r['boost'] <= 2.5)]
|
|||
|
|
assert not bad, f"Boost out of expected [1.0, 2.5]: {bad}"
|
|||
|
|
|
|||
|
|
def test_not_all_boost_1(self, archive):
|
|||
|
|
"""Not all dates should return boost=1.0 — that indicates broken data."""
|
|||
|
|
all_one = all(abs(r['boost'] - 1.0) < 1e-9 for _, r in archive)
|
|||
|
|
assert not all_one, (
|
|||
|
|
"ALL dates returned boost=1.0 — this is the broken NPZ path sentinel. "
|
|||
|
|
"Likely cause: acb.config.EIGENVALUES_PATH not set for Linux."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_known_stress_event_captured(self, archive):
|
|||
|
|
"""2026-02-05 (dvol=82.6) must show boost > 1.3 — verifies the path is live."""
|
|||
|
|
for ds, r in archive:
|
|||
|
|
if ds == '2026-02-05':
|
|||
|
|
assert r['boost'] > 1.3, (
|
|||
|
|
f"2026-02-05 boost={r['boost']:.4f}. Expected > 1.3 (dvol=82.6 extreme). "
|
|||
|
|
"NPZ path likely broken."
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
pytest.skip("2026-02-05 not in archive")
|
|||
|
|
|
|||
|
|
def test_fng_frozen_value_warning(self, archive):
|
|||
|
|
"""fng=9.0 on every single date suggests a frozen/stale fng feed.
|
|||
|
|
This is a data quality issue worth flagging but not a hard failure."""
|
|||
|
|
available = [(ds, r) for ds, r in archive if r['factors'].get('available')]
|
|||
|
|
if not available:
|
|||
|
|
pytest.skip("No available factor data")
|
|||
|
|
fng_vals = [r['factors'].get('fng', 50) for _, r in available]
|
|||
|
|
unique_fng = set(fng_vals)
|
|||
|
|
if len(unique_fng) == 1:
|
|||
|
|
pytest.warns(None) # soft warning only
|
|||
|
|
import warnings
|
|||
|
|
warnings.warn(
|
|||
|
|
f"fng is frozen at {list(unique_fng)[0]} for ALL {len(available)} dates. "
|
|||
|
|
"The fng feed may be stale or broken.",
|
|||
|
|
UserWarning
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 7 — HZ connectivity and key health (live, skipped when HZ down)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not HZ_AVAILABLE, reason="HZ not reachable on localhost:5701")
|
|||
|
|
class TestHZConnectivity:
|
|||
|
|
|
|||
|
|
def test_hz_connects(self):
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
try:
|
|||
|
|
assert fmap is not None
|
|||
|
|
finally:
|
|||
|
|
c.shutdown()
|
|||
|
|
|
|||
|
|
def test_features_map_accessible(self):
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
try:
|
|||
|
|
keys = fmap.key_set()
|
|||
|
|
assert isinstance(keys, (set, list, type(keys))) # any iterable
|
|||
|
|
finally:
|
|||
|
|
c.shutdown()
|
|||
|
|
|
|||
|
|
def test_latest_eigen_scan_present(self):
|
|||
|
|
"""FAILURE (not skip) when scan-bridge is down — it must be running."""
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
try:
|
|||
|
|
raw = fmap.get('latest_eigen_scan')
|
|||
|
|
assert raw is not None, \
|
|||
|
|
"latest_eigen_scan not found in HZ. dolphin:scan_bridge is DOWN. " \
|
|||
|
|
"Run: supervisorctl start dolphin:scan_bridge"
|
|||
|
|
data = json.loads(raw)
|
|||
|
|
assert isinstance(data, dict)
|
|||
|
|
finally:
|
|||
|
|
c.shutdown()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 8 — exf_latest recency & update frequency (live)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not HZ_AVAILABLE, reason="HZ not reachable")
|
|||
|
|
class TestExfRecencyAndFrequency:
|
|||
|
|
"""Live ExF daemon tests. Missing exf_latest is a FAILURE — daemon must be running."""
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def exf(self):
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
snap = _get_exf(fmap)
|
|||
|
|
c.shutdown()
|
|||
|
|
assert snap is not None, (
|
|||
|
|
"exf_latest NOT FOUND in HZ. dolphin_data:exf_fetcher is DOWN. "
|
|||
|
|
"Run: supervisorctl -c /mnt/dolphinng5_predict/prod/supervisor/dolphin-supervisord.conf "
|
|||
|
|
"start dolphin_data:exf_fetcher"
|
|||
|
|
)
|
|||
|
|
return snap
|
|||
|
|
|
|||
|
|
def test_exf_pushed_recently(self, exf):
|
|||
|
|
"""exf_latest must be pushed within the last 60 seconds (daemon runs at 0.5s)."""
|
|||
|
|
pushed_at_str = exf.get('_pushed_at')
|
|||
|
|
assert pushed_at_str, "_pushed_at missing from exf_latest payload"
|
|||
|
|
pushed_at = datetime.fromisoformat(pushed_at_str)
|
|||
|
|
if pushed_at.tzinfo is None:
|
|||
|
|
pushed_at = pushed_at.replace(tzinfo=timezone.utc)
|
|||
|
|
age_s = (datetime.now(timezone.utc) - pushed_at).total_seconds()
|
|||
|
|
assert age_s < 60, (
|
|||
|
|
f"exf_latest is {age_s:.0f}s old. Daemon alive but may have stalled. "
|
|||
|
|
f"Expected age < 60s (push every 0.5s)."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_acb_critical_keys_present(self, exf):
|
|||
|
|
"""The five keys used by _calculate_signals() must ALL be present. FAILURE = broken feed."""
|
|||
|
|
required = {'funding_btc', 'dvol_btc', 'fng', 'taker', 'fund_dbt_btc'}
|
|||
|
|
missing = required - set(exf.keys())
|
|||
|
|
assert not missing, (
|
|||
|
|
f"ACB-critical keys MISSING from exf_latest: {missing}. "
|
|||
|
|
f"These indicators are DOWN. Check provider connectivity."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_acb_ready_flag(self, exf):
|
|||
|
|
"""_acb_ready=True means all ACB_KEYS are present. FAILURE = provider outage."""
|
|||
|
|
assert exf.get('_acb_ready') is True, (
|
|||
|
|
f"_acb_ready=False. ok_count={exf.get('_ok_count')}. "
|
|||
|
|
f"Missing ACB keys. Check provider connectivity for funding/dvol/fng/taker."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_staleness_funding_not_stale(self, exf):
|
|||
|
|
"""funding_btc staleness must be < 4h. FAILURE = Binance futures API down."""
|
|||
|
|
stale = float(exf.get('_staleness_s', {}).get('funding_btc', 0))
|
|||
|
|
assert stale < _STALE_WARN_S, (
|
|||
|
|
f"funding_btc staleness={stale:.0f}s > {_STALE_WARN_S}s. "
|
|||
|
|
f"Binance futures funding endpoint may be down or rate-limited."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_staleness_dvol_not_stale(self, exf):
|
|||
|
|
"""dvol_btc staleness must be < 4h. FAILURE = Deribit API down."""
|
|||
|
|
stale = float(exf.get('_staleness_s', {}).get('dvol_btc', 0))
|
|||
|
|
assert stale < _STALE_WARN_S, (
|
|||
|
|
f"dvol_btc staleness={stale:.0f}s > {_STALE_WARN_S}s. "
|
|||
|
|
f"Deribit volatility index endpoint may be down."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_staleness_taker_not_stale(self, exf):
|
|||
|
|
"""taker staleness must be < 4h."""
|
|||
|
|
stale = float(exf.get('_staleness_s', {}).get('taker', 0))
|
|||
|
|
assert stale < _STALE_WARN_S, (
|
|||
|
|
f"taker staleness={stale:.0f}s > {_STALE_WARN_S}s."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_staleness_fng_within_fallback(self, exf):
|
|||
|
|
"""fng updates daily — allow up to 12h before declaring failure."""
|
|||
|
|
fng_stale = float(exf.get('_staleness_s', {}).get('fng', 0))
|
|||
|
|
assert fng_stale < _STALE_FALLBACK_S, (
|
|||
|
|
f"fng staleness={fng_stale:.0f}s > {_STALE_FALLBACK_S}s. "
|
|||
|
|
f"Fear & Greed index provider is completely stale."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_exf_funding_value_plausible(self, exf):
|
|||
|
|
"""funding_btc must be in [-0.01, 0.01]."""
|
|||
|
|
f = float(exf['funding_btc'])
|
|||
|
|
assert -0.01 < f < 0.01, \
|
|||
|
|
f"funding_btc={f} outside [-0.01, 0.01] — looks like bad data"
|
|||
|
|
|
|||
|
|
def test_exf_dvol_value_plausible(self, exf):
|
|||
|
|
"""dvol_btc must be in [10, 300]."""
|
|||
|
|
d = float(exf['dvol_btc'])
|
|||
|
|
assert 10 < d < 300, f"dvol_btc={d} outside [10, 300]"
|
|||
|
|
|
|||
|
|
def test_exf_fng_value_plausible(self, exf):
|
|||
|
|
"""fng is a 0–100 index."""
|
|||
|
|
f = float(exf['fng'])
|
|||
|
|
assert 0 <= f <= 100, f"fng={f} outside [0, 100]"
|
|||
|
|
|
|||
|
|
def test_exf_taker_value_plausible(self, exf):
|
|||
|
|
"""taker ratio is buy/sell; typically in [0.5, 2.0] for BTC."""
|
|||
|
|
t = float(exf['taker'])
|
|||
|
|
assert 0.3 < t < 5.0, f"taker={t} outside plausible range [0.3, 5.0]"
|
|||
|
|
|
|||
|
|
def test_exf_push_frequency(self):
|
|||
|
|
"""ExF daemon must push at ~0.5 s cadence — verify push_seq advances 2s apart."""
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
try:
|
|||
|
|
snap1 = _get_exf(fmap)
|
|||
|
|
assert snap1 is not None, "exf_latest absent — daemon DOWN"
|
|||
|
|
seq1 = snap1.get('_push_seq', 0)
|
|||
|
|
time.sleep(2.2)
|
|||
|
|
snap2 = _get_exf(fmap)
|
|||
|
|
assert snap2 is not None, "exf_latest disappeared during test"
|
|||
|
|
seq2 = snap2.get('_push_seq', 0)
|
|||
|
|
delta_s = (seq2 - seq1) / 1000.0 # push_seq is ms epoch
|
|||
|
|
assert delta_s > 1.0, (
|
|||
|
|
f"push_seq advanced only {delta_s:.2f}s in 2.2s — daemon may have stalled "
|
|||
|
|
f"(seq1={seq1}, seq2={seq2})"
|
|||
|
|
)
|
|||
|
|
finally:
|
|||
|
|
c.shutdown()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 9 — acb_boost HZ key: presence, recency, consistency with exf_latest
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not HZ_AVAILABLE, reason="HZ not reachable")
|
|||
|
|
class TestAcbBoostHzKey:
|
|||
|
|
"""Tests for DOLPHIN_FEATURES['acb_boost']. Missing key is a FAILURE."""
|
|||
|
|
|
|||
|
|
@pytest.fixture
|
|||
|
|
def acb_boost(self):
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
raw = fmap.get('acb_boost')
|
|||
|
|
c.shutdown()
|
|||
|
|
assert raw is not None, (
|
|||
|
|
"acb_boost NOT FOUND in HZ. dolphin_data:acb_processor is DOWN. "
|
|||
|
|
"Run: supervisorctl -c /mnt/dolphinng5_predict/prod/supervisor/dolphin-supervisord.conf "
|
|||
|
|
"start dolphin_data:acb_processor"
|
|||
|
|
)
|
|||
|
|
return json.loads(raw)
|
|||
|
|
|
|||
|
|
def test_acb_boost_schema(self, acb_boost):
|
|||
|
|
required = {'boost', 'signals', 'beta', 'date'}
|
|||
|
|
missing = required - set(acb_boost.keys())
|
|||
|
|
assert not missing, f"acb_boost missing keys: {missing}"
|
|||
|
|
|
|||
|
|
def test_acb_boost_values_plausible(self, acb_boost):
|
|||
|
|
assert 1.0 <= acb_boost['boost'] <= 2.5, f"boost={acb_boost['boost']} out of [1,2.5]"
|
|||
|
|
assert acb_boost['signals'] >= 0.0
|
|||
|
|
legal_betas = [ACBConfig.BETA_HIGH, ACBConfig.BETA_LOW,
|
|||
|
|
(ACBConfig.BETA_HIGH + ACBConfig.BETA_LOW) / 2.0]
|
|||
|
|
assert any(abs(acb_boost['beta'] - b) < 1e-6 for b in legal_betas), \
|
|||
|
|
f"beta={acb_boost['beta']} not in legal values {legal_betas}"
|
|||
|
|
|
|||
|
|
def test_acb_boost_date_is_today_or_recent(self, acb_boost):
|
|||
|
|
"""acb_boost['date'] should be today or yesterday (UTC)."""
|
|||
|
|
from datetime import date
|
|||
|
|
date_str = acb_boost.get('date', '')
|
|||
|
|
if not date_str:
|
|||
|
|
pytest.skip("date key missing from acb_boost")
|
|||
|
|
boost_date = datetime.fromisoformat(date_str).date() if 'T' in date_str \
|
|||
|
|
else datetime.strptime(date_str, '%Y-%m-%d').date()
|
|||
|
|
today = date.today()
|
|||
|
|
delta = (today - boost_date).days
|
|||
|
|
assert delta <= 2, \
|
|||
|
|
f"acb_boost date is {delta} days old ({date_str}). acb_processor_service may be stale."
|
|||
|
|
|
|||
|
|
def test_acb_boost_consistent_with_formula(self, acb_boost):
|
|||
|
|
"""Verify boost matches log_0.5 formula for the reported signal count."""
|
|||
|
|
sig = acb_boost['signals']
|
|||
|
|
expected = 1.0 + 0.5 * math.log1p(sig) if sig >= 1.0 else 1.0
|
|||
|
|
assert acb_boost['boost'] == pytest.approx(expected, rel=0.005), \
|
|||
|
|
f"acb_boost formula mismatch: boost={acb_boost['boost']:.4f} != f({sig:.2f})={expected:.4f}"
|
|||
|
|
|
|||
|
|
def test_acb_boost_hz_source_when_exf_running(self, acb_boost):
|
|||
|
|
"""When ExF daemon is running, acb_boost should be sourced from HZ."""
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
exf = _get_exf(fmap)
|
|||
|
|
c.shutdown()
|
|||
|
|
if exf is None:
|
|||
|
|
pytest.skip("exf_latest absent — ExF daemon not running")
|
|||
|
|
# If ExF is running, acb_boost source should be 'hz'
|
|||
|
|
src = acb_boost.get('source', 'npz')
|
|||
|
|
assert src == 'hz', (
|
|||
|
|
f"acb_boost source='{src}' but exf_latest is present. "
|
|||
|
|
"acb_processor_service may not be using the HZ path."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 10 — NPZ vs HZ factor agreement (when both available)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
@pytest.mark.skipif(not HZ_AVAILABLE or not NPZ_AVAILABLE,
|
|||
|
|
reason="Need both HZ and NPZ archive")
|
|||
|
|
class TestNpzHzFactorAgreement:
|
|||
|
|
"""Cross-validate: live HZ values should agree with today's NPZ values
|
|||
|
|
within the expected lag window (funding lag=5d, dvol lag=1d, etc.)."""
|
|||
|
|
|
|||
|
|
MAX_LAG_DAYS = 5 # Maximum expected lag for any indicator
|
|||
|
|
|
|||
|
|
def _today_npz_factors(self):
|
|||
|
|
from datetime import date
|
|||
|
|
today = date.today().isoformat()
|
|||
|
|
acb = _make_acb()
|
|||
|
|
result = acb.get_dynamic_boost_for_date(today)
|
|||
|
|
if not result['factors'].get('available'):
|
|||
|
|
return None
|
|||
|
|
return result['factors']
|
|||
|
|
|
|||
|
|
def test_funding_btc_within_lag_range(self):
|
|||
|
|
"""Live HZ funding_btc should be similar to a recent NPZ value
|
|||
|
|
(differences may reflect the lag, but magnitude should be same order)."""
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
exf = _get_exf(fmap)
|
|||
|
|
c.shutdown()
|
|||
|
|
if exf is None:
|
|||
|
|
pytest.skip("exf_latest not found")
|
|||
|
|
|
|||
|
|
hz_funding = exf.get('funding_btc')
|
|||
|
|
if hz_funding is None:
|
|||
|
|
pytest.skip("funding_btc not in exf_latest")
|
|||
|
|
|
|||
|
|
# Just check it's in a plausible range — exact match depends on lag
|
|||
|
|
assert -0.01 < float(hz_funding) < 0.01, \
|
|||
|
|
f"HZ funding_btc={hz_funding} implausible"
|
|||
|
|
|
|||
|
|
def test_dvol_btc_within_lag_range(self):
|
|||
|
|
"""dvol_btc from HZ should be in [10, 300]."""
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
exf = _get_exf(fmap)
|
|||
|
|
c.shutdown()
|
|||
|
|
if exf is None:
|
|||
|
|
pytest.skip("exf_latest not found")
|
|||
|
|
|
|||
|
|
hz_dvol = exf.get('dvol_btc')
|
|||
|
|
if hz_dvol is None:
|
|||
|
|
pytest.skip("dvol_btc not in exf_latest")
|
|||
|
|
|
|||
|
|
assert 10 < float(hz_dvol) < 300, f"HZ dvol_btc={hz_dvol} implausible"
|
|||
|
|
|
|||
|
|
def test_acb_hz_boost_vs_npz_recent(self):
|
|||
|
|
"""ACB boost from HZ path vs NPZ path for the most recent archived date
|
|||
|
|
should agree within ±0.5 (they may differ due to different date's factors)."""
|
|||
|
|
if not _NPZ_DATES:
|
|||
|
|
pytest.skip("No NPZ dates")
|
|||
|
|
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
exf = _get_exf(fmap)
|
|||
|
|
c.shutdown()
|
|||
|
|
if exf is None:
|
|||
|
|
pytest.skip("exf_latest not found")
|
|||
|
|
|
|||
|
|
acb = _make_acb()
|
|||
|
|
hz_result = acb.get_dynamic_boost_from_hz('today-check', exf)
|
|||
|
|
hz_boost = hz_result['boost']
|
|||
|
|
|
|||
|
|
recent_date = _NPZ_DATES[-1]
|
|||
|
|
npz_result = acb.get_dynamic_boost_for_date(recent_date)
|
|||
|
|
npz_boost = npz_result['boost']
|
|||
|
|
|
|||
|
|
# This is a loose check — factors may differ (lag, different day)
|
|||
|
|
# but boost should stay in [1.0, 2.5] for both
|
|||
|
|
assert 1.0 <= hz_boost <= 2.5, f"HZ boost {hz_boost} out of range"
|
|||
|
|
assert 1.0 <= npz_boost <= 2.5, f"NPZ boost {npz_boost} out of range"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
# Section 11 — Status report (always runs, prints diagnostic summary)
|
|||
|
|
# ════════════════════════════════════════════════════════════════════════════════
|
|||
|
|
|
|||
|
|
class TestStatusReport:
|
|||
|
|
"""Generates a human-readable diagnostic printout when run with -s."""
|
|||
|
|
|
|||
|
|
def test_print_acb_status_summary(self, capsys):
|
|||
|
|
lines = ["", "=" * 60, "ACBv6 STATUS REPORT", "=" * 60]
|
|||
|
|
|
|||
|
|
# Path
|
|||
|
|
acb = AdaptiveCircuitBreaker()
|
|||
|
|
lines.append(f"NPZ path : {acb.config.EIGENVALUES_PATH}")
|
|||
|
|
lines.append(f"Path exists : {acb.config.EIGENVALUES_PATH.exists()}")
|
|||
|
|
lines.append(f"NPZ dates : {len(_NPZ_DATES)} ({_NPZ_DATES[0] if _NPZ_DATES else 'N/A'} → {_NPZ_DATES[-1] if _NPZ_DATES else 'N/A'})")
|
|||
|
|
|
|||
|
|
# Recent NPZ values
|
|||
|
|
if _NPZ_DATES:
|
|||
|
|
acb_r = _make_acb()
|
|||
|
|
lines.append("\nRecent NPZ ACB values:")
|
|||
|
|
lines.append(f" {'Date':<12} {'boost':>8} {'signals':>8} {'funding_btc':>14} {'dvol_btc':>10} {'fng':>6}")
|
|||
|
|
for ds in _NPZ_DATES[-7:]:
|
|||
|
|
try:
|
|||
|
|
r = acb_r.get_dynamic_boost_for_date(ds)
|
|||
|
|
f = r['factors']
|
|||
|
|
lines.append(
|
|||
|
|
f" {ds:<12} {r['boost']:>8.4f} {r['signals']:>8.2f} "
|
|||
|
|
f"{f.get('funding_btc', 0):>14.7f} {f.get('dvol_btc', 50):>10.2f} "
|
|||
|
|
f"{f.get('fng', 50):>6.1f}"
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
lines.append(f" {ds:<12} ERROR: {e}")
|
|||
|
|
|
|||
|
|
# HZ status
|
|||
|
|
lines.append(f"\nHZ reachable: {HZ_AVAILABLE}")
|
|||
|
|
if HZ_AVAILABLE:
|
|||
|
|
try:
|
|||
|
|
c, fmap = _hz_features_map()
|
|||
|
|
for key in ('exf_latest', 'acb_boost', 'latest_eigen_scan'):
|
|||
|
|
raw = fmap.get(key)
|
|||
|
|
if raw:
|
|||
|
|
d = json.loads(raw)
|
|||
|
|
pushed = d.get('_pushed_at', 'no timestamp')
|
|||
|
|
lines.append(f" {key:<22}: PRESENT (pushed={pushed})")
|
|||
|
|
if key == 'exf_latest':
|
|||
|
|
lines.append(f" funding_btc={d.get('funding_btc')} "
|
|||
|
|
f"dvol_btc={d.get('dvol_btc')} "
|
|||
|
|
f"fng={d.get('fng')} "
|
|||
|
|
f"_acb_ready={d.get('_acb_ready')}")
|
|||
|
|
lines.append(f" staleness_s: {d.get('_staleness_s', {})}")
|
|||
|
|
elif key == 'acb_boost':
|
|||
|
|
lines.append(f" boost={d.get('boost')} signals={d.get('signals')} "
|
|||
|
|
f"beta={d.get('beta')} source={d.get('source','npz')}")
|
|||
|
|
else:
|
|||
|
|
lines.append(f" {key:<22}: NOT FOUND")
|
|||
|
|
c.shutdown()
|
|||
|
|
except Exception as e:
|
|||
|
|
lines.append(f" HZ read error: {e}")
|
|||
|
|
|
|||
|
|
lines.append("=" * 60)
|
|||
|
|
with capsys.disabled():
|
|||
|
|
print('\n'.join(lines))
|
|||
|
|
|
|||
|
|
# Always pass — this is a diagnostic test
|
|||
|
|
assert True
|