728 lines
35 KiB
Python
728 lines
35 KiB
Python
|
|
"""
|
||
|
|
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)
|