- 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.
688 lines
22 KiB
Rust
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);
|
|
}
|
|
}
|