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.
272 lines
10 KiB
Python
Executable File
272 lines
10 KiB
Python
Executable File
"""
|
|
Tests for Adaptive Circuit Breaker v5.
|
|
"""
|
|
|
|
import unittest
|
|
from datetime import datetime
|
|
from unittest.mock import patch, MagicMock
|
|
import numpy as np
|
|
|
|
from nautilus_dolphin.nautilus.adaptive_circuit_breaker import (
|
|
AdaptiveCircuitBreaker, ACBConfig, ACBPositionSizer, get_acb_cut_for_date
|
|
)
|
|
|
|
|
|
class TestACBConfig(unittest.TestCase):
|
|
"""Test ACB configuration."""
|
|
|
|
def test_default_config(self):
|
|
"""Test default configuration values."""
|
|
config = ACBConfig()
|
|
|
|
# Check cut rates (v5 configuration)
|
|
self.assertEqual(config.CUT_RATES[0], 0.00)
|
|
self.assertEqual(config.CUT_RATES[1], 0.15)
|
|
self.assertEqual(config.CUT_RATES[2], 0.45)
|
|
self.assertEqual(config.CUT_RATES[3], 0.55)
|
|
self.assertEqual(config.CUT_RATES[4], 0.75)
|
|
self.assertEqual(config.CUT_RATES[5], 0.80)
|
|
|
|
# Check thresholds
|
|
self.assertEqual(config.FUNDING_VERY_BEARISH, -0.0001)
|
|
self.assertEqual(config.DVOL_EXTREME, 80)
|
|
self.assertEqual(config.FNG_EXTREME_FEAR, 25)
|
|
|
|
|
|
class TestAdaptiveCircuitBreaker(unittest.TestCase):
|
|
"""Test Adaptive Circuit Breaker functionality."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.acb = AdaptiveCircuitBreaker()
|
|
|
|
def test_signal_calculation_no_stress(self):
|
|
"""Test signal calculation with no stress factors."""
|
|
factors = {
|
|
'funding_btc': 0.0001, # Positive funding (bullish)
|
|
'dvol_btc': 40.0, # Low volatility
|
|
'fng': 60, # Greed (bullish)
|
|
'taker': 1.1 # Buying pressure
|
|
}
|
|
|
|
result = self.acb._calculate_signals(factors)
|
|
self.assertEqual(result['signals'], 0.0)
|
|
self.assertEqual(result['severity'], 0)
|
|
|
|
def test_signal_calculation_funding_only(self):
|
|
"""Test signal calculation with funding stress only."""
|
|
factors = {
|
|
'funding_btc': -0.00015, # Very bearish funding
|
|
'dvol_btc': 40.0,
|
|
'fng': 60,
|
|
'taker': 1.1
|
|
}
|
|
|
|
result = self.acb._calculate_signals(factors)
|
|
self.assertEqual(result['signals'], 1.0) # 1 signal from funding
|
|
self.assertEqual(result['severity'], 2)
|
|
|
|
def test_signal_calculation_multiple_stress(self):
|
|
"""Test signal calculation with multiple stress factors."""
|
|
factors = {
|
|
'funding_btc': -0.00015, # Very bearish funding (1.0 signals, sev 2)
|
|
'dvol_btc': 85.0, # Extreme volatility (1.0 signals, sev 2)
|
|
'fng': 20, # Extreme fear (confirmed, 1.0 signals, sev 2)
|
|
'taker': 0.75 # Selling pressure (1.0 signals, sev 1)
|
|
}
|
|
|
|
result = self.acb._calculate_signals(factors)
|
|
# Should have 4 signals (funding + dvol + fng + taker)
|
|
self.assertGreaterEqual(result['signals'], 3.0)
|
|
self.assertGreater(result['severity'], 0)
|
|
|
|
def test_cut_mapping(self):
|
|
"""Test signal to cut rate mapping."""
|
|
test_cases = [
|
|
(0.0, 0.00), # 0 signals -> 0% cut
|
|
(0.5, 0.00), # 0.5 signals -> 0% cut (below threshold)
|
|
(1.0, 0.15), # 1 signal -> 15% cut
|
|
(1.5, 0.15), # 1.5 signals -> 15% cut
|
|
(2.0, 0.45), # 2 signals -> 45% cut
|
|
(2.5, 0.45), # 2.5 signals -> 45% cut
|
|
(3.0, 0.55), # 3 signals -> 55% cut
|
|
(4.0, 0.75), # 4 signals -> 75% cut
|
|
(5.0, 0.80), # 5 signals -> 80% cut
|
|
]
|
|
|
|
for signals, expected_cut in test_cases:
|
|
cut = self.acb._get_cut_from_signals(signals)
|
|
self.assertEqual(cut, expected_cut,
|
|
f"Failed for signals={signals}: got {cut}, expected {expected_cut}")
|
|
|
|
def test_apply_cut_to_position_size(self):
|
|
"""Test applying cut to position size."""
|
|
base_size = 1000.0
|
|
|
|
# Test with no stress (0% cut)
|
|
with patch.object(self.acb, 'get_cut_for_date') as mock_get:
|
|
mock_get.return_value = {'cut': 0.0, 'signals': 0.0}
|
|
final_size, info = self.acb.apply_cut_to_position_size(base_size, '2026-02-06')
|
|
self.assertEqual(final_size, base_size)
|
|
|
|
# Test with moderate stress (45% cut)
|
|
with patch.object(self.acb, 'get_cut_for_date') as mock_get:
|
|
mock_get.return_value = {'cut': 0.45, 'signals': 2.0}
|
|
final_size, info = self.acb.apply_cut_to_position_size(base_size, '2026-02-06')
|
|
self.assertAlmostEqual(final_size, base_size * 0.55, places=10) # 1000 * (1 - 0.45)
|
|
|
|
# Test with extreme stress (80% cut)
|
|
with patch.object(self.acb, 'get_cut_for_date') as mock_get:
|
|
mock_get.return_value = {'cut': 0.80, 'signals': 5.0}
|
|
final_size, info = self.acb.apply_cut_to_position_size(base_size, '2026-02-06')
|
|
self.assertAlmostEqual(final_size, base_size * 0.20, places=10) # 1000 * (1 - 0.80)
|
|
|
|
def test_caching(self):
|
|
"""Test that results are cached."""
|
|
date_str = '2026-02-06'
|
|
|
|
# First call should cache
|
|
with patch.object(self.acb, '_load_external_factors') as mock_load:
|
|
mock_load.return_value = {
|
|
'funding_btc': -0.0001,
|
|
'dvol_btc': 60,
|
|
'fng': 40,
|
|
'taker': 0.95
|
|
}
|
|
|
|
result1 = self.acb.get_cut_for_date(date_str)
|
|
self.assertEqual(self.acb._stats['total_calls'], 1)
|
|
self.assertEqual(self.acb._stats['cache_hits'], 0)
|
|
|
|
# Second call should use cache
|
|
result2 = self.acb.get_cut_for_date(date_str)
|
|
self.assertEqual(self.acb._stats['total_calls'], 2)
|
|
self.assertEqual(self.acb._stats['cache_hits'], 1)
|
|
|
|
# Results should be identical
|
|
self.assertEqual(result1['cut'], result2['cut'])
|
|
|
|
def test_stats_tracking(self):
|
|
"""Test statistics tracking."""
|
|
# Clear stats
|
|
self.acb.reset_stats()
|
|
|
|
# Simulate some calls
|
|
with patch.object(self.acb, '_load_external_factors') as mock_load:
|
|
mock_load.return_value = {
|
|
'funding_btc': -0.0001,
|
|
'dvol_btc': 60,
|
|
'fng': 40,
|
|
'taker': 0.95
|
|
}
|
|
|
|
self.acb.get_cut_for_date('2026-02-06')
|
|
self.acb.get_cut_for_date('2026-02-07')
|
|
self.acb.get_cut_for_date('2026-02-06') # Should be cached
|
|
|
|
stats = self.acb.get_stats()
|
|
self.assertEqual(stats['total_calls'], 3)
|
|
self.assertEqual(stats['cache_hits'], 1)
|
|
self.assertGreater(stats['cache_hit_rate'], 0)
|
|
|
|
|
|
class TestACBPositionSizer(unittest.TestCase):
|
|
"""Test ACB Position Sizer."""
|
|
|
|
def setUp(self):
|
|
"""Set up test fixtures."""
|
|
self.sizer = ACBPositionSizer()
|
|
|
|
def test_enabled_disabled(self):
|
|
"""Test enabling/disabling ACB."""
|
|
self.assertTrue(self.sizer.is_enabled())
|
|
|
|
self.sizer.disable()
|
|
self.assertFalse(self.sizer.is_enabled())
|
|
|
|
# When disabled, should return base size unchanged
|
|
size, info = self.sizer.calculate_size(1000.0, '2026-02-06')
|
|
self.assertEqual(size, 1000.0)
|
|
self.assertFalse(info['enabled'])
|
|
|
|
self.sizer.enable()
|
|
self.assertTrue(self.sizer.is_enabled())
|
|
|
|
def test_calculate_size_with_acb(self):
|
|
"""Test calculate size with ACB enabled."""
|
|
base_size = 1000.0
|
|
|
|
with patch.object(self.sizer.acb, 'get_cut_for_date') as mock_get:
|
|
mock_get.return_value = {
|
|
'cut': 0.15,
|
|
'signals': 1.0,
|
|
'factors': {}
|
|
}
|
|
|
|
final_size, info = self.sizer.calculate_size(base_size, '2026-02-06')
|
|
|
|
self.assertAlmostEqual(final_size, base_size * 0.85, places=10) # 15% cut
|
|
self.assertEqual(info['base_size'], base_size)
|
|
self.assertAlmostEqual(info['final_size'], final_size, places=10)
|
|
self.assertAlmostEqual(info['reduction_pct'], 15.0, places=10)
|
|
|
|
|
|
class TestIntegration(unittest.TestCase):
|
|
"""Integration tests."""
|
|
|
|
def test_feb_6_scenario(self):
|
|
"""
|
|
Test Feb 6, 2026 scenario (actual crash day).
|
|
|
|
Expected signals:
|
|
- Funding: -0.000137 (very bearish)
|
|
- DVOL: 58.9 (elevated)
|
|
- FNG: 14 (extreme fear)
|
|
|
|
Expected: 3+ signals -> 55% cut
|
|
"""
|
|
acb = AdaptiveCircuitBreaker()
|
|
|
|
# Mock the external factors for Feb 6
|
|
with patch.object(acb, '_load_external_factors') as mock_load:
|
|
mock_load.return_value = {
|
|
'funding_btc': -0.000137, # Very bearish
|
|
'dvol_btc': 58.9, # Elevated
|
|
'fng': 14, # Extreme fear
|
|
'taker': 0.85, # Mild selling
|
|
'available': True
|
|
}
|
|
|
|
result = acb.get_cut_for_date('2026-02-06')
|
|
|
|
# Should detect 3+ signals
|
|
self.assertGreaterEqual(result['signals'], 2.0)
|
|
|
|
# Should apply 55% or higher cut
|
|
self.assertGreaterEqual(result['cut'], 0.45)
|
|
|
|
def test_normal_day_scenario(self):
|
|
"""Test normal market day scenario."""
|
|
acb = AdaptiveCircuitBreaker()
|
|
|
|
with patch.object(acb, '_load_external_factors') as mock_load:
|
|
mock_load.return_value = {
|
|
'funding_btc': 0.00005, # Slightly positive
|
|
'dvol_btc': 45.0, # Normal volatility
|
|
'fng': 55, # Neutral/greed
|
|
'taker': 1.05, # Slight buying
|
|
'available': True
|
|
}
|
|
|
|
result = acb.get_cut_for_date('2026-01-15')
|
|
|
|
# Should detect 0-1 signals
|
|
self.assertLessEqual(result['signals'], 1.0)
|
|
|
|
# Should apply 0% or 15% cut
|
|
self.assertLessEqual(result['cut'], 0.15)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|