Includes core prod + GREEN/BLUE subsystems: - prod/ (BLUE harness, configs, scripts, docs) - nautilus_dolphin/ (GREEN Nautilus-native impl + dvae/ preserved) - adaptive_exit/ (AEM engine + models/bucket_assignments.pkl) - Observability/ (EsoF advisor, TUI, dashboards) - external_factors/ (EsoF producer) - mc_forewarning_qlabs_fork/ (MC regime/envelope) Excludes runtime caches, logs, backups, and reproducible artifacts per .gitignore.
876 lines
42 KiB
Python
Executable File
876 lines
42 KiB
Python
Executable File
"""
|
||
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
|