""" 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