"""Circuit breaker safety mechanism for the autonomous trading engine. Pure computation module — no DB or Redis access. State management and persistence are handled by the caller. All methods operate on values passed in as arguments and return deterministic results. """ from __future__ import annotations from datetime import datetime, timedelta, timezone from services.trading.models import CircuitBreakerState class CircuitBreaker: """Evaluates circuit breaker conditions and computes cooldown expiries. Parameters ---------- daily_loss_pct: Maximum allowed daily portfolio loss as a fraction (e.g. 0.05 = 5%). single_position_loss_pct: Maximum allowed loss on a single position as a fraction of entry value. ticker_cooldown_hours: Hours a ticker is blocked from re-entry after a single-position breach. volatility_pause_hours: Hours trading is paused after a volatility (stop-loss cluster) trigger. stop_loss_hits_threshold: Number of stop-loss hits within the window that triggers a volatility pause. stop_loss_window_minutes: Rolling window (in minutes) for counting clustered stop-loss hits. """ def __init__( self, daily_loss_pct: float = 0.05, single_position_loss_pct: float = 0.15, ticker_cooldown_hours: int = 48, volatility_pause_hours: int = 2, stop_loss_hits_threshold: int = 3, stop_loss_window_minutes: int = 30, ) -> None: self.daily_loss_pct = daily_loss_pct self.single_position_loss_pct = single_position_loss_pct self.ticker_cooldown_hours = ticker_cooldown_hours self.volatility_pause_hours = volatility_pause_hours self.stop_loss_hits_threshold = stop_loss_hits_threshold self.stop_loss_window_minutes = stop_loss_window_minutes # ------------------------------------------------------------------ # Trigger checks # ------------------------------------------------------------------ def check_daily_loss(self, daily_pnl: float, portfolio_value: float) -> bool: """Return True when the portfolio has dropped more than *daily_loss_pct* today. Parameters ---------- daily_pnl: Today's profit/loss — negative values represent losses. portfolio_value: Current total portfolio value (must be positive). """ if portfolio_value <= 0: return True # degenerate case — treat as triggered loss_ratio = abs(daily_pnl) / portfolio_value return daily_pnl < 0 and loss_ratio > self.daily_loss_pct def check_single_position(self, position_loss_pct: float) -> bool: """Return True when a single position has lost more than the threshold. Parameters ---------- position_loss_pct: Loss expressed as a positive fraction of entry value (e.g. 0.15 means the position is down 15%). """ return position_loss_pct > self.single_position_loss_pct def check_volatility(self, stop_loss_hits: list[datetime]) -> bool: """Return True when too many stop-losses fired within the rolling window. Parameters ---------- stop_loss_hits: Timestamps of recent stop-loss trigger events. """ if len(stop_loss_hits) < self.stop_loss_hits_threshold: return False window = timedelta(minutes=self.stop_loss_window_minutes) sorted_hits = sorted(stop_loss_hits) # Sliding window: check every contiguous sub-sequence of length # *stop_loss_hits_threshold* to see if it fits within the window. for i in range(len(sorted_hits) - self.stop_loss_hits_threshold + 1): start = sorted_hits[i] end = sorted_hits[i + self.stop_loss_hits_threshold - 1] if end - start <= window: return True return False # ------------------------------------------------------------------ # Cooldown helpers # ------------------------------------------------------------------ def is_ticker_cooled_down( self, ticker: str, ticker_cooldowns: dict[str, datetime], now: datetime | None = None, ) -> bool: """Return True if *ticker* is still in its cooldown period. Returns False when the cooldown has expired or the ticker has no active cooldown. """ if ticker not in ticker_cooldowns: return False now = now or datetime.now(tz=timezone.utc) return now < ticker_cooldowns[ticker] def is_active( self, state: CircuitBreakerState, now: datetime | None = None, ) -> bool: """Return True if any circuit breaker is currently active (not expired).""" if not state.active: return False if state.cooldown_expires is None: # Active with no expiry — treat as active. return True now = now or datetime.now(tz=timezone.utc) return now < state.cooldown_expires def compute_cooldown_expiry( self, trigger_type: str, triggered_at: datetime, ) -> datetime: """Compute when the cooldown for *trigger_type* expires. * ``daily_loss`` / ``volatility`` → *triggered_at* + *volatility_pause_hours* * ``single_position`` → *triggered_at* + *ticker_cooldown_hours* """ if trigger_type == "single_position": return triggered_at + timedelta(hours=self.ticker_cooldown_hours) # daily_loss, volatility, and any other type default to the # volatility pause duration. return triggered_at + timedelta(hours=self.volatility_pause_hours)