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