Files
DOLPHIN/prod/tests/test_acb_hz_integration.py

728 lines
35 KiB
Python
Raw Normal View History

"""
ACBv6 HZ Integration Tests
===========================
Tests for get_dynamic_boost_from_hz() and _load_external_factors_from_snapshot()
in AdaptiveCircuitBreaker.
Covers:
- Unit: snapshot parsing correct factor extraction
- Unit: boost / signal computation from snapshot
- Unit: staleness guard (warn vs fallback)
- Unit: lag NOT re-applied (HZ values pass through unchanged)
- Parity: HZ path == NPZ path when fed same factor values
- Regression: known ACBv6 ground-truth dates (2026-01-13, 2026-02-05, 2026-02-07)
- w750 live injection overrides NPZ-cached value
- OB Sub-4 regime modulation preserved on HZ path
- Cache pre-warm: engine get_dynamic_boost_for_date() sees HZ result (no disk I/O)
- E2E: live HZ ping (skipped when HZ unavailable)
Usage:
source /home/dolphin/siloqy_env/bin/activate
pytest prod/tests/test_acb_hz_integration.py -v
"""
import sys
import json
import math
import time
import pytest
from pathlib import Path
from unittest.mock import MagicMock, patch
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
)
# ── Fixture helpers ──────────────────────────────────────────────────────────────
def _make_snapshot(
funding_btc=0.0001, # mild positive — no signal
dvol_btc=50.0, # below DVOL_ELEVATED — no signal
fng=50.0, # neutral
taker=1.0, # neutral
fund_dbt_btc=0.0,
acb_ready=True,
staleness_s: dict | None = None,
) -> dict:
"""Build a minimal exf_latest-style snapshot dict."""
snap = {
'funding_btc': funding_btc,
'dvol_btc': dvol_btc,
'fng': fng,
'taker': taker,
'fund_dbt_btc': fund_dbt_btc,
'_acb_ready': acb_ready,
'_pushed_at': '2026-02-05T12:00:00+00:00',
'_staleness_s': staleness_s if staleness_s is not None else {
'funding_btc': 30.0,
'dvol_btc': 45.0,
'fng': 3600.0,
'taker': 60.0,
},
}
return snap
def _make_acb_with_threshold(threshold=0.001) -> AdaptiveCircuitBreaker:
"""Return an ACB whose w750 threshold is manually pre-set."""
acb = AdaptiveCircuitBreaker()
acb._w750_threshold = threshold
return acb
# ── Ground truth from live NPZ probe (2026-01-13 to 2026-02-07) ─────────────────
# These values were computed by running get_dynamic_boost_for_date() against the
# gold NG6 NPZ archive and recorded as regression anchors.
GROUND_TRUTH = {
'2026-01-13': {'boost': 1.0, 'signals': 0.0, 'beta_if_high': 0.8, 'beta_if_low': 0.2},
'2026-02-05': {'boost': 1.5493, 'signals': 2.0, 'beta_if_high': 0.8, 'beta_if_low': 0.2},
'2026-02-07': {'boost': 1.6264, 'signals': 2.5, 'beta_if_high': 0.8, 'beta_if_low': 0.2},
}
# Factor values that reproduce the ground-truth signals (used for parity tests)
GT_SNAPSHOTS = {
'2026-01-13': _make_snapshot(funding_btc=0.0001, dvol_btc=50.0, fng=50.0, taker=1.0),
# 2026-02-05: dvol=82.6 (extreme), funding very bearish → signals=2.0
# fng=45 (neutral, >= FNG_FEAR=40) ensures fng does NOT fire, keeping total at 2.0
'2026-02-05': _make_snapshot(funding_btc=-0.00015, dvol_btc=82.6, fng=45.0, taker=0.95),
# 2026-02-07: funding very bearish, dvol=59.4 (elevated), fng=9 (extreme fear) → signals=2.5
'2026-02-07': _make_snapshot(funding_btc=-0.00015, dvol_btc=59.4, fng=9.0, taker=0.95),
}
# ════════════════════════════════════════════════════════════════════════════════
# Section 1 — Unit: _load_external_factors_from_snapshot
# ════════════════════════════════════════════════════════════════════════════════
class TestLoadFactorsFromSnapshot:
def test_basic_extraction(self):
snap = _make_snapshot(funding_btc=-0.0002, dvol_btc=85.0, fng=20.0, taker=0.75)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['funding_btc'] == pytest.approx(-0.0002)
assert factors['dvol_btc'] == pytest.approx(85.0)
assert factors['fng'] == pytest.approx(20.0)
assert factors['taker'] == pytest.approx(0.75)
assert factors['source'] == 'hz'
assert factors['available'] is True
def test_defaults_on_missing_keys(self):
"""Empty snapshot should produce safe neutral defaults."""
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot({})
assert factors['funding_btc'] == pytest.approx(0.0)
assert factors['dvol_btc'] == pytest.approx(50.0)
assert factors['fng'] == pytest.approx(50.0)
assert factors['taker'] == pytest.approx(1.0)
assert factors['available'] is False
def test_max_staleness_computed(self):
snap = _make_snapshot(staleness_s={
'funding_btc': 100.0,
'dvol_btc': 200.0,
'fng': 14500.0, # > 4 h — most stale
'taker': 50.0,
})
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['max_staleness_s'] == pytest.approx(14500.0)
def test_no_lag_reapplied(self):
"""Values must pass through exactly as-is; no transformation applied."""
raw_funding = -0.000123456
snap = _make_snapshot(funding_btc=raw_funding)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
# If lag were being re-applied, the value would differ (shifted by a day)
assert factors['funding_btc'] == pytest.approx(raw_funding, rel=1e-9)
# ════════════════════════════════════════════════════════════════════════════════
# Section 2 — Unit: get_dynamic_boost_from_hz — signals & boost
# ════════════════════════════════════════════════════════════════════════════════
class TestGetDynamicBoostFromHz:
def test_no_signals_gives_boost_1(self):
acb = _make_acb_with_threshold(threshold=0.001)
snap = _make_snapshot() # neutral values
result = acb.get_dynamic_boost_from_hz('2026-01-13', snap)
assert result['signals'] == pytest.approx(0.0)
assert result['boost'] == pytest.approx(1.0)
assert result['source'] == 'hz'
def test_dvol_extreme_funding_bearish_gives_2_signals(self):
"""dvol > 80 (extreme) + funding < -0.0001 (very bearish) = 2.0 signals."""
acb = _make_acb_with_threshold(threshold=0.001)
snap = _make_snapshot(dvol_btc=85.0, funding_btc=-0.0002)
result = acb.get_dynamic_boost_from_hz('2026-02-05', snap)
assert result['signals'] == pytest.approx(2.0)
expected_boost = 1.0 + 0.5 * math.log1p(2.0)
assert result['boost'] == pytest.approx(expected_boost, rel=1e-6)
def test_full_stress_scenario(self):
"""All four indicators firing at extreme levels."""
acb = _make_acb_with_threshold(threshold=0.001)
snap = _make_snapshot(
funding_btc=-0.0002, # very bearish (+1.0 sig)
dvol_btc=85.0, # extreme (+1.0 sig)
fng=20.0, # extreme fear (+1.0 sig, confirmed by 2 prior)
taker=0.75, # selling (+1.0 sig)
)
result = acb.get_dynamic_boost_from_hz('2026-02-06', snap)
assert result['signals'] == pytest.approx(4.0)
expected = 1.0 + 0.5 * math.log1p(4.0)
assert result['boost'] == pytest.approx(expected, rel=1e-6)
def test_result_schema_complete(self):
acb = _make_acb_with_threshold(threshold=0.001)
snap = _make_snapshot()
result = acb.get_dynamic_boost_from_hz('2026-01-15', snap)
required_keys = {
'boost', 'beta', 'signals', 'severity', 'factors',
'cut', 'w750_vel', 'w750_threshold', 'ob_regime',
'ob_depth_velocity', 'ob_cascade_count', 'date',
'config_used', 'source', 'max_staleness_s',
}
assert required_keys <= result.keys()
def test_cut_always_zero(self):
"""Inverse ACB — no cut, only boost."""
acb = _make_acb_with_threshold()
snap = _make_snapshot(dvol_btc=90.0, funding_btc=-0.0005)
result = acb.get_dynamic_boost_from_hz('2026-02-10', snap)
assert result['cut'] == pytest.approx(0.0)
def test_config_used_v6(self):
acb = AdaptiveCircuitBreaker()
result = acb.get_dynamic_boost_from_hz('2026-01-20', _make_snapshot())
assert result['config_used'] == 'v6'
# ════════════════════════════════════════════════════════════════════════════════
# Section 3 — Unit: staleness guard
# ════════════════════════════════════════════════════════════════════════════════
class TestStalenessGuard:
def test_fresh_data_no_error(self):
acb = _make_acb_with_threshold()
snap = _make_snapshot(staleness_s={'funding_btc': 30, 'dvol_btc': 45,
'fng': 300, 'taker': 10})
result = acb.get_dynamic_boost_from_hz('2026-02-01', snap)
assert result['max_staleness_s'] < _STALE_WARN_S
def test_stale_warn_threshold_still_passes(self):
"""4 h < staleness < 12 h: method succeeds but max_staleness_s is recorded."""
stale_s = _STALE_WARN_S + 100 # just over 4 h, well under 12 h
acb = _make_acb_with_threshold()
snap = _make_snapshot(staleness_s={
'funding_btc': stale_s, 'dvol_btc': 30, 'fng': 100, 'taker': 20
})
result = acb.get_dynamic_boost_from_hz('2026-02-02', snap)
assert result['max_staleness_s'] == pytest.approx(stale_s)
def test_stale_fallback_raises(self):
"""Staleness > 12 h must raise ValueError for caller to fall back."""
stale_s = _STALE_FALLBACK_S + 60
acb = _make_acb_with_threshold()
snap = _make_snapshot(staleness_s={
'funding_btc': stale_s, 'dvol_btc': 30, 'fng': 100, 'taker': 20
})
with pytest.raises(ValueError, match="stale"):
acb.get_dynamic_boost_from_hz('2026-02-03', snap)
def test_empty_staleness_dict_no_error(self):
"""Missing _staleness_s treated as 0 — should not raise."""
snap = _make_snapshot(staleness_s={})
acb = _make_acb_with_threshold()
result = acb.get_dynamic_boost_from_hz('2026-01-10', snap)
assert result['max_staleness_s'] == pytest.approx(0.0)
# ════════════════════════════════════════════════════════════════════════════════
# Section 4 — Unit: w750 live injection
# ════════════════════════════════════════════════════════════════════════════════
class TestW750Injection:
def test_live_w750_overrides_cached_value(self):
acb = _make_acb_with_threshold(threshold=0.005)
date_str = '2026-02-05'
# Pre-seed NPZ cache with a low value (would give beta_low)
acb._w750_vel_cache[date_str] = 0.001 # below threshold
snap = _make_snapshot()
# Pass live w750 above threshold → should give beta_high
result = acb.get_dynamic_boost_from_hz(date_str, snap, w750_velocity=0.010)
assert acb._w750_vel_cache[date_str] == pytest.approx(0.010)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH)
def test_no_live_w750_uses_cached(self):
acb = _make_acb_with_threshold(threshold=0.005)
date_str = '2026-02-06'
acb._w750_vel_cache[date_str] = 0.010 # above threshold → beta_high
snap = _make_snapshot()
result = acb.get_dynamic_boost_from_hz(date_str, snap, w750_velocity=None)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH)
def test_no_threshold_gives_midpoint_beta(self):
"""Without preload_w750(), threshold is None → midpoint beta returned."""
acb = AdaptiveCircuitBreaker()
assert acb._w750_threshold is None
result = acb.get_dynamic_boost_from_hz('2026-01-05', _make_snapshot())
expected_mid = (ACBConfig.BETA_HIGH + ACBConfig.BETA_LOW) / 2.0
assert result['beta'] == pytest.approx(expected_mid)
def test_w750_below_threshold_gives_beta_low(self):
acb = _make_acb_with_threshold(threshold=0.010)
result = acb.get_dynamic_boost_from_hz(
'2026-02-08', _make_snapshot(), w750_velocity=0.002
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_LOW)
def test_w750_above_threshold_gives_beta_high(self):
acb = _make_acb_with_threshold(threshold=0.002)
result = acb.get_dynamic_boost_from_hz(
'2026-02-09', _make_snapshot(), w750_velocity=0.010
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH)
# ════════════════════════════════════════════════════════════════════════════════
# Section 5 — Unit: OB Sub-4 regime modulation
# ════════════════════════════════════════════════════════════════════════════════
class TestOBRegimeModulation:
def _make_ob_engine(self, regime_signal):
ob_macro = MagicMock()
ob_macro.regime_signal = regime_signal
ob_macro.depth_velocity = 0.05
ob_macro.cascade_count = 1
ob_engine = MagicMock()
ob_engine.get_macro.return_value = ob_macro
return ob_engine
def test_stress_regime_increases_beta(self):
acb = _make_acb_with_threshold(threshold=0.001)
# Set up so beta would be BETA_HIGH (0.8) without OB
acb._w750_vel_cache['2026-02-05'] = 0.010
ob_engine = self._make_ob_engine(regime_signal=1)
result = acb.get_dynamic_boost_from_hz(
'2026-02-05', _make_snapshot(), w750_velocity=0.010,
ob_engine=ob_engine
)
# BETA_HIGH=0.8 * 1.25 = 1.0 (capped at 1.0)
assert result['beta'] == pytest.approx(min(1.0, ACBConfig.BETA_HIGH * 1.25))
assert result['ob_regime'] == 1
def test_calm_regime_reduces_beta(self):
acb = _make_acb_with_threshold(threshold=0.001)
acb._w750_vel_cache['2026-02-05'] = 0.010
ob_engine = self._make_ob_engine(regime_signal=-1)
result = acb.get_dynamic_boost_from_hz(
'2026-02-05', _make_snapshot(), w750_velocity=0.010,
ob_engine=ob_engine
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH * 0.85)
assert result['ob_regime'] == -1
def test_neutral_regime_no_change(self):
acb = _make_acb_with_threshold(threshold=0.001)
acb._w750_vel_cache['2026-02-05'] = 0.010
ob_engine = self._make_ob_engine(regime_signal=0)
result = acb.get_dynamic_boost_from_hz(
'2026-02-05', _make_snapshot(), w750_velocity=0.010,
ob_engine=ob_engine
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH)
assert result['ob_regime'] == 0
def test_no_ob_engine_sets_zero_regime(self):
acb = _make_acb_with_threshold()
result = acb.get_dynamic_boost_from_hz('2026-02-05', _make_snapshot())
assert result['ob_regime'] == 0
assert result['ob_depth_velocity'] == pytest.approx(0.0)
assert result['ob_cascade_count'] == 0
# ════════════════════════════════════════════════════════════════════════════════
# Section 6 — Cache pre-warm: engine path uses HZ result without disk I/O
# ════════════════════════════════════════════════════════════════════════════════
class TestCachePreWarm:
def test_hz_result_cached_for_npz_path(self):
"""After get_dynamic_boost_from_hz(), get_dynamic_boost_for_date() returns
the same result (cache hit, no NPZ disk read)."""
acb = _make_acb_with_threshold(threshold=0.001)
snap = _make_snapshot(dvol_btc=85.0, funding_btc=-0.0002)
date_str = '2026-02-05'
hz_result = acb.get_dynamic_boost_from_hz(date_str, snap, w750_velocity=0.010)
# Now simulate what the engine does internally
with patch.object(acb, '_load_external_factors', side_effect=AssertionError(
"_load_external_factors must NOT be called after HZ pre-warm"
)):
# get_cut_for_date() will hit the cache (populated by get_dynamic_boost_from_hz)
# rather than calling _load_external_factors()
cached = acb.get_cut_for_date(date_str)
assert cached['signals'] == pytest.approx(hz_result['signals'])
def test_cache_key_is_date_string(self):
acb = _make_acb_with_threshold()
date_str = '2026-01-20'
acb.get_dynamic_boost_from_hz(date_str, _make_snapshot())
assert date_str in acb._cache
def test_second_call_npz_path_hits_cache(self):
"""get_dynamic_boost_for_date() called after HZ pre-warm returns HZ result."""
acb = _make_acb_with_threshold(threshold=0.001)
date_str = '2026-02-05'
snap = _make_snapshot(dvol_btc=85.0, funding_btc=-0.0002)
acb.get_dynamic_boost_from_hz(date_str, snap, w750_velocity=0.010)
# get_dynamic_boost_for_date() calls get_boost_for_date() → get_cut_for_date()
# get_cut_for_date() finds the cache hit; no disk access occurs.
with patch.object(acb, '_load_external_factors', side_effect=RuntimeError("DISK")):
result = acb.get_dynamic_boost_for_date(date_str)
assert result['signals'] == pytest.approx(2.0)
# ════════════════════════════════════════════════════════════════════════════════
# Section 7 — Parity: HZ path == NPZ path for identical factor values
# ════════════════════════════════════════════════════════════════════════════════
class TestNpzHzParity:
"""Verify HZ path produces the same boost/signals as NPZ path when fed identical
factor values. This ensures the computation is equivalent regardless of source."""
def _npz_result_from_factors(self, factors: dict, date_str: str, threshold=0.001) -> dict:
"""Simulate NPZ path by injecting factors directly (bypassing disk)."""
acb = _make_acb_with_threshold(threshold=threshold)
with patch.object(acb, '_load_external_factors', return_value=factors):
return acb.get_dynamic_boost_for_date(date_str)
def _hz_result(self, factors: dict, date_str: str, threshold=0.001) -> dict:
snap = {
'funding_btc': factors.get('funding_btc', 0.0),
'dvol_btc': factors.get('dvol_btc', 50.0),
'fng': factors.get('fng', 50.0),
'taker': factors.get('taker', 1.0),
'fund_dbt_btc':factors.get('fund_dbt_btc', 0.0),
'_acb_ready': True,
'_staleness_s': {'funding_btc': 30, 'dvol_btc': 30, 'fng': 30, 'taker': 30},
}
acb = _make_acb_with_threshold(threshold=threshold)
return acb.get_dynamic_boost_from_hz(date_str, snap)
def test_parity_no_signals(self):
factors = {'funding_btc': 0.0001, 'dvol_btc': 50.0, 'fng': 50.0, 'taker': 1.0, 'available': True}
npz = self._npz_result_from_factors(factors, '2026-01-10')
hz = self._hz_result(factors, '2026-01-10')
assert hz['signals'] == pytest.approx(npz['signals'])
assert hz['boost'] == pytest.approx(npz['boost'])
def test_parity_2_signals(self):
factors = {'funding_btc': -0.00015, 'dvol_btc': 82.6, 'fng': 30.0, 'taker': 0.95, 'available': True}
npz = self._npz_result_from_factors(factors, '2026-02-05')
hz = self._hz_result(factors, '2026-02-05')
assert hz['signals'] == pytest.approx(npz['signals'])
assert hz['boost'] == pytest.approx(npz['boost'], rel=1e-6)
def test_parity_2pt5_signals(self):
factors = {'funding_btc': -0.00015, 'dvol_btc': 59.4, 'fng': 9.0, 'taker': 0.95, 'available': True}
npz = self._npz_result_from_factors(factors, '2026-02-07')
hz = self._hz_result(factors, '2026-02-07')
assert hz['signals'] == pytest.approx(npz['signals'])
assert hz['boost'] == pytest.approx(npz['boost'], rel=1e-6)
def test_parity_full_stress(self):
factors = {'funding_btc': -0.0002, 'dvol_btc': 88.0, 'fng': 15.0, 'taker': 0.70, 'available': True}
npz = self._npz_result_from_factors(factors, '2026-02-10')
hz = self._hz_result(factors, '2026-02-10')
assert hz['signals'] == pytest.approx(npz['signals'])
assert hz['boost'] == pytest.approx(npz['boost'], rel=1e-6)
# ════════════════════════════════════════════════════════════════════════════════
# Section 8 — Regression against known ACBv6 ground-truth values
# ════════════════════════════════════════════════════════════════════════════════
class TestRegressionGroundTruth:
"""Compare HZ path output against manually probed NPZ values.
Ground truth source: full NPZ scan of /mnt/ng6_data/eigenvalues/
using get_dynamic_boost_for_date() on each date.
The HZ snapshots in GT_SNAPSHOTS are synthetic but constructed to reproduce
the same factor values measured from those dates' NPZ files.
"""
@pytest.mark.parametrize("date_str, expected", [
('2026-01-13', {'boost': 1.0, 'signals': 0.0}),
('2026-02-05', {'boost': 1.5493, 'signals': 2.0}),
('2026-02-07', {'boost': 1.6264, 'signals': 2.5}),
])
def test_boost_matches_ground_truth(self, date_str, expected):
acb = _make_acb_with_threshold(threshold=0.001)
snap = GT_SNAPSHOTS[date_str]
result = acb.get_dynamic_boost_from_hz(date_str, snap)
assert result['signals'] == pytest.approx(expected['signals'], abs=0.01), \
f"{date_str}: signals={result['signals']} != {expected['signals']}"
assert result['boost'] == pytest.approx(expected['boost'], rel=0.01), \
f"{date_str}: boost={result['boost']:.4f} != {expected['boost']:.4f}"
def test_beta_high_when_above_threshold(self):
"""With w750 above threshold, beta must be BETA_HIGH=0.8."""
acb = _make_acb_with_threshold(threshold=0.001)
result = acb.get_dynamic_boost_from_hz(
'2026-02-05', GT_SNAPSHOTS['2026-02-05'], w750_velocity=0.005
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_HIGH)
def test_beta_low_when_below_threshold(self):
acb = _make_acb_with_threshold(threshold=0.010)
result = acb.get_dynamic_boost_from_hz(
'2026-02-05', GT_SNAPSHOTS['2026-02-05'], w750_velocity=0.001
)
assert result['beta'] == pytest.approx(ACBConfig.BETA_LOW)
# ════════════════════════════════════════════════════════════════════════════════
# Section 9 — Delay preservation (lag not re-applied)
# ════════════════════════════════════════════════════════════════════════════════
class TestDelayPreservation:
"""Confirm that the HZ path does not re-apply any lag to indicator values.
The ExF service applies lag before pushing to HZ. The design is:
- funding_btc lag=5 days (Binance funding 8h rate)
- dvol_btc lag=1 day
- fng lag=5 days
- taker lag=1 day
If the ACB were to re-apply lag, it would effectively double-delay the
indicators, producing completely different signals than the gold backtest.
We verify this by checking that the extracted factor values match the
snapshot values EXACTLY no arithmetic transformation applied.
"""
def test_funding_passes_through_unchanged(self):
sentinel = -0.000111222333 # distinctive value
snap = _make_snapshot(funding_btc=sentinel)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['funding_btc'] == pytest.approx(sentinel, rel=1e-9), \
"funding_btc must not be transformed (lag already applied by ExF service)"
def test_dvol_passes_through_unchanged(self):
sentinel = 73.456789
snap = _make_snapshot(dvol_btc=sentinel)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['dvol_btc'] == pytest.approx(sentinel, rel=1e-9)
def test_fng_passes_through_unchanged(self):
sentinel = 17.0
snap = _make_snapshot(fng=sentinel)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['fng'] == pytest.approx(sentinel, rel=1e-9)
def test_taker_passes_through_unchanged(self):
sentinel = 0.83456
snap = _make_snapshot(taker=sentinel)
acb = AdaptiveCircuitBreaker()
factors = acb._load_external_factors_from_snapshot(snap)
assert factors['taker'] == pytest.approx(sentinel, rel=1e-9)
# ════════════════════════════════════════════════════════════════════════════════
# Section 10 — E2E: live HZ ping (skipped when HZ unavailable)
# ════════════════════════════════════════════════════════════════════════════════
HZ_AVAILABLE = False
try:
import hazelcast as _hz
_c = _hz.HazelcastClient(
cluster_name='dolphin',
cluster_members=['localhost:5701'],
connection_timeout=2.0,
)
_c.shutdown()
HZ_AVAILABLE = True
except Exception:
pass
@pytest.mark.skipif(not HZ_AVAILABLE, reason="Hazelcast not reachable — skipping live E2E test")
class TestLiveHzE2E:
"""Live integration test — only runs when Hazelcast is accessible on localhost:5701."""
def _get_hz_features(self):
import hazelcast
client = hazelcast.HazelcastClient(
cluster_name='dolphin',
cluster_members=['localhost:5701'],
connection_timeout=5.0,
)
try:
fmap = client.get_map('DOLPHIN_FEATURES').blocking()
exf_raw = fmap.get('exf_latest')
scan_raw = fmap.get('latest_eigen_scan')
return (
json.loads(exf_raw) if exf_raw else None,
json.loads(scan_raw) if scan_raw else None,
)
finally:
client.shutdown()
def test_exf_latest_present_and_parseable(self):
"""FAILURE (not skip) — exf daemon must be running."""
exf_snap, _ = self._get_hz_features()
assert exf_snap is not None, \
"exf_latest NOT FOUND — dolphin_data:exf_fetcher is DOWN"
assert isinstance(exf_snap.get('funding_btc'), (int, float))
assert isinstance(exf_snap.get('dvol_btc'), (int, float))
def test_acb_computes_from_live_hz(self):
from datetime import date
exf_snap, scan_snap = self._get_hz_features()
assert exf_snap is not None, "exf_latest NOT FOUND — daemon DOWN"
today = date.today().isoformat()
acb = AdaptiveCircuitBreaker()
# Minimal preload (no history needed for this test)
acb._w750_threshold = 0.001
w750_live = scan_snap.get('w750_velocity') if scan_snap else None
result = acb.get_dynamic_boost_from_hz(today, exf_snap, w750_velocity=w750_live)
assert result['source'] == 'hz'
assert result['boost'] >= 1.0
assert result['beta'] in (ACBConfig.BETA_HIGH, ACBConfig.BETA_LOW,
(ACBConfig.BETA_HIGH + ACBConfig.BETA_LOW) / 2.0)
assert result['signals'] >= 0.0
print(f"\n[E2E] Live ACB: boost={result['boost']:.4f} signals={result['signals']:.1f} "
f"beta={result['beta']:.2f} staleness={result['max_staleness_s']:.0f}s")
def test_stale_exf_triggers_fallback_path(self):
"""Manually inject a stale timestamp and verify ValueError is raised."""
acb = AdaptiveCircuitBreaker()
acb._w750_threshold = 0.001
# Build a snapshot with extremely stale indicators
stale_snap = _make_snapshot(staleness_s={
'funding_btc': _STALE_FALLBACK_S + 100,
'dvol_btc': 30, 'fng': 100, 'taker': 20
})
with pytest.raises(ValueError):
acb.get_dynamic_boost_from_hz('2026-02-01', stale_snap)
# ════════════════════════════════════════════════════════════════════════════════
# Section 11 — acb_processor_service HZ path (unit, no real HZ needed)
# ════════════════════════════════════════════════════════════════════════════════
class TestACBProcessorServiceHzPath:
"""Unit tests for acb_processor_service.process_and_write() HZ preference logic."""
def _make_service(self, imap_data: dict):
"""Build an ACBProcessorService with mocked HZ imap."""
sys.path.insert(0, str(HCM_DIR / 'prod'))
from acb_processor_service import ACBProcessorService
# Patch hazelcast.HazelcastClient so no real connection is made
mock_imap = MagicMock()
mock_imap.get.side_effect = lambda key: (
json.dumps(imap_data[key]) if key in imap_data else None
)
written = {}
mock_imap.put.side_effect = lambda k, v: written.update({k: v})
mock_lock = MagicMock()
mock_cp = MagicMock()
mock_cp.get_lock.return_value.blocking.return_value = mock_lock
mock_hz = MagicMock()
mock_hz.get_map.return_value.blocking.return_value = mock_imap
mock_hz.cp_subsystem = mock_cp
with patch('hazelcast.HazelcastClient', return_value=mock_hz):
svc = ACBProcessorService.__new__(ACBProcessorService)
svc.hz_client = mock_hz
svc.imap = mock_imap
svc.lock = mock_lock
svc.acb = AdaptiveCircuitBreaker()
svc.acb._w750_threshold = 0.001
svc.last_scan_count = 0
svc.last_date = None
return svc, written
def test_hz_path_used_when_exf_available(self):
exf_snap = _make_snapshot(dvol_btc=85.0, funding_btc=-0.0002)
svc, written = self._make_service({'exf_latest': exf_snap})
svc.process_and_write('2026-02-05')
assert 'acb_boost' in written
result = json.loads(written['acb_boost'])
assert result['source'] == 'hz'
assert result['signals'] == pytest.approx(2.0)
def test_npz_fallback_when_exf_absent(self):
"""When exf_latest is missing, service falls back to NPZ path (which reads disk)."""
svc, written = self._make_service({}) # empty HZ
# NPZ disk won't be available in CI but get_dynamic_boost_for_date() returns
# a result with source='npz' (or absent source key from NPZ path).
# We mock _load_external_factors to return neutral factors.
with patch.object(svc.acb, '_load_external_factors',
return_value={'funding_btc': 0.0, 'dvol_btc': 50.0,
'fng': 50.0, 'taker': 1.0, 'available': True}):
svc.process_and_write('2026-02-05')
assert 'acb_boost' in written
result = json.loads(written['acb_boost'])
# NPZ path doesn't set source='hz'
assert result.get('source') != 'hz'
def test_stale_exf_triggers_npz_fallback(self):
stale_snap = _make_snapshot(staleness_s={
'funding_btc': _STALE_FALLBACK_S + 1000,
'dvol_btc': 30, 'fng': 30, 'taker': 30,
})
svc, written = self._make_service({'exf_latest': stale_snap})
with patch.object(svc.acb, '_load_external_factors',
return_value={'funding_btc': 0.0, 'dvol_btc': 50.0,
'fng': 50.0, 'taker': 1.0, 'available': True}):
svc.process_and_write('2026-02-05')
assert 'acb_boost' in written
result = json.loads(written['acb_boost'])
assert result.get('source') != 'hz'
# ════════════════════════════════════════════════════════════════════════════════
if __name__ == '__main__':
import subprocess
subprocess.run(['pytest', __file__, '-v', '--tb=short'], check=True)