use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PriceCircuitBreakerConfig {
pub max_price_change_per_hour: Decimal,
pub max_user_volume_per_hour: Decimal,
pub max_user_volume_per_day: Decimal,
pub cooldown_seconds: u64,
pub min_trade_interval_ms: u64,
pub volume_spike_threshold: Decimal,
pub pattern_detection_window: usize,
}
impl Default for PriceCircuitBreakerConfig {
fn default() -> Self {
Self {
max_price_change_per_hour: dec!(0.5), max_user_volume_per_hour: dec!(1000), max_user_volume_per_day: dec!(5000), cooldown_seconds: 300, min_trade_interval_ms: 100, volume_spike_threshold: dec!(3), pattern_detection_window: 100, }
}
}
impl PriceCircuitBreakerConfig {
pub fn strict() -> Self {
Self {
max_price_change_per_hour: dec!(0.25),
max_user_volume_per_hour: dec!(500),
max_user_volume_per_day: dec!(2000),
cooldown_seconds: 600,
min_trade_interval_ms: 500,
volume_spike_threshold: dec!(2),
pattern_detection_window: 200,
}
}
pub fn lenient() -> Self {
Self {
max_price_change_per_hour: dec!(1.0), max_user_volume_per_hour: dec!(5000),
max_user_volume_per_day: dec!(20000),
cooldown_seconds: 120,
min_trade_interval_ms: 50,
volume_spike_threshold: dec!(5),
pattern_detection_window: 50,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircuitBreakerState {
pub token_id: Uuid,
pub is_triggered: bool,
pub trigger_reason: Option<CircuitBreakerReason>,
pub triggered_at: Option<i64>,
pub cooldown_ends_at: Option<i64>,
pub price_history: Vec<PricePoint>,
pub user_volumes: HashMap<Uuid, UserVolumeStats>,
pub recent_trades: Vec<TradeRecord>,
}
impl CircuitBreakerState {
pub fn new(token_id: Uuid) -> Self {
Self {
token_id,
is_triggered: false,
trigger_reason: None,
triggered_at: None,
cooldown_ends_at: None,
price_history: Vec::with_capacity(60), user_volumes: HashMap::new(),
recent_trades: Vec::with_capacity(100),
}
}
pub fn is_in_cooldown(&self, now: i64) -> bool {
if let Some(cooldown_end) = self.cooldown_ends_at {
now < cooldown_end
} else {
false
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum CircuitBreakerReason {
ExcessivePriceChange {
change_percent: String,
},
UserVolumeLimitExceeded {
user_id: Uuid,
volume: String,
},
VolumeSpikeDetected {
multiplier: String,
},
RapidTrading {
user_id: Uuid,
trades_per_second: String,
},
WashTradingDetected {
users: Vec<Uuid>,
},
ManualTrigger {
admin_id: Uuid,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PricePoint {
pub price: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserVolumeStats {
pub hourly_volume: Decimal,
pub daily_volume: Decimal,
pub last_trade_at: i64,
pub trades_last_minute: u32,
pub hour_start: i64,
pub day_start: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TradeRecord {
pub trade_id: Uuid,
pub buyer_id: Uuid,
pub seller_id: Option<Uuid>,
pub amount: Decimal,
pub price: Decimal,
pub timestamp: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct CheckResult {
pub allowed: bool,
pub reason: Option<CircuitBreakerReason>,
pub warnings: Vec<String>,
}
impl CheckResult {
pub fn allowed() -> Self {
Self {
allowed: true,
reason: None,
warnings: Vec::new(),
}
}
pub fn blocked(reason: CircuitBreakerReason) -> Self {
Self {
allowed: false,
reason: Some(reason),
warnings: Vec::new(),
}
}
pub fn with_warning(mut self, warning: impl Into<String>) -> Self {
self.warnings.push(warning.into());
self
}
}
pub struct PriceCircuitBreaker {
config: PriceCircuitBreakerConfig,
states: HashMap<Uuid, CircuitBreakerState>,
}
impl PriceCircuitBreaker {
pub fn new(config: PriceCircuitBreakerConfig) -> Self {
Self {
config,
states: HashMap::new(),
}
}
pub fn with_default_config() -> Self {
Self::new(PriceCircuitBreakerConfig::default())
}
pub fn get_state(&mut self, token_id: Uuid) -> &mut CircuitBreakerState {
self.states
.entry(token_id)
.or_insert_with(|| CircuitBreakerState::new(token_id))
}
pub fn check_trade(
&mut self,
token_id: Uuid,
user_id: Uuid,
amount: Decimal,
price: Decimal,
) -> CheckResult {
let now = chrono::Utc::now().timestamp();
let now_ms = chrono::Utc::now().timestamp_millis();
let config = self.config.clone();
let state = self.get_state(token_id);
if state.is_triggered && state.is_in_cooldown(now) {
return CheckResult::blocked(state.trigger_reason.clone().unwrap_or(
CircuitBreakerReason::ManualTrigger {
admin_id: Uuid::nil(),
},
));
}
if state.is_triggered && !state.is_in_cooldown(now) {
state.is_triggered = false;
state.trigger_reason = None;
state.triggered_at = None;
state.cooldown_ends_at = None;
}
let mut result = CheckResult::allowed();
if let Some(reason) = Self::check_price_change_static(state, price, now, &config) {
return Self::trigger_static(state, reason, now, &config);
}
if let Some(reason) = Self::check_user_volume_static(state, user_id, amount, &config) {
return Self::trigger_static(state, reason, now, &config);
}
if let Some(reason) = Self::check_trade_frequency_static(state, user_id, now_ms, &config) {
return Self::trigger_static(state, reason, now, &config);
}
if let Some(reason) = Self::check_volume_spike_static(state, amount, &config) {
result = result.with_warning(format!("Volume spike detected: {:?}", reason));
}
result
}
pub fn record_trade(&mut self, token_id: Uuid, trade: TradeRecord) {
let now = chrono::Utc::now().timestamp();
let pattern_window = self.config.pattern_detection_window;
let state = self.get_state(token_id);
state.price_history.push(PricePoint {
price: trade.price,
timestamp: trade.timestamp,
});
let hour_ago = now - 3600;
state.price_history.retain(|p| p.timestamp > hour_ago);
let user_stats = state.user_volumes.entry(trade.buyer_id).or_default();
Self::update_user_stats_static(user_stats, trade.amount, trade.timestamp);
state.recent_trades.push(trade);
if state.recent_trades.len() > pattern_window {
state.recent_trades.remove(0);
}
}
fn check_price_change_static(
state: &CircuitBreakerState,
current_price: Decimal,
now: i64,
config: &PriceCircuitBreakerConfig,
) -> Option<CircuitBreakerReason> {
let hour_ago = now - 3600;
let old_price = state
.price_history
.iter()
.rfind(|p| p.timestamp <= hour_ago)
.or_else(|| state.price_history.first());
if let Some(old_point) = old_price {
if old_point.price > Decimal::ZERO {
let change = (current_price - old_point.price).abs() / old_point.price;
if change > config.max_price_change_per_hour {
return Some(CircuitBreakerReason::ExcessivePriceChange {
change_percent: format!("{:.2}%", change * dec!(100)),
});
}
}
}
None
}
fn check_user_volume_static(
state: &CircuitBreakerState,
user_id: Uuid,
amount: Decimal,
config: &PriceCircuitBreakerConfig,
) -> Option<CircuitBreakerReason> {
if let Some(stats) = state.user_volumes.get(&user_id) {
if stats.hourly_volume + amount > config.max_user_volume_per_hour {
return Some(CircuitBreakerReason::UserVolumeLimitExceeded {
user_id,
volume: format!("{} (hourly)", stats.hourly_volume + amount),
});
}
if stats.daily_volume + amount > config.max_user_volume_per_day {
return Some(CircuitBreakerReason::UserVolumeLimitExceeded {
user_id,
volume: format!("{} (daily)", stats.daily_volume + amount),
});
}
}
None
}
fn check_trade_frequency_static(
state: &CircuitBreakerState,
user_id: Uuid,
now_ms: i64,
config: &PriceCircuitBreakerConfig,
) -> Option<CircuitBreakerReason> {
if let Some(stats) = state.user_volumes.get(&user_id) {
let time_since_last = now_ms - stats.last_trade_at;
if time_since_last < config.min_trade_interval_ms as i64 && stats.last_trade_at > 0 {
let trades_per_second = 1000.0 / time_since_last.max(1) as f64;
return Some(CircuitBreakerReason::RapidTrading {
user_id,
trades_per_second: format!("{:.2}", trades_per_second),
});
}
}
None
}
fn check_volume_spike_static(
state: &CircuitBreakerState,
amount: Decimal,
config: &PriceCircuitBreakerConfig,
) -> Option<CircuitBreakerReason> {
if state.recent_trades.len() < 10 {
return None; }
let total_volume: Decimal = state.recent_trades.iter().map(|t| t.amount).sum();
let avg_volume = total_volume / Decimal::from(state.recent_trades.len());
if avg_volume > Decimal::ZERO && amount > avg_volume * config.volume_spike_threshold {
return Some(CircuitBreakerReason::VolumeSpikeDetected {
multiplier: format!("{:.2}x", amount / avg_volume),
});
}
None
}
fn update_user_stats_static(stats: &mut UserVolumeStats, amount: Decimal, timestamp: i64) {
let hour_start = timestamp - (timestamp % 3600);
let day_start = timestamp - (timestamp % 86400);
if hour_start != stats.hour_start {
stats.hourly_volume = Decimal::ZERO;
stats.hour_start = hour_start;
}
if day_start != stats.day_start {
stats.daily_volume = Decimal::ZERO;
stats.day_start = day_start;
}
stats.hourly_volume += amount;
stats.daily_volume += amount;
stats.last_trade_at = timestamp;
}
fn trigger_static(
state: &mut CircuitBreakerState,
reason: CircuitBreakerReason,
now: i64,
config: &PriceCircuitBreakerConfig,
) -> CheckResult {
state.is_triggered = true;
state.trigger_reason = Some(reason.clone());
state.triggered_at = Some(now);
state.cooldown_ends_at = Some(now + config.cooldown_seconds as i64);
tracing::warn!(
token_id = %state.token_id,
reason = ?reason,
"Price circuit breaker triggered"
);
CheckResult::blocked(reason)
}
pub fn manual_trigger(&mut self, token_id: Uuid, admin_id: Uuid) -> CheckResult {
let now = chrono::Utc::now().timestamp();
let cooldown_seconds = self.config.cooldown_seconds;
let reason = CircuitBreakerReason::ManualTrigger { admin_id };
let state = self.get_state(token_id);
state.is_triggered = true;
state.trigger_reason = Some(reason.clone());
state.triggered_at = Some(now);
state.cooldown_ends_at = Some(now + cooldown_seconds as i64);
tracing::warn!(
token_id = %state.token_id,
reason = ?reason,
"Price circuit breaker triggered"
);
CheckResult::blocked(reason)
}
pub fn reset(&mut self, token_id: Uuid) {
if let Some(state) = self.states.get_mut(&token_id) {
state.is_triggered = false;
state.trigger_reason = None;
state.triggered_at = None;
state.cooldown_ends_at = None;
}
}
pub fn get_status(&self, token_id: Uuid) -> Option<&CircuitBreakerState> {
self.states.get(&token_id)
}
}
impl Default for PriceCircuitBreaker {
fn default() -> Self {
Self::with_default_config()
}
}
#[cfg(test)]
mod tests {
use super::*;
use rust_decimal_macros::dec;
fn make_token_id() -> Uuid {
Uuid::new_v4()
}
fn make_user_id() -> Uuid {
Uuid::new_v4()
}
#[test]
fn test_default_config_values() {
let config = PriceCircuitBreakerConfig::default();
assert_eq!(config.max_price_change_per_hour, dec!(0.5));
assert_eq!(config.max_user_volume_per_hour, dec!(1000));
assert_eq!(config.max_user_volume_per_day, dec!(5000));
assert_eq!(config.cooldown_seconds, 300);
assert_eq!(config.min_trade_interval_ms, 100);
assert_eq!(config.volume_spike_threshold, dec!(3));
assert_eq!(config.pattern_detection_window, 100);
}
#[test]
fn test_strict_config_is_tighter_than_default() {
let strict = PriceCircuitBreakerConfig::strict();
let default = PriceCircuitBreakerConfig::default();
assert!(strict.max_price_change_per_hour < default.max_price_change_per_hour);
assert!(strict.max_user_volume_per_hour < default.max_user_volume_per_hour);
assert!(strict.cooldown_seconds > default.cooldown_seconds);
assert!(strict.min_trade_interval_ms > default.min_trade_interval_ms);
}
#[test]
fn test_lenient_config_is_looser_than_default() {
let lenient = PriceCircuitBreakerConfig::lenient();
let default = PriceCircuitBreakerConfig::default();
assert!(lenient.max_price_change_per_hour > default.max_price_change_per_hour);
assert!(lenient.max_user_volume_per_hour > default.max_user_volume_per_hour);
assert!(lenient.cooldown_seconds < default.cooldown_seconds);
}
#[test]
fn test_trade_allowed_when_no_history() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let user_id = make_user_id();
let result = cb.check_trade(token_id, user_id, dec!(10), dec!(1.0));
assert!(
result.allowed,
"Trade with no prior history must be allowed"
);
assert!(result.reason.is_none());
}
#[test]
fn test_is_in_cooldown_while_active() {
let token_id = make_token_id();
let mut state = CircuitBreakerState::new(token_id);
let now = chrono::Utc::now().timestamp();
state.cooldown_ends_at = Some(now + 3600);
assert!(
state.is_in_cooldown(now),
"State must report in-cooldown when end is in the future"
);
}
#[test]
fn test_is_in_cooldown_after_expiry() {
let token_id = make_token_id();
let mut state = CircuitBreakerState::new(token_id);
let now = chrono::Utc::now().timestamp();
state.cooldown_ends_at = Some(now - 1);
assert!(
!state.is_in_cooldown(now),
"State must not report in-cooldown when end is in the past"
);
}
#[test]
fn test_is_in_cooldown_when_none() {
let token_id = make_token_id();
let state = CircuitBreakerState::new(token_id);
let now = chrono::Utc::now().timestamp();
assert!(
!state.is_in_cooldown(now),
"Newly created state must not be in cooldown"
);
}
#[test]
fn test_manual_trigger_blocks_trade() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let admin_id = make_user_id();
let trigger_result = cb.manual_trigger(token_id, admin_id);
assert!(!trigger_result.allowed, "Manual trigger must block trades");
let user_id = make_user_id();
let check_result = cb.check_trade(token_id, user_id, dec!(1), dec!(1.0));
assert!(
!check_result.allowed,
"Trade after manual trigger must be blocked during cooldown"
);
}
#[test]
fn test_reset_after_manual_trigger_allows_trade() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let admin_id = make_user_id();
cb.manual_trigger(token_id, admin_id);
cb.reset(token_id);
let user_id = make_user_id();
let result = cb.check_trade(token_id, user_id, dec!(1), dec!(1.0));
assert!(
result.allowed,
"Trade after reset must be allowed even within original cooldown window"
);
}
#[test]
fn test_manual_trigger_reason_is_manual_trigger() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let admin_id = make_user_id();
let result = cb.manual_trigger(token_id, admin_id);
assert!(
matches!(
result.reason,
Some(CircuitBreakerReason::ManualTrigger { .. })
),
"Manual trigger must produce ManualTrigger reason"
);
}
#[test]
fn test_price_change_triggers_circuit_breaker() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let user_id = make_user_id();
let now = chrono::Utc::now().timestamp();
let two_hours_ago = now - 7200;
let state = cb.get_state(token_id);
state.price_history.push(PricePoint {
price: dec!(1.0),
timestamp: two_hours_ago,
});
let result = cb.check_trade(token_id, user_id, dec!(1), dec!(2.0));
assert!(
!result.allowed,
"A 100% price jump must trigger the circuit breaker (threshold 50%)"
);
assert!(
matches!(
result.reason,
Some(CircuitBreakerReason::ExcessivePriceChange { .. })
),
"Reason must be ExcessivePriceChange"
);
}
#[test]
fn test_small_price_change_does_not_trigger() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let user_id = make_user_id();
let now = chrono::Utc::now().timestamp();
let two_hours_ago = now - 7200;
let state = cb.get_state(token_id);
state.price_history.push(PricePoint {
price: dec!(1.0),
timestamp: two_hours_ago,
});
let result = cb.check_trade(token_id, user_id, dec!(1), dec!(1.05));
assert!(
result.allowed,
"A 5% price change must not trigger the circuit breaker"
);
}
#[test]
fn test_user_volume_limit_triggers_circuit_breaker() {
let config = PriceCircuitBreakerConfig {
max_user_volume_per_hour: dec!(100),
max_user_volume_per_day: dec!(200),
..PriceCircuitBreakerConfig::default()
};
let mut cb = PriceCircuitBreaker::new(config);
let token_id = make_token_id();
let user_id = make_user_id();
let now = chrono::Utc::now().timestamp();
cb.record_trade(
token_id,
TradeRecord {
trade_id: Uuid::new_v4(),
buyer_id: user_id,
seller_id: None,
amount: dec!(90),
price: dec!(1.0),
timestamp: now,
},
);
let result = cb.check_trade(token_id, user_id, dec!(20), dec!(1.0));
assert!(
!result.allowed,
"Exceeding hourly user volume must trigger the circuit breaker"
);
assert!(
matches!(
result.reason,
Some(CircuitBreakerReason::UserVolumeLimitExceeded { .. })
),
"Reason must be UserVolumeLimitExceeded"
);
}
#[test]
fn test_user_volume_within_limit_is_allowed() {
let config = PriceCircuitBreakerConfig {
max_user_volume_per_hour: dec!(100),
max_user_volume_per_day: dec!(200),
..PriceCircuitBreakerConfig::default()
};
let mut cb = PriceCircuitBreaker::new(config);
let token_id = make_token_id();
let user_id = make_user_id();
let now = chrono::Utc::now().timestamp();
cb.record_trade(
token_id,
TradeRecord {
trade_id: Uuid::new_v4(),
buyer_id: user_id,
seller_id: None,
amount: dec!(50),
price: dec!(1.0),
timestamp: now,
},
);
let result = cb.check_trade(token_id, user_id, dec!(30), dec!(1.0));
assert!(
result.allowed,
"Total volume of 80 must be under the 100 hourly limit"
);
}
#[test]
fn test_volume_spike_generates_warning_but_allows_trade() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let now = chrono::Utc::now().timestamp();
for _ in 0..10 {
let uid = make_user_id();
cb.record_trade(
token_id,
TradeRecord {
trade_id: Uuid::new_v4(),
buyer_id: uid,
seller_id: None,
amount: dec!(1),
price: dec!(1.0),
timestamp: now,
},
);
}
let new_user = make_user_id();
let result = cb.check_trade(token_id, new_user, dec!(100), dec!(1.0));
assert!(
result.allowed,
"Volume spike must not block the trade — only generate a warning"
);
assert!(
!result.warnings.is_empty(),
"Volume spike must produce at least one warning"
);
}
#[test]
fn test_get_status_returns_none_for_unknown_token() {
let cb = PriceCircuitBreaker::with_default_config();
let unknown_token = make_token_id();
assert!(
cb.get_status(unknown_token).is_none(),
"get_status must return None for a token that was never accessed"
);
}
#[test]
fn test_get_status_returns_state_after_manual_trigger() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let admin_id = make_user_id();
cb.manual_trigger(token_id, admin_id);
let status = cb
.get_status(token_id)
.expect("get_status must return Some after manual trigger");
assert!(status.is_triggered, "State must be triggered");
assert!(
status.trigger_reason.is_some(),
"Trigger reason must be set"
);
assert!(status.triggered_at.is_some(), "triggered_at must be set");
assert!(
status.cooldown_ends_at.is_some(),
"cooldown_ends_at must be set"
);
}
#[test]
fn test_record_trade_populates_price_history() {
let mut cb = PriceCircuitBreaker::with_default_config();
let token_id = make_token_id();
let user_id = make_user_id();
let now = chrono::Utc::now().timestamp();
cb.record_trade(
token_id,
TradeRecord {
trade_id: Uuid::new_v4(),
buyer_id: user_id,
seller_id: None,
amount: dec!(10),
price: dec!(1.5),
timestamp: now,
},
);
let status = cb
.get_status(token_id)
.expect("state must exist after record_trade");
assert_eq!(
status.price_history.len(),
1,
"Price history must contain exactly the recorded trade's price point"
);
assert_eq!(
status.price_history[0].price,
dec!(1.5),
"Recorded price must match"
);
}
}