"""Property-based tests for the Circuit Breaker. Feature: autonomous-trading-engine Tests properties 13 and 14 from the design specification, covering circuit breaker activation triggers (daily loss, single position, volatility) and cooldown expiry behaviour. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from hypothesis import assume, given, settings from hypothesis import strategies as st from services.trading.circuit_breaker import CircuitBreaker from services.trading.models import CircuitBreakerState # --------------------------------------------------------------------------- # Hypothesis strategies # --------------------------------------------------------------------------- def _circuit_breaker_strategy() -> st.SearchStrategy[CircuitBreaker]: """Generate CircuitBreaker instances with random but valid thresholds.""" return st.builds( CircuitBreaker, daily_loss_pct=st.floats(min_value=0.01, max_value=0.20, allow_nan=False, allow_infinity=False), single_position_loss_pct=st.floats(min_value=0.05, max_value=0.50, allow_nan=False, allow_infinity=False), ticker_cooldown_hours=st.integers(min_value=1, max_value=168), volatility_pause_hours=st.integers(min_value=1, max_value=24), stop_loss_hits_threshold=st.integers(min_value=2, max_value=10), stop_loss_window_minutes=st.integers(min_value=5, max_value=120), ) def _aware_datetime_strategy( min_dt: datetime | None = None, max_dt: datetime | None = None, ) -> st.SearchStrategy[datetime]: """Generate timezone-aware UTC datetimes.""" _min = min_dt or datetime(2024, 1, 1, tzinfo=timezone.utc) _max = max_dt or datetime(2025, 12, 31, tzinfo=timezone.utc) return st.datetimes(min_value=_min.replace(tzinfo=None), max_value=_max.replace(tzinfo=None)).map( lambda dt: dt.replace(tzinfo=timezone.utc) ) # --------------------------------------------------------------------------- # Property 13: Circuit breaker activation # **Validates: Requirements 6.1, 6.2, 6.3** # --------------------------------------------------------------------------- class TestProperty13CircuitBreakerActivation: """Property 13: Circuit breaker activation. **Validates: Requirements 6.1, 6.2, 6.3** """ # -- Daily loss trigger ------------------------------------------------ @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False), excess_pct=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False), ) def test_daily_loss_triggers_when_loss_exceeds_threshold( self, cb: CircuitBreaker, portfolio_value: float, excess_pct: float, ) -> None: """Daily loss circuit breaker triggers when abs(daily_pnl)/portfolio_value > daily_loss_pct.""" # Construct a daily_pnl that exceeds the threshold loss_ratio = cb.daily_loss_pct + excess_pct daily_pnl = -(loss_ratio * portfolio_value) result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value) assert result is True, ( f"Expected daily_loss trigger: loss_ratio={loss_ratio:.4f} > threshold={cb.daily_loss_pct:.4f}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False), below_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False), ) def test_daily_loss_does_not_trigger_below_threshold( self, cb: CircuitBreaker, portfolio_value: float, below_fraction: float, ) -> None: """Daily loss circuit breaker does NOT trigger when loss ratio <= threshold.""" # Loss ratio strictly below threshold loss_ratio = cb.daily_loss_pct * below_fraction daily_pnl = -(loss_ratio * portfolio_value) result = cb.check_daily_loss(daily_pnl=daily_pnl, portfolio_value=portfolio_value) assert result is False, ( f"Should not trigger: loss_ratio={loss_ratio:.4f} <= threshold={cb.daily_loss_pct:.4f}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), portfolio_value=st.floats(min_value=100.0, max_value=100000.0, allow_nan=False, allow_infinity=False), profit=st.floats(min_value=0.01, max_value=10000.0, allow_nan=False, allow_infinity=False), ) def test_daily_loss_does_not_trigger_on_positive_pnl( self, cb: CircuitBreaker, portfolio_value: float, profit: float, ) -> None: """Daily loss circuit breaker never triggers on positive P&L.""" result = cb.check_daily_loss(daily_pnl=profit, portfolio_value=portfolio_value) assert result is False, "Should not trigger on positive daily P&L" # -- Single position trigger ------------------------------------------- @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), excess=st.floats(min_value=0.001, max_value=0.50, allow_nan=False, allow_infinity=False), ) def test_single_position_triggers_when_loss_exceeds_threshold( self, cb: CircuitBreaker, excess: float, ) -> None: """Single position circuit breaker triggers when position_loss_pct > threshold.""" position_loss_pct = cb.single_position_loss_pct + excess result = cb.check_single_position(position_loss_pct=position_loss_pct) assert result is True, ( f"Expected single_position trigger: loss={position_loss_pct:.4f} > threshold={cb.single_position_loss_pct:.4f}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), below_fraction=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False), ) def test_single_position_does_not_trigger_at_or_below_threshold( self, cb: CircuitBreaker, below_fraction: float, ) -> None: """Single position circuit breaker does NOT trigger when loss <= threshold.""" position_loss_pct = cb.single_position_loss_pct * below_fraction result = cb.check_single_position(position_loss_pct=position_loss_pct) assert result is False, ( f"Should not trigger: loss={position_loss_pct:.4f} <= threshold={cb.single_position_loss_pct:.4f}" ) # -- Volatility trigger ------------------------------------------------ @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), base_time=_aware_datetime_strategy(), ) def test_volatility_triggers_when_enough_stops_within_window( self, cb: CircuitBreaker, base_time: datetime, ) -> None: """Volatility circuit breaker triggers when >= threshold stop-losses fire within the window.""" # Generate exactly threshold hits within the window window_minutes = cb.stop_loss_window_minutes n_hits = cb.stop_loss_hits_threshold # Space hits evenly within the window interval = timedelta(minutes=window_minutes / n_hits) stop_loss_hits = [base_time + interval * i for i in range(n_hits)] result = cb.check_volatility(stop_loss_hits=stop_loss_hits) assert result is True, ( f"Expected volatility trigger: {n_hits} hits within {window_minutes}min window" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), base_time=_aware_datetime_strategy(), ) def test_volatility_does_not_trigger_when_too_few_stops( self, cb: CircuitBreaker, base_time: datetime, ) -> None: """Volatility circuit breaker does NOT trigger with fewer than threshold hits.""" n_hits = cb.stop_loss_hits_threshold - 1 assume(n_hits >= 0) # Even if all within the window, not enough hits stop_loss_hits = [base_time + timedelta(minutes=i) for i in range(n_hits)] result = cb.check_volatility(stop_loss_hits=stop_loss_hits) assert result is False, ( f"Should not trigger: only {n_hits} hits < threshold {cb.stop_loss_hits_threshold}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), base_time=_aware_datetime_strategy(), ) def test_volatility_does_not_trigger_when_stops_spread_outside_window( self, cb: CircuitBreaker, base_time: datetime, ) -> None: """Volatility circuit breaker does NOT trigger when hits are spread beyond the window.""" n_hits = cb.stop_loss_hits_threshold window_minutes = cb.stop_loss_window_minutes # Space hits so that no contiguous sub-sequence of threshold size fits in the window # Each hit is window_minutes + 1 apart, so any threshold-sized group spans # (threshold - 1) * (window_minutes + 1) minutes > window_minutes gap = timedelta(minutes=window_minutes + 1) stop_loss_hits = [base_time + gap * i for i in range(n_hits)] result = cb.check_volatility(stop_loss_hits=stop_loss_hits) assert result is False, ( f"Should not trigger: hits spread {window_minutes + 1}min apart, " f"window is {window_minutes}min" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), ) def test_volatility_does_not_trigger_on_empty_hits( self, cb: CircuitBreaker, ) -> None: """Volatility circuit breaker does NOT trigger with no stop-loss hits.""" result = cb.check_volatility(stop_loss_hits=[]) assert result is False, "Should not trigger on empty stop-loss hits list" # --------------------------------------------------------------------------- # Property 14: Circuit breaker cooldown expiry # **Validates: Requirements 6.5** # --------------------------------------------------------------------------- class TestProperty14CircuitBreakerCooldownExpiry: """Property 14: Circuit breaker cooldown expiry. **Validates: Requirements 6.5** """ # -- is_active cooldown expiry ----------------------------------------- @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), extra_seconds=st.integers(min_value=1, max_value=86400), ) def test_is_active_returns_false_after_cooldown_expires( self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int, ) -> None: """is_active returns False when current time > cooldown_expires.""" cooldown_expires = triggered_at + timedelta(hours=cb.volatility_pause_hours) now = cooldown_expires + timedelta(seconds=extra_seconds) state = CircuitBreakerState( active=True, trigger_type="daily_loss", triggered_at=triggered_at, cooldown_expires=cooldown_expires, ) result = cb.is_active(state=state, now=now) assert result is False, ( f"Expected inactive: now={now} > cooldown_expires={cooldown_expires}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False), ) def test_is_active_returns_true_before_cooldown_expires( self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float, ) -> None: """is_active returns True when current time < cooldown_expires.""" cooldown_duration = timedelta(hours=cb.volatility_pause_hours) cooldown_expires = triggered_at + cooldown_duration # now is some fraction of the way through the cooldown (before expiry) now = triggered_at + cooldown_duration * before_fraction assume(now < cooldown_expires) state = CircuitBreakerState( active=True, trigger_type="volatility", triggered_at=triggered_at, cooldown_expires=cooldown_expires, ) result = cb.is_active(state=state, now=now) assert result is True, ( f"Expected active: now={now} < cooldown_expires={cooldown_expires}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), ) def test_is_active_returns_false_when_state_not_active( self, cb: CircuitBreaker, triggered_at: datetime, ) -> None: """is_active returns False when state.active is False regardless of time.""" state = CircuitBreakerState( active=False, trigger_type=None, triggered_at=triggered_at, cooldown_expires=triggered_at + timedelta(hours=24), ) result = cb.is_active(state=state, now=triggered_at) assert result is False, "Expected inactive when state.active is False" # -- is_ticker_cooled_down expiry -------------------------------------- @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), extra_seconds=st.integers(min_value=1, max_value=86400), ) def test_ticker_cooldown_returns_false_after_expiry( self, cb: CircuitBreaker, triggered_at: datetime, extra_seconds: int, ) -> None: """is_ticker_cooled_down returns False when cooldown has expired.""" cooldown_expires = triggered_at + timedelta(hours=cb.ticker_cooldown_hours) now = cooldown_expires + timedelta(seconds=extra_seconds) ticker_cooldowns = {"AAPL": cooldown_expires} result = cb.is_ticker_cooled_down( ticker="AAPL", ticker_cooldowns=ticker_cooldowns, now=now, ) assert result is False, ( f"Expected ticker not cooled down: now={now} > expiry={cooldown_expires}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), before_fraction=st.floats(min_value=0.0, max_value=0.99, allow_nan=False, allow_infinity=False), ) def test_ticker_cooldown_returns_true_during_cooldown( self, cb: CircuitBreaker, triggered_at: datetime, before_fraction: float, ) -> None: """is_ticker_cooled_down returns True when still within cooldown period.""" cooldown_duration = timedelta(hours=cb.ticker_cooldown_hours) cooldown_expires = triggered_at + cooldown_duration now = triggered_at + cooldown_duration * before_fraction assume(now < cooldown_expires) ticker_cooldowns = {"TSLA": cooldown_expires} result = cb.is_ticker_cooled_down( ticker="TSLA", ticker_cooldowns=ticker_cooldowns, now=now, ) assert result is True, ( f"Expected ticker cooled down: now={now} < expiry={cooldown_expires}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), now=_aware_datetime_strategy(), ) def test_ticker_cooldown_returns_false_for_unknown_ticker( self, cb: CircuitBreaker, now: datetime, ) -> None: """is_ticker_cooled_down returns False for a ticker with no cooldown entry.""" result = cb.is_ticker_cooled_down( ticker="UNKNOWN", ticker_cooldowns={}, now=now, ) assert result is False, "Expected no cooldown for unknown ticker" # -- compute_cooldown_expiry ------------------------------------------- @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), ) def test_cooldown_expiry_single_position_uses_ticker_cooldown_hours( self, cb: CircuitBreaker, triggered_at: datetime, ) -> None: """compute_cooldown_expiry for single_position uses ticker_cooldown_hours.""" expiry = cb.compute_cooldown_expiry( trigger_type="single_position", triggered_at=triggered_at, ) expected = triggered_at + timedelta(hours=cb.ticker_cooldown_hours) assert expiry == expected, ( f"single_position expiry {expiry} != expected {expected}" ) @settings(max_examples=100) @given( cb=_circuit_breaker_strategy(), triggered_at=_aware_datetime_strategy(), trigger_type=st.sampled_from(["daily_loss", "volatility"]), ) def test_cooldown_expiry_daily_loss_and_volatility_use_pause_hours( self, cb: CircuitBreaker, triggered_at: datetime, trigger_type: str, ) -> None: """compute_cooldown_expiry for daily_loss/volatility uses volatility_pause_hours.""" expiry = cb.compute_cooldown_expiry( trigger_type=trigger_type, triggered_at=triggered_at, ) expected = triggered_at + timedelta(hours=cb.volatility_pause_hours) assert expiry == expected, ( f"{trigger_type} expiry {expiry} != expected {expected}" )