//! Price Circuit Breakers //! //! Automatic trading halts when prices move too rapidly. //! Protects against flash crashes, oracle manipulation, and black swan events. use crate::error::{EconomicsError, Result}; use crate::SynorDecimal; use chrono::{DateTime, Duration, Utc}; use rust_decimal::Decimal; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, VecDeque}; /// Circuit breaker state #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CircuitState { /// Normal operation Closed, /// Temporarily halted, monitoring for recovery Open, /// Testing if conditions are stable enough to resume HalfOpen, } /// Circuit breaker trigger reason #[derive(Debug, Clone, Serialize, Deserialize)] pub enum TriggerReason { /// Price moved too much in short time RapidPriceChange { change_percent: Decimal, window_seconds: i64, }, /// Price deviated too far from reference ExcessiveDeviation { deviation_percent: Decimal, reference_price: SynorDecimal, }, /// Liquidity dropped below threshold LowLiquidity { current: SynorDecimal, threshold: SynorDecimal, }, /// Multiple oracle sources disagree OracleDisagreement { spread_percent: Decimal, }, /// Manual trigger by admin ManualHalt { reason: String, }, /// Cascade from related market CascadeTrigger { source_pair: String, }, } /// Circuit breaker event #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircuitEvent { /// Affected trading pair pub pair: String, /// Previous state pub from_state: CircuitState, /// New state pub to_state: CircuitState, /// Trigger reason pub reason: TriggerReason, /// Event timestamp pub timestamp: DateTime, /// Duration before auto-recovery (if applicable) pub cooldown: Option, } /// Configuration for circuit breakers #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircuitBreakerConfig { /// Maximum price change in 1 minute (percentage) pub max_1m_change: Decimal, /// Maximum price change in 5 minutes (percentage) pub max_5m_change: Decimal, /// Maximum price change in 1 hour (percentage) pub max_1h_change: Decimal, /// Maximum deviation from 24h TWAP (percentage) pub max_twap_deviation: Decimal, /// Minimum liquidity threshold pub min_liquidity: SynorDecimal, /// Maximum oracle spread (percentage) pub max_oracle_spread: Decimal, /// Cooldown duration after trigger pub cooldown_duration: Duration, /// Number of stable checks before half-open → closed pub recovery_checks: usize, /// Enable cascade triggers from related markets pub cascade_enabled: bool, /// Related markets for cascade (e.g., ETH/USD affects ETH/SYNOR) pub cascade_pairs: Vec<(String, String)>, } impl Default for CircuitBreakerConfig { fn default() -> Self { Self { max_1m_change: Decimal::new(10, 2), // 10% max_5m_change: Decimal::new(20, 2), // 20% max_1h_change: Decimal::new(50, 2), // 50% max_twap_deviation: Decimal::new(30, 2), // 30% min_liquidity: Decimal::new(10000, 0), // $10k max_oracle_spread: Decimal::new(5, 2), // 5% cooldown_duration: Duration::minutes(5), recovery_checks: 3, cascade_enabled: true, cascade_pairs: vec![ ("BTC/USD".to_string(), "BTC/SYNOR".to_string()), ("ETH/USD".to_string(), "ETH/SYNOR".to_string()), ], } } } /// Price snapshot for tracking #[derive(Debug, Clone)] struct PriceSnapshot { price: SynorDecimal, timestamp: DateTime, liquidity: Option, } /// Per-pair circuit breaker state #[derive(Debug)] struct PairCircuitBreaker { state: CircuitState, /// Recent price history price_history: VecDeque, /// When breaker was triggered triggered_at: Option>, /// Trigger reason trigger_reason: Option, /// Recovery check count recovery_checks: usize, /// 24h TWAP reference twap_24h: Option, /// Event history events: Vec, } impl PairCircuitBreaker { fn new() -> Self { Self { state: CircuitState::Closed, price_history: VecDeque::with_capacity(1000), triggered_at: None, trigger_reason: None, recovery_checks: 0, twap_24h: None, events: Vec::new(), } } fn record_price(&mut self, price: SynorDecimal, liquidity: Option) { self.record_price_at(price, liquidity, Utc::now()); } fn record_price_at( &mut self, price: SynorDecimal, liquidity: Option, timestamp: DateTime, ) { let snapshot = PriceSnapshot { price, timestamp, liquidity, }; self.price_history.push_back(snapshot); // Keep only last 24 hours let cutoff = Utc::now() - Duration::hours(24); while self.price_history.front().map(|s| s.timestamp < cutoff).unwrap_or(false) { self.price_history.pop_front(); } // Update 24h TWAP self.update_twap(); } fn update_twap(&mut self) { if self.price_history.is_empty() { return; } let sum: Decimal = self.price_history.iter().map(|s| s.price).sum(); self.twap_24h = Some(sum / Decimal::from(self.price_history.len())); } fn get_price_at(&self, seconds_ago: i64) -> Option { let target = Utc::now() - Duration::seconds(seconds_ago); self.price_history.iter() .rev() .find(|s| s.timestamp <= target) .map(|s| s.price) } fn current_price(&self) -> Option { self.price_history.back().map(|s| s.price) } fn current_liquidity(&self) -> Option { self.price_history.back().and_then(|s| s.liquidity) } } /// Circuit breaker manager for all trading pairs pub struct CircuitBreakerManager { config: CircuitBreakerConfig, breakers: HashMap, } impl CircuitBreakerManager { pub fn new() -> Self { Self::with_config(CircuitBreakerConfig::default()) } pub fn with_config(config: CircuitBreakerConfig) -> Self { Self { config, breakers: HashMap::new(), } } /// Record a new price and check circuit breakers pub fn record_price( &mut self, pair: &str, price: SynorDecimal, liquidity: Option, ) -> Result { let breaker = self.breakers.entry(pair.to_string()) .or_insert_with(PairCircuitBreaker::new); // Use the convenience method for real-time price recording breaker.record_price(price, liquidity); // Check breakers if currently closed if breaker.state == CircuitState::Closed { self.check_triggers(pair)?; } else { self.check_recovery(pair)?; } Ok(self.get_state(pair)) } /// Record a price at a specific timestamp (useful for testing) pub fn record_price_at( &mut self, pair: &str, price: SynorDecimal, liquidity: Option, timestamp: DateTime, ) -> Result { let breaker = self.breakers.entry(pair.to_string()) .or_insert_with(PairCircuitBreaker::new); breaker.record_price_at(price, liquidity, timestamp); // Check breakers if currently closed if breaker.state == CircuitState::Closed { self.check_triggers(pair)?; } else { self.check_recovery(pair)?; } Ok(self.get_state(pair)) } /// Check all trigger conditions fn check_triggers(&mut self, pair: &str) -> Result<()> { let breaker = self.breakers.get(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; let current = breaker.current_price().ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; // Check 1-minute change if let Some(price_1m) = breaker.get_price_at(60) { let change = ((current - price_1m) / price_1m).abs(); if change > self.config.max_1m_change { return self.trigger_breaker(pair, TriggerReason::RapidPriceChange { change_percent: change * Decimal::ONE_HUNDRED, window_seconds: 60, }); } } // Check 5-minute change if let Some(price_5m) = breaker.get_price_at(300) { let change = ((current - price_5m) / price_5m).abs(); if change > self.config.max_5m_change { return self.trigger_breaker(pair, TriggerReason::RapidPriceChange { change_percent: change * Decimal::ONE_HUNDRED, window_seconds: 300, }); } } // Check 1-hour change if let Some(price_1h) = breaker.get_price_at(3600) { let change = ((current - price_1h) / price_1h).abs(); if change > self.config.max_1h_change { return self.trigger_breaker(pair, TriggerReason::RapidPriceChange { change_percent: change * Decimal::ONE_HUNDRED, window_seconds: 3600, }); } } // Check TWAP deviation if let Some(twap) = breaker.twap_24h { let deviation = ((current - twap) / twap).abs(); if deviation > self.config.max_twap_deviation { return self.trigger_breaker(pair, TriggerReason::ExcessiveDeviation { deviation_percent: deviation * Decimal::ONE_HUNDRED, reference_price: twap, }); } } // Check liquidity if let Some(liquidity) = breaker.current_liquidity() { if liquidity < self.config.min_liquidity { return self.trigger_breaker(pair, TriggerReason::LowLiquidity { current: liquidity, threshold: self.config.min_liquidity, }); } } Ok(()) } /// Trigger the circuit breaker fn trigger_breaker(&mut self, pair: &str, reason: TriggerReason) -> Result<()> { let breaker = self.breakers.get_mut(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; let event = CircuitEvent { pair: pair.to_string(), from_state: breaker.state, to_state: CircuitState::Open, reason: reason.clone(), timestamp: Utc::now(), cooldown: Some(self.config.cooldown_duration), }; breaker.state = CircuitState::Open; breaker.triggered_at = Some(Utc::now()); breaker.trigger_reason = Some(reason); breaker.recovery_checks = 0; breaker.events.push(event); // Check cascade triggers if self.config.cascade_enabled { let cascades: Vec<_> = self.config.cascade_pairs.iter() .filter(|(source, _)| source == pair) .map(|(_, target)| target.clone()) .collect(); for target in cascades { self.trigger_cascade(pair, &target)?; } } Ok(()) } /// Trigger cascade to related market fn trigger_cascade(&mut self, source: &str, target: &str) -> Result<()> { if let Some(breaker) = self.breakers.get_mut(target) { if breaker.state == CircuitState::Closed { let event = CircuitEvent { pair: target.to_string(), from_state: breaker.state, to_state: CircuitState::Open, reason: TriggerReason::CascadeTrigger { source_pair: source.to_string(), }, timestamp: Utc::now(), cooldown: Some(self.config.cooldown_duration), }; breaker.state = CircuitState::Open; breaker.triggered_at = Some(Utc::now()); breaker.trigger_reason = Some(TriggerReason::CascadeTrigger { source_pair: source.to_string(), }); breaker.events.push(event); } } Ok(()) } /// Check if breaker can recover fn check_recovery(&mut self, pair: &str) -> Result<()> { let cooldown = self.config.cooldown_duration; let recovery_needed = self.config.recovery_checks; // Get current state first (immutable borrow) let (current_state, triggered_at, trigger_reason) = { let breaker = self.breakers.get(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; (breaker.state, breaker.triggered_at, breaker.trigger_reason.clone()) }; // Check stability for half-open state (immutable borrow) let is_stable = if current_state == CircuitState::HalfOpen { self.is_stable(pair)? } else { false }; // Now get mutable reference for updates let breaker = self.breakers.get_mut(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; match current_state { CircuitState::Open => { // Check if cooldown expired if let Some(triggered) = triggered_at { if Utc::now() - triggered >= cooldown { // Move to half-open let event = CircuitEvent { pair: pair.to_string(), from_state: CircuitState::Open, to_state: CircuitState::HalfOpen, reason: trigger_reason.clone().unwrap_or( TriggerReason::ManualHalt { reason: "Unknown".into() } ), timestamp: Utc::now(), cooldown: None, }; breaker.state = CircuitState::HalfOpen; breaker.events.push(event); } } } CircuitState::HalfOpen => { // Check if conditions are stable if is_stable { breaker.recovery_checks += 1; if breaker.recovery_checks >= recovery_needed { // Fully recover let event = CircuitEvent { pair: pair.to_string(), from_state: CircuitState::HalfOpen, to_state: CircuitState::Closed, reason: trigger_reason.unwrap_or( TriggerReason::ManualHalt { reason: "Recovery".into() } ), timestamp: Utc::now(), cooldown: None, }; breaker.state = CircuitState::Closed; breaker.triggered_at = None; breaker.trigger_reason = None; breaker.recovery_checks = 0; breaker.events.push(event); } } else { // Reset recovery counter breaker.recovery_checks = 0; } } CircuitState::Closed => {} } Ok(()) } /// Check if market conditions are stable fn is_stable(&self, pair: &str) -> Result { let breaker = self.breakers.get(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; let current = match breaker.current_price() { Some(p) => p, None => return Ok(false), }; // Check 1-minute stability (must be under half the trigger threshold) if let Some(price_1m) = breaker.get_price_at(60) { let change = ((current - price_1m) / price_1m).abs(); if change > self.config.max_1m_change / Decimal::from(2) { return Ok(false); } } // Check liquidity if let Some(liquidity) = breaker.current_liquidity() { if liquidity < self.config.min_liquidity { return Ok(false); } } Ok(true) } /// Get current state for a pair pub fn get_state(&self, pair: &str) -> CircuitState { self.breakers.get(pair) .map(|b| b.state) .unwrap_or(CircuitState::Closed) } /// Check if trading is allowed pub fn is_trading_allowed(&self, pair: &str) -> bool { self.get_state(pair) == CircuitState::Closed } /// Manually trigger circuit breaker pub fn manual_halt(&mut self, pair: &str, reason: impl Into) -> Result<()> { self.breakers.entry(pair.to_string()) .or_insert_with(PairCircuitBreaker::new); self.trigger_breaker(pair, TriggerReason::ManualHalt { reason: reason.into(), }) } /// Manually reset circuit breaker pub fn manual_reset(&mut self, pair: &str) -> Result<()> { let breaker = self.breakers.get_mut(pair).ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()) )?; let event = CircuitEvent { pair: pair.to_string(), from_state: breaker.state, to_state: CircuitState::Closed, reason: TriggerReason::ManualHalt { reason: "Manual reset".into() }, timestamp: Utc::now(), cooldown: None, }; breaker.state = CircuitState::Closed; breaker.triggered_at = None; breaker.trigger_reason = None; breaker.recovery_checks = 0; breaker.events.push(event); Ok(()) } /// Record oracle disagreement pub fn record_oracle_spread(&mut self, pair: &str, spread: Decimal) -> Result<()> { if spread > self.config.max_oracle_spread { self.breakers.entry(pair.to_string()) .or_insert_with(PairCircuitBreaker::new); self.trigger_breaker(pair, TriggerReason::OracleDisagreement { spread_percent: spread * Decimal::ONE_HUNDRED, })?; } Ok(()) } /// Get event history for a pair pub fn get_events(&self, pair: &str) -> Vec { self.breakers.get(pair) .map(|b| b.events.clone()) .unwrap_or_default() } /// Get all currently halted pairs pub fn get_halted_pairs(&self) -> Vec<(String, CircuitState, Option)> { self.breakers.iter() .filter(|(_, b)| b.state != CircuitState::Closed) .map(|(pair, b)| (pair.clone(), b.state, b.trigger_reason.clone())) .collect() } /// Get summary statistics pub fn get_stats(&self) -> CircuitBreakerStats { let total = self.breakers.len(); let open = self.breakers.values().filter(|b| b.state == CircuitState::Open).count(); let half_open = self.breakers.values().filter(|b| b.state == CircuitState::HalfOpen).count(); let total_events: usize = self.breakers.values().map(|b| b.events.len()).sum(); CircuitBreakerStats { total_pairs: total, open_breakers: open, half_open_breakers: half_open, closed_breakers: total - open - half_open, total_events, } } } impl Default for CircuitBreakerManager { fn default() -> Self { Self::new() } } /// Circuit breaker statistics #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CircuitBreakerStats { pub total_pairs: usize, pub open_breakers: usize, pub half_open_breakers: usize, pub closed_breakers: usize, pub total_events: usize, } #[cfg(test)] mod tests { use super::*; use rust_decimal_macros::dec; #[test] fn test_normal_price_movement() { let mut manager = CircuitBreakerManager::new(); // Normal price movements should not trigger for i in 0..10 { let price = dec!(100) + Decimal::from(i); let state = manager.record_price("SYNOR/USD", price, Some(dec!(100000))).unwrap(); assert_eq!(state, CircuitState::Closed); } } #[test] fn test_flash_crash() { use chrono::Duration; let mut manager = CircuitBreakerManager::new(); let now = Utc::now(); // Record baseline 2 minutes ago manager.record_price_at("SYNOR/USD", dec!(100), Some(dec!(100000)), now - Duration::minutes(2)).unwrap(); // Simulate 15% drop (exceeds 10% 1-minute threshold) let state = manager.record_price_at("SYNOR/USD", dec!(85), Some(dec!(100000)), now).unwrap(); assert_eq!(state, CircuitState::Open); } #[test] fn test_low_liquidity() { let mut manager = CircuitBreakerManager::new(); // Record with very low liquidity let state = manager.record_price("SYNOR/USD", dec!(100), Some(dec!(100))).unwrap(); assert_eq!(state, CircuitState::Open); } #[test] fn test_manual_halt_and_reset() { let mut manager = CircuitBreakerManager::new(); manager.record_price("SYNOR/USD", dec!(100), Some(dec!(100000))).unwrap(); assert!(manager.is_trading_allowed("SYNOR/USD")); // Manual halt manager.manual_halt("SYNOR/USD", "Scheduled maintenance").unwrap(); assert!(!manager.is_trading_allowed("SYNOR/USD")); // Manual reset manager.manual_reset("SYNOR/USD").unwrap(); assert!(manager.is_trading_allowed("SYNOR/USD")); } #[test] fn test_oracle_disagreement() { let mut manager = CircuitBreakerManager::new(); // Initialize manager.record_price("SYNOR/USD", dec!(100), Some(dec!(100000))).unwrap(); // Record 10% spread (exceeds 5% threshold) manager.record_oracle_spread("SYNOR/USD", dec!(0.10)).unwrap(); assert_eq!(manager.get_state("SYNOR/USD"), CircuitState::Open); } }