synor/crates/synor-economics/src/oracle/circuit_breaker.rs
Gulshan Yadav 780a6aaad0 feat: Enhance economics manager with flexible oracle configurations
- Added `with_production_oracle` and `with_oracle` methods to `EconomicsManager` for custom oracle setups.
- Introduced `record_compute_with_gpu` method in `MeteringService` to handle GPU-specific pricing.
- Enhanced `CircuitBreakerManager` to streamline price recording and state checking.
- Expanded `CrossChainOracle` with a builder pattern for easier configuration and added methods for managing pending packets.
- Introduced `PriceOracleBuilder` and `OracleFactory` for creating price oracles with various feeds.
- Added volume discount functionalities in `PricingEngine` for better pricing strategies.
- Improved `ContentResolver` with configuration management and health check features.
- Enhanced `ProverConfig` accessibility in `ProofSubmitter` and `Verifier` for better integration.
- Added utility methods in `SmtContext` for managing SMT-LIB scripts and assertions.
2026-01-26 23:37:45 +05:30

688 lines
22 KiB
Rust

//! 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<Utc>,
/// Duration before auto-recovery (if applicable)
pub cooldown: Option<Duration>,
}
/// 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<Utc>,
liquidity: Option<SynorDecimal>,
}
/// Per-pair circuit breaker state
#[derive(Debug)]
struct PairCircuitBreaker {
state: CircuitState,
/// Recent price history
price_history: VecDeque<PriceSnapshot>,
/// When breaker was triggered
triggered_at: Option<DateTime<Utc>>,
/// Trigger reason
trigger_reason: Option<TriggerReason>,
/// Recovery check count
recovery_checks: usize,
/// 24h TWAP reference
twap_24h: Option<SynorDecimal>,
/// Event history
events: Vec<CircuitEvent>,
}
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<SynorDecimal>) {
self.record_price_at(price, liquidity, Utc::now());
}
fn record_price_at(
&mut self,
price: SynorDecimal,
liquidity: Option<SynorDecimal>,
timestamp: DateTime<Utc>,
) {
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<SynorDecimal> {
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<SynorDecimal> {
self.price_history.back().map(|s| s.price)
}
fn current_liquidity(&self) -> Option<SynorDecimal> {
self.price_history.back().and_then(|s| s.liquidity)
}
}
/// Circuit breaker manager for all trading pairs
pub struct CircuitBreakerManager {
config: CircuitBreakerConfig,
breakers: HashMap<String, PairCircuitBreaker>,
}
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<SynorDecimal>,
) -> Result<CircuitState> {
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<SynorDecimal>,
timestamp: DateTime<Utc>,
) -> Result<CircuitState> {
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<bool> {
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<String>) -> 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<CircuitEvent> {
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<TriggerReason>)> {
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);
}
}