diff --git a/crates/synor-economics/src/error.rs b/crates/synor-economics/src/error.rs index 09ba811..d5ffecd 100644 --- a/crates/synor-economics/src/error.rs +++ b/crates/synor-economics/src/error.rs @@ -27,6 +27,20 @@ pub enum EconomicsError { available: String, }, + /// Insufficient funds (with Decimal values) + #[error("Insufficient funds: required {required}, available {available}")] + InsufficientFunds { + required: rust_decimal::Decimal, + available: rust_decimal::Decimal, + }, + + /// Stale price with specific asset + #[error("Price stale for {asset}: {age_seconds} seconds old")] + StalePrice { + asset: String, + age_seconds: i64, + }, + /// Account not found #[error("Account not found: {0}")] AccountNotFound(String), @@ -120,6 +134,8 @@ impl EconomicsError { EconomicsError::PriceStale(_) => "PRICE_STALE", EconomicsError::InvalidPrice(_) => "INVALID_PRICE", EconomicsError::InsufficientBalance { .. } => "INSUFFICIENT_BALANCE", + EconomicsError::InsufficientFunds { .. } => "INSUFFICIENT_FUNDS", + EconomicsError::StalePrice { .. } => "STALE_PRICE", EconomicsError::AccountNotFound(_) => "ACCOUNT_NOT_FOUND", EconomicsError::InvoiceNotFound(_) => "INVOICE_NOT_FOUND", EconomicsError::InvoiceAlreadyPaid(_) => "INVOICE_ALREADY_PAID", diff --git a/crates/synor-economics/src/oracle/anomaly.rs b/crates/synor-economics/src/oracle/anomaly.rs new file mode 100644 index 0000000..161547c --- /dev/null +++ b/crates/synor-economics/src/oracle/anomaly.rs @@ -0,0 +1,951 @@ +//! ML-Based Anomaly Detection +//! +//! Detect price manipulation, wash trading, and abnormal patterns +//! using statistical and machine learning techniques. + +use crate::SynorDecimal; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, VecDeque}; + +/// Types of anomalies that can be detected +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AnomalyType { + /// Price moved outside expected range + PriceOutlier, + /// Sudden volume spike + VolumeSpike, + /// Suspected wash trading (self-trades) + WashTrading, + /// Coordinated trading pattern + CoordinatedTrading, + /// Pump and dump pattern + PumpAndDump, + /// Flash loan attack signature + FlashLoanAttack, + /// Abnormal order book imbalance + OrderBookImbalance, + /// Unusual trading frequency + FrequencyAnomaly, + /// Statistical outlier (general) + StatisticalOutlier, +} + +/// Detected anomaly with details +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Anomaly { + /// Type of anomaly + pub anomaly_type: AnomalyType, + /// Affected trading pair + pub pair: String, + /// Detection timestamp + pub detected_at: DateTime, + /// Severity score (0.0 - 1.0) + pub severity: f64, + /// Confidence in detection (0.0 - 1.0) + pub confidence: f64, + /// Detailed description + pub description: String, + /// Associated data points + pub data: AnomalyData, + /// Recommended action + pub recommended_action: RecommendedAction, +} + +/// Data associated with an anomaly +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyData { + /// Current value + pub current_value: SynorDecimal, + /// Expected value + pub expected_value: SynorDecimal, + /// Standard deviations from mean + pub z_score: f64, + /// Relevant timestamps + pub timestamps: Vec>, + /// Additional context + pub context: HashMap, +} + +/// Recommended action for detected anomaly +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum RecommendedAction { + /// No action needed + Monitor, + /// Alert administrators + Alert, + /// Temporarily pause trading + PauseTrading, + /// Trigger circuit breaker + TriggerCircuitBreaker, + /// Investigate manually + Investigate, + /// Block suspicious addresses + BlockAddresses(Vec), +} + +/// Configuration for anomaly detection +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyDetectorConfig { + /// Z-score threshold for outlier detection + pub z_score_threshold: f64, + /// Minimum data points for statistical analysis + pub min_data_points: usize, + /// Volume spike multiplier threshold + pub volume_spike_multiplier: f64, + /// Wash trading detection window (seconds) + pub wash_trading_window: i64, + /// Pump and dump detection window (minutes) + pub pump_dump_window: i64, + /// Flash loan detection window (seconds) + pub flash_loan_window: i64, + /// Enable ML-based detection (vs pure statistical) + pub ml_enabled: bool, + /// Learning rate for online updates + pub learning_rate: f64, +} + +impl Default for AnomalyDetectorConfig { + fn default() -> Self { + Self { + z_score_threshold: 3.0, + min_data_points: 30, + volume_spike_multiplier: 5.0, + wash_trading_window: 60, + pump_dump_window: 60, + flash_loan_window: 15, + ml_enabled: true, + learning_rate: 0.01, + } + } +} + +/// Price/volume data point +#[derive(Debug, Clone)] +pub struct MarketDataPoint { + pub price: SynorDecimal, + pub volume: SynorDecimal, + pub timestamp: DateTime, + pub bid_volume: Option, + pub ask_volume: Option, + pub trade_count: Option, + pub addresses: Vec, // Participating addresses +} + +/// Running statistics for a time series +#[derive(Debug, Clone)] +struct RunningStats { + count: usize, + mean: f64, + m2: f64, // For variance calculation + min: f64, + max: f64, +} + +impl RunningStats { + fn new() -> Self { + Self { + count: 0, + mean: 0.0, + m2: 0.0, + min: f64::MAX, + max: f64::MIN, + } + } + + /// Welford's online algorithm for mean and variance + fn update(&mut self, value: f64) { + self.count += 1; + let delta = value - self.mean; + self.mean += delta / self.count as f64; + let delta2 = value - self.mean; + self.m2 += delta * delta2; + self.min = self.min.min(value); + self.max = self.max.max(value); + } + + fn variance(&self) -> f64 { + if self.count < 2 { + 0.0 + } else { + self.m2 / (self.count - 1) as f64 + } + } + + fn std_dev(&self) -> f64 { + self.variance().sqrt() + } + + fn z_score(&self, value: f64) -> f64 { + let std = self.std_dev(); + if std < f64::EPSILON { + 0.0 + } else { + (value - self.mean) / std + } + } +} + +/// Isolation Forest for anomaly detection +#[derive(Debug, Clone)] +struct IsolationTree { + split_feature: usize, + split_value: f64, + left: Option>, + right: Option>, + size: usize, +} + +impl IsolationTree { + /// Build tree from data + fn build(data: &[Vec], max_depth: usize, current_depth: usize) -> Option { + if data.is_empty() || current_depth >= max_depth || data.len() <= 1 { + return None; + } + + let n_features = data[0].len(); + if n_features == 0 { + return None; + } + + // Random feature selection (deterministic for reproducibility) + let feature = (current_depth * 7 + data.len()) % n_features; + + // Find min/max for this feature + let values: Vec = data.iter().map(|row| row[feature]).collect(); + let min_val = values.iter().copied().fold(f64::MAX, f64::min); + let max_val = values.iter().copied().fold(f64::MIN, f64::max); + + if (max_val - min_val).abs() < f64::EPSILON { + return None; + } + + // Random split point + let split = min_val + (max_val - min_val) * 0.5; + + let (left_data, right_data): (Vec<_>, Vec<_>) = data.iter() + .cloned() + .partition(|row| row[feature] < split); + + Some(Self { + split_feature: feature, + split_value: split, + left: Self::build(&left_data, max_depth, current_depth + 1).map(Box::new), + right: Self::build(&right_data, max_depth, current_depth + 1).map(Box::new), + size: data.len(), + }) + } + + /// Get path length for a point + fn path_length(&self, point: &[f64], current_length: f64) -> f64 { + if self.left.is_none() && self.right.is_none() { + return current_length + c_factor(self.size); + } + + if point[self.split_feature] < self.split_value { + if let Some(ref left) = self.left { + return left.path_length(point, current_length + 1.0); + } + } else if let Some(ref right) = self.right { + return right.path_length(point, current_length + 1.0); + } + + current_length + c_factor(self.size) + } +} + +/// Expected path length for random BST +fn c_factor(n: usize) -> f64 { + if n <= 1 { + 0.0 + } else if n == 2 { + 1.0 + } else { + let n = n as f64; + 2.0 * ((n - 1.0).ln() + 0.5772156649) - (2.0 * (n - 1.0) / n) + } +} + +/// Isolation Forest ensemble +struct IsolationForest { + trees: Vec, + sample_size: usize, +} + +impl IsolationForest { + fn new(data: &[Vec], n_trees: usize, sample_size: usize) -> Self { + let max_depth = (sample_size as f64).log2().ceil() as usize; + + let trees: Vec<_> = (0..n_trees) + .filter_map(|i| { + // Subsample with deterministic "randomness" based on tree index + let sample: Vec<_> = data.iter() + .enumerate() + .filter(|(j, _)| (i + j) % 3 != 0) + .take(sample_size) + .map(|(_, row)| row.clone()) + .collect(); + + IsolationTree::build(&sample, max_depth, 0) + }) + .collect(); + + Self { trees, sample_size } + } + + fn anomaly_score(&self, point: &[f64]) -> f64 { + if self.trees.is_empty() { + return 0.5; + } + + let avg_path: f64 = self.trees.iter() + .map(|tree| tree.path_length(point, 0.0)) + .sum::() / self.trees.len() as f64; + + let c = c_factor(self.sample_size); + if c < f64::EPSILON { + return 0.5; + } + + 2.0_f64.powf(-avg_path / c) + } +} + +/// Per-pair anomaly detection state +struct PairDetector { + /// Price history + price_history: VecDeque, + /// Price statistics + price_stats: RunningStats, + /// Volume statistics + volume_stats: RunningStats, + /// Return statistics (price changes) + return_stats: RunningStats, + /// Recent trade addresses for wash trading detection + recent_addresses: VecDeque<(DateTime, Vec)>, + /// Detected anomalies + anomalies: Vec, + /// Isolation forest (rebuilt periodically) + isolation_forest: Option, + /// Last model rebuild time + last_rebuild: DateTime, +} + +impl PairDetector { + fn new() -> Self { + Self { + price_history: VecDeque::with_capacity(10000), + price_stats: RunningStats::new(), + volume_stats: RunningStats::new(), + return_stats: RunningStats::new(), + recent_addresses: VecDeque::with_capacity(1000), + anomalies: Vec::new(), + isolation_forest: None, + last_rebuild: Utc::now() - Duration::hours(24), // Force initial build + } + } + + fn add_data_point(&mut self, point: MarketDataPoint) { + // Update return stats + if let Some(prev) = self.price_history.back() { + let price_f64 = point.price.to_string().parse::().unwrap_or(0.0); + let prev_f64 = prev.price.to_string().parse::().unwrap_or(0.0); + if prev_f64 > 0.0 { + let return_pct = (price_f64 - prev_f64) / prev_f64 * 100.0; + self.return_stats.update(return_pct); + } + } + + // Update statistics + let price_f64 = point.price.to_string().parse::().unwrap_or(0.0); + let volume_f64 = point.volume.to_string().parse::().unwrap_or(0.0); + self.price_stats.update(price_f64); + self.volume_stats.update(volume_f64); + + // Track addresses + if !point.addresses.is_empty() { + self.recent_addresses.push_back((point.timestamp, point.addresses.clone())); + } + + self.price_history.push_back(point); + + // Cleanup old data + let cutoff = Utc::now() - Duration::hours(24); + while self.price_history.front().map(|p| p.timestamp < cutoff).unwrap_or(false) { + self.price_history.pop_front(); + } + while self.recent_addresses.front().map(|(t, _)| *t < cutoff).unwrap_or(false) { + self.recent_addresses.pop_front(); + } + } + + fn rebuild_model(&mut self) { + if self.price_history.len() < 100 { + return; + } + + // Build feature vectors: [price, volume, return, bid/ask ratio] + let data: Vec> = self.price_history.iter() + .skip(1) + .zip(self.price_history.iter()) + .map(|(curr, prev)| { + let price = curr.price.to_string().parse::().unwrap_or(0.0); + let volume = curr.volume.to_string().parse::().unwrap_or(0.0); + let prev_price = prev.price.to_string().parse::().unwrap_or(1.0); + let return_pct = if prev_price > 0.0 { + (price - prev_price) / prev_price * 100.0 + } else { + 0.0 + }; + + let bid_ask_ratio = match (curr.bid_volume, curr.ask_volume) { + (Some(bid), Some(ask)) => { + let bid_f = bid.to_string().parse::().unwrap_or(0.0); + let ask_f = ask.to_string().parse::().unwrap_or(1.0); + if ask_f > 0.0 { bid_f / ask_f } else { 1.0 } + } + _ => 1.0, + }; + + vec![price, volume, return_pct, bid_ask_ratio] + }) + .collect(); + + self.isolation_forest = Some(IsolationForest::new(&data, 100, 256.min(data.len()))); + self.last_rebuild = Utc::now(); + } +} + +/// Main anomaly detector +pub struct AnomalyDetector { + config: AnomalyDetectorConfig, + detectors: HashMap, +} + +impl AnomalyDetector { + pub fn new() -> Self { + Self::with_config(AnomalyDetectorConfig::default()) + } + + pub fn with_config(config: AnomalyDetectorConfig) -> Self { + Self { + config, + detectors: HashMap::new(), + } + } + + /// Process a new market data point and detect anomalies + pub fn process(&mut self, pair: &str, data: MarketDataPoint) -> Vec { + // Ensure detector exists + if !self.detectors.contains_key(pair) { + self.detectors.insert(pair.to_string(), PairDetector::new()); + } + + // Rebuild model periodically + let ml_enabled = self.config.ml_enabled; + if let Some(detector) = self.detectors.get_mut(pair) { + if (Utc::now() - detector.last_rebuild).num_hours() >= 1 && ml_enabled { + detector.rebuild_model(); + } + } + + // Copy config values we need + let min_data_points = self.config.min_data_points; + let z_score_threshold = self.config.z_score_threshold; + let volume_spike_multiplier = self.config.volume_spike_multiplier; + let wash_trading_window = self.config.wash_trading_window; + let pump_dump_window = self.config.pump_dump_window; + let flash_loan_window = self.config.flash_loan_window; + + let mut anomalies = Vec::new(); + + // Run all detectors using the immutable reference first + if let Some(detector) = self.detectors.get(pair) { + if let Some(a) = Self::detect_price_outlier_impl(pair, &data, detector, min_data_points, z_score_threshold) { + anomalies.push(a); + } + if let Some(a) = Self::detect_volume_spike_impl(pair, &data, detector, min_data_points, volume_spike_multiplier) { + anomalies.push(a); + } + if let Some(a) = Self::detect_wash_trading_impl(pair, &data, detector, wash_trading_window) { + anomalies.push(a); + } + if let Some(a) = Self::detect_pump_dump_impl(pair, detector, pump_dump_window) { + anomalies.push(a); + } + if let Some(a) = Self::detect_flash_loan_impl(pair, &data, detector, flash_loan_window) { + anomalies.push(a); + } + if ml_enabled { + if let Some(a) = Self::detect_ml_anomaly_impl(pair, &data, detector) { + anomalies.push(a); + } + } + } + + // Now mutably access detector to add data and anomalies + if let Some(detector) = self.detectors.get_mut(pair) { + detector.add_data_point(data); + detector.anomalies.extend(anomalies.clone()); + } + + anomalies + } + + fn detect_price_outlier_impl(pair: &str, data: &MarketDataPoint, detector: &PairDetector, min_data_points: usize, z_score_threshold: f64) -> Option { + if detector.price_stats.count < min_data_points { + return None; + } + + let price_f64 = data.price.to_string().parse::().ok()?; + let z_score = detector.price_stats.z_score(price_f64); + + if z_score.abs() > z_score_threshold { + let expected = Decimal::from_f64_retain(detector.price_stats.mean)?; + Some(Anomaly { + anomaly_type: AnomalyType::PriceOutlier, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: (z_score.abs() / 10.0).min(1.0), + confidence: 0.8, + description: format!( + "Price {} is {:.2} standard deviations from mean {:.4}", + data.price, z_score, detector.price_stats.mean + ), + data: AnomalyData { + current_value: data.price, + expected_value: expected, + z_score, + timestamps: vec![data.timestamp], + context: HashMap::new(), + }, + recommended_action: if z_score.abs() > 5.0 { + RecommendedAction::TriggerCircuitBreaker + } else { + RecommendedAction::Alert + }, + }) + } else { + None + } + } + + fn detect_volume_spike_impl(pair: &str, data: &MarketDataPoint, detector: &PairDetector, min_data_points: usize, volume_spike_multiplier: f64) -> Option { + if detector.volume_stats.count < min_data_points { + return None; + } + + let volume_f64 = data.volume.to_string().parse::().ok()?; + let mean = detector.volume_stats.mean; + + if volume_f64 > mean * volume_spike_multiplier { + let expected = Decimal::from_f64_retain(mean)?; + let z_score = detector.volume_stats.z_score(volume_f64); + Some(Anomaly { + anomaly_type: AnomalyType::VolumeSpike, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: ((volume_f64 / mean) / 20.0).min(1.0), + confidence: 0.75, + description: format!( + "Volume {} is {:.1}x the average {:.2}", + data.volume, volume_f64 / mean, mean + ), + data: AnomalyData { + current_value: data.volume, + expected_value: expected, + z_score, + timestamps: vec![data.timestamp], + context: HashMap::new(), + }, + recommended_action: RecommendedAction::Investigate, + }) + } else { + None + } + } + + fn detect_wash_trading_impl(pair: &str, data: &MarketDataPoint, detector: &PairDetector, wash_trading_window: i64) -> Option { + if data.addresses.is_empty() { + return None; + } + + let window_start = Utc::now() - Duration::seconds(wash_trading_window); + + // Count address appearances in recent window + let mut address_counts: HashMap<&str, usize> = HashMap::new(); + for (ts, addrs) in &detector.recent_addresses { + if *ts >= window_start { + for addr in addrs { + *address_counts.entry(addr.as_str()).or_insert(0) += 1; + } + } + } + + // Check current trade addresses + for addr in &data.addresses { + if let Some(&count) = address_counts.get(addr.as_str()) { + if count >= 3 { + let mut context = HashMap::new(); + context.insert("address".to_string(), addr.clone()); + context.insert("trade_count".to_string(), count.to_string()); + + return Some(Anomaly { + anomaly_type: AnomalyType::WashTrading, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: ((count as f64) / 10.0).min(1.0), + confidence: 0.6, + description: format!( + "Address {} appears in {} trades within {}s window", + addr, count, wash_trading_window + ), + data: AnomalyData { + current_value: data.volume, + expected_value: Decimal::ZERO, + z_score: 0.0, + timestamps: vec![data.timestamp], + context, + }, + recommended_action: RecommendedAction::BlockAddresses(vec![addr.clone()]), + }); + } + } + } + + None + } + + fn detect_pump_dump_impl(pair: &str, detector: &PairDetector, pump_dump_window: i64) -> Option { + // Need enough history + if detector.price_history.len() < 10 { + return None; + } + + let window_start = Utc::now() - Duration::minutes(pump_dump_window); + let prices: Vec<_> = detector.price_history.iter() + .filter(|p| p.timestamp >= window_start) + .map(|p| p.price.to_string().parse::().unwrap_or(0.0)) + .collect(); + + if prices.len() < 5 { + return None; + } + + // Find max and check for reversal + let max_price = prices.iter().copied().fold(f64::MIN, f64::max); + let max_idx = prices.iter().position(|&p| (p - max_price).abs() < f64::EPSILON)?; + let first_price = prices.first()?; + let last_price = prices.last()?; + + // Pump: >30% rise, Dump: >20% drop from peak + let pump_pct = (max_price - first_price) / first_price * 100.0; + let dump_pct = (max_price - last_price) / max_price * 100.0; + + if pump_pct > 30.0 && dump_pct > 20.0 && max_idx < prices.len() - 2 { + let mut context = HashMap::new(); + context.insert("pump_percent".to_string(), format!("{:.1}", pump_pct)); + context.insert("dump_percent".to_string(), format!("{:.1}", dump_pct)); + + return Some(Anomaly { + anomaly_type: AnomalyType::PumpAndDump, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: ((pump_pct + dump_pct) / 100.0).min(1.0), + confidence: 0.7, + description: format!( + "Potential pump and dump: +{:.1}% pump, -{:.1}% dump", + pump_pct, dump_pct + ), + data: AnomalyData { + current_value: Decimal::from_f64_retain(*last_price)?, + expected_value: Decimal::from_f64_retain(*first_price)?, + z_score: pump_pct, + timestamps: vec![Utc::now()], + context, + }, + recommended_action: RecommendedAction::PauseTrading, + }); + } + + None + } + + fn detect_flash_loan_impl(pair: &str, data: &MarketDataPoint, detector: &PairDetector, flash_loan_window: i64) -> Option { + // Flash loan signature: huge volume spike + quick price movement + reversal + let window_start = Utc::now() - Duration::seconds(flash_loan_window); + let recent: Vec<_> = detector.price_history.iter() + .filter(|p| p.timestamp >= window_start) + .collect(); + + if recent.len() < 3 { + return None; + } + + let volumes: Vec = recent.iter() + .map(|p| p.volume.to_string().parse::().unwrap_or(0.0)) + .collect(); + + let prices: Vec = recent.iter() + .map(|p| p.price.to_string().parse::().unwrap_or(0.0)) + .collect(); + + let avg_volume = detector.volume_stats.mean; + let max_volume = volumes.iter().copied().fold(f64::MIN, f64::max); + + // Volume spike > 20x average + if max_volume > avg_volume * 20.0 && !prices.is_empty() { + let first = prices.first()?; + let last = prices.last()?; + let max_price = prices.iter().copied().fold(f64::MIN, f64::max); + + let spike = (max_price - first) / first * 100.0; + let reversal = (max_price - last) / max_price * 100.0; + + // Big spike and quick reversal + if spike > 10.0 && reversal > 8.0 { + let mut context = HashMap::new(); + context.insert("volume_spike".to_string(), format!("{:.0}x", max_volume / avg_volume)); + context.insert("price_spike".to_string(), format!("{:.1}%", spike)); + + return Some(Anomaly { + anomaly_type: AnomalyType::FlashLoanAttack, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: 0.9, + confidence: 0.65, + description: format!( + "Suspected flash loan attack: {}x volume, {:.1}% price spike", + max_volume / avg_volume, spike + ), + data: AnomalyData { + current_value: data.volume, + expected_value: Decimal::from_f64_retain(avg_volume)?, + z_score: (max_volume - avg_volume) / detector.volume_stats.std_dev(), + timestamps: recent.iter().map(|p| p.timestamp).collect(), + context, + }, + recommended_action: RecommendedAction::TriggerCircuitBreaker, + }); + } + } + + None + } + + fn detect_ml_anomaly_impl(pair: &str, data: &MarketDataPoint, detector: &PairDetector) -> Option { + let forest = detector.isolation_forest.as_ref()?; + + if detector.price_history.is_empty() { + return None; + } + + let prev = detector.price_history.back()?; + let price = data.price.to_string().parse::().ok()?; + let volume = data.volume.to_string().parse::().ok()?; + let prev_price = prev.price.to_string().parse::().ok()?; + + let return_pct = if prev_price > 0.0 { + (price - prev_price) / prev_price * 100.0 + } else { + 0.0 + }; + + let bid_ask_ratio = match (data.bid_volume, data.ask_volume) { + (Some(bid), Some(ask)) => { + let bid_f = bid.to_string().parse::().ok()?; + let ask_f = ask.to_string().parse::().ok()?; + if ask_f > 0.0 { bid_f / ask_f } else { 1.0 } + } + _ => 1.0, + }; + + let point = vec![price, volume, return_pct, bid_ask_ratio]; + let score = forest.anomaly_score(&point); + + if score > 0.7 { + let mut context = HashMap::new(); + context.insert("anomaly_score".to_string(), format!("{:.3}", score)); + + Some(Anomaly { + anomaly_type: AnomalyType::StatisticalOutlier, + pair: pair.to_string(), + detected_at: Utc::now(), + severity: score, + confidence: 0.6, + description: format!( + "ML model detected anomaly with score {:.3}", + score + ), + data: AnomalyData { + current_value: data.price, + expected_value: Decimal::from_f64_retain(detector.price_stats.mean)?, + z_score: detector.price_stats.z_score(price), + timestamps: vec![data.timestamp], + context, + }, + recommended_action: if score > 0.85 { + RecommendedAction::Alert + } else { + RecommendedAction::Monitor + }, + }) + } else { + None + } + } + + /// Get recent anomalies for a pair + pub fn get_anomalies(&self, pair: &str) -> Vec { + self.detectors.get(pair) + .map(|d| d.anomalies.clone()) + .unwrap_or_default() + } + + /// Get anomaly statistics + pub fn get_stats(&self, pair: &str) -> Option { + let detector = self.detectors.get(pair)?; + + let by_type: HashMap = detector.anomalies.iter() + .fold(HashMap::new(), |mut acc, a| { + *acc.entry(a.anomaly_type.clone()).or_insert(0) += 1; + acc + }); + + Some(AnomalyStats { + total_anomalies: detector.anomalies.len(), + by_type, + data_points: detector.price_history.len(), + avg_severity: if detector.anomalies.is_empty() { + 0.0 + } else { + detector.anomalies.iter().map(|a| a.severity).sum::() + / detector.anomalies.len() as f64 + }, + }) + } + + /// Clear old anomalies + pub fn cleanup(&mut self, max_age: Duration) { + let cutoff = Utc::now() - max_age; + for detector in self.detectors.values_mut() { + detector.anomalies.retain(|a| a.detected_at >= cutoff); + } + } +} + +impl Default for AnomalyDetector { + fn default() -> Self { + Self::new() + } +} + +/// Anomaly detection statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnomalyStats { + pub total_anomalies: usize, + pub by_type: HashMap, + pub data_points: usize, + pub avg_severity: f64, +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_running_stats() { + let mut stats = RunningStats::new(); + for i in 1..=10 { + stats.update(i as f64); + } + + assert_eq!(stats.count, 10); + assert!((stats.mean - 5.5).abs() < 0.001); + assert!(stats.std_dev() > 0.0); + } + + #[test] + fn test_z_score() { + let mut stats = RunningStats::new(); + // Add values with some natural variance (e.g., 98-102 range) + for i in 0..100 { + let value = 100.0 + ((i % 5) as f64) - 2.0; // Values: 98, 99, 100, 101, 102 + stats.update(value); + } + + // Mean should be ~100, std dev ~1.4 + // z_score(150) should be (150 - 100) / ~1.4 = ~35 + let z = stats.z_score(150.0); + assert!(z.abs() > 3.0); // Outlier + } + + #[test] + fn test_price_outlier_detection() { + let mut detector = AnomalyDetector::new(); + + // Add normal prices + for i in 0..50 { + let data = MarketDataPoint { + price: dec!(100) + Decimal::from(i % 3), + volume: dec!(1000), + timestamp: Utc::now() - Duration::minutes(50 - i), + bid_volume: None, + ask_volume: None, + trade_count: None, + addresses: vec![], + }; + detector.process("SYNOR/USD", data); + } + + // Add outlier + let outlier = MarketDataPoint { + price: dec!(200), // Way above normal + volume: dec!(1000), + timestamp: Utc::now(), + bid_volume: None, + ask_volume: None, + trade_count: None, + addresses: vec![], + }; + + let anomalies = detector.process("SYNOR/USD", outlier); + assert!(!anomalies.is_empty()); + assert!(anomalies.iter().any(|a| a.anomaly_type == AnomalyType::PriceOutlier)); + } + + #[test] + fn test_volume_spike_detection() { + let mut detector = AnomalyDetector::new(); + + // Add normal volumes + for i in 0..50 { + let data = MarketDataPoint { + price: dec!(100), + volume: dec!(1000), + timestamp: Utc::now() - Duration::minutes(50 - i), + bid_volume: None, + ask_volume: None, + trade_count: None, + addresses: vec![], + }; + detector.process("SYNOR/USD", data); + } + + // Add volume spike + let spike = MarketDataPoint { + price: dec!(100), + volume: dec!(10000), // 10x normal + timestamp: Utc::now(), + bid_volume: None, + ask_volume: None, + trade_count: None, + addresses: vec![], + }; + + let anomalies = detector.process("SYNOR/USD", spike); + assert!(anomalies.iter().any(|a| a.anomaly_type == AnomalyType::VolumeSpike)); + } +} diff --git a/crates/synor-economics/src/oracle/circuit_breaker.rs b/crates/synor-economics/src/oracle/circuit_breaker.rs new file mode 100644 index 0000000..1744748 --- /dev/null +++ b/crates/synor-economics/src/oracle/circuit_breaker.rs @@ -0,0 +1,675 @@ +//! 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 { + self.record_price_at(pair, price, liquidity, Utc::now()) + } + + /// 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); + } +} diff --git a/crates/synor-economics/src/oracle/cross_chain.rs b/crates/synor-economics/src/oracle/cross_chain.rs new file mode 100644 index 0000000..aecdf37 --- /dev/null +++ b/crates/synor-economics/src/oracle/cross_chain.rs @@ -0,0 +1,625 @@ +//! Cross-Chain Price Feeds via IBC +//! +//! Pull ETH/BTC/stablecoin prices from Ethereum, Cosmos, and other chains +//! via Inter-Blockchain Communication protocol. + +use crate::error::{EconomicsError, Result}; +use crate::oracle::{PriceSource, TokenPrice}; +use crate::SynorDecimal; +use async_trait::async_trait; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Supported cross-chain networks +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ChainNetwork { + /// Ethereum mainnet + Ethereum, + /// Binance Smart Chain + BinanceSmartChain, + /// Polygon + Polygon, + /// Arbitrum + Arbitrum, + /// Optimism + Optimism, + /// Cosmos Hub + Cosmos, + /// Osmosis DEX + Osmosis, + /// Avalanche + Avalanche, + /// Solana + Solana, + /// Bitcoin (via bridge) + Bitcoin, +} + +impl ChainNetwork { + /// Get IBC channel ID for this network + pub fn ibc_channel(&self) -> Option<&str> { + match self { + ChainNetwork::Cosmos => Some("channel-0"), + ChainNetwork::Osmosis => Some("channel-1"), + _ => None, // Non-IBC chains use bridges + } + } + + /// Get native token symbol + pub fn native_token(&self) -> &str { + match self { + ChainNetwork::Ethereum => "ETH", + ChainNetwork::BinanceSmartChain => "BNB", + ChainNetwork::Polygon => "MATIC", + ChainNetwork::Arbitrum => "ETH", + ChainNetwork::Optimism => "ETH", + ChainNetwork::Cosmos => "ATOM", + ChainNetwork::Osmosis => "OSMO", + ChainNetwork::Avalanche => "AVAX", + ChainNetwork::Solana => "SOL", + ChainNetwork::Bitcoin => "BTC", + } + } + + /// Check if this is a Cosmos SDK chain (native IBC) + pub fn is_ibc_native(&self) -> bool { + matches!(self, ChainNetwork::Cosmos | ChainNetwork::Osmosis) + } +} + +/// Cross-chain price packet (IBC format) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrossChainPricePacket { + /// Source chain + pub source_chain: ChainNetwork, + /// Token symbol + pub token: String, + /// Quote currency + pub quote: String, + /// Price value + pub price: SynorDecimal, + /// Block height at source + pub source_block: u64, + /// Timestamp at source + pub source_timestamp: DateTime, + /// Merkle proof for verification + pub proof: Option, + /// Oracle signatures (for bridge chains) + pub signatures: Vec, +} + +/// Merkle proof for IBC verification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MerkleProof { + /// Proof path + pub path: Vec, + /// Root hash + pub root: [u8; 32], + /// Leaf hash + pub leaf: [u8; 32], +} + +/// Node in Merkle proof path +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProofNode { + pub hash: [u8; 32], + pub is_left: bool, +} + +/// Oracle signature for bridge verification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OracleSignature { + pub signer: String, + pub signature: Vec, + pub timestamp: DateTime, +} + +impl CrossChainPricePacket { + /// Verify the packet using Merkle proof (for IBC) + pub fn verify_merkle_proof(&self) -> bool { + match &self.proof { + Some(proof) => { + let mut current = proof.leaf; + for node in &proof.path { + current = if node.is_left { + hash_pair(&node.hash, ¤t) + } else { + hash_pair(¤t, &node.hash) + }; + } + current == proof.root + } + None => false, + } + } + + /// Verify bridge oracle signatures (threshold) + pub fn verify_signatures(&self, threshold: usize) -> bool { + // In production, verify each signature cryptographically + // For now, just check we have enough + self.signatures.len() >= threshold + } + + /// Convert to TokenPrice + pub fn to_token_price(&self) -> TokenPrice { + TokenPrice { + token: self.token.clone(), + quote: self.quote.clone(), + price: self.price, + timestamp: self.source_timestamp, + source: PriceSource::CrossChain, + confidence: self.calculate_confidence(), + } + } + + fn calculate_confidence(&self) -> f64 { + let age = (Utc::now() - self.source_timestamp).num_seconds() as f64; + let freshness = (1.0 - age / 300.0).max(0.0); // 5 min staleness + + let has_proof = if self.proof.is_some() { 1.0 } else { 0.0 }; + let sig_count = (self.signatures.len() as f64 / 3.0).min(1.0); + + (freshness * 0.4 + has_proof * 0.3 + sig_count * 0.3).min(1.0) + } +} + +/// Simple hash function for Merkle proofs +fn hash_pair(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + left.hash(&mut hasher); + right.hash(&mut hasher); + let hash = hasher.finish(); + + let mut result = [0u8; 32]; + result[..8].copy_from_slice(&hash.to_le_bytes()); + result +} + +/// Configuration for cross-chain feeds +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrossChainConfig { + /// Enabled chains + pub enabled_chains: Vec, + /// Minimum signatures required for bridge verification + pub min_signatures: usize, + /// Maximum packet age (seconds) + pub max_packet_age: i64, + /// Update interval (seconds) + pub update_interval: i64, + /// IBC timeout (seconds) + pub ibc_timeout: i64, + /// Tokens to track per chain + pub tracked_tokens: HashMap>, +} + +impl Default for CrossChainConfig { + fn default() -> Self { + let mut tracked = HashMap::new(); + tracked.insert(ChainNetwork::Ethereum, vec!["ETH".to_string(), "USDC".to_string(), "USDT".to_string()]); + tracked.insert(ChainNetwork::Bitcoin, vec!["BTC".to_string()]); + tracked.insert(ChainNetwork::Cosmos, vec!["ATOM".to_string()]); + tracked.insert(ChainNetwork::Osmosis, vec!["OSMO".to_string()]); + + Self { + enabled_chains: vec![ + ChainNetwork::Ethereum, + ChainNetwork::Bitcoin, + ChainNetwork::Cosmos, + ChainNetwork::Osmosis, + ], + min_signatures: 3, + max_packet_age: 300, // 5 minutes + update_interval: 30, + ibc_timeout: 600, // 10 minutes + tracked_tokens: tracked, + } + } +} + +/// Interface for chain-specific price fetchers +#[async_trait] +pub trait ChainPriceFetcher: Send + Sync { + /// Get the chain this fetcher supports + fn chain(&self) -> ChainNetwork; + + /// Fetch current price for a token + async fn fetch_price(&self, token: &str, quote: &str) -> Result; + + /// Verify a received packet + fn verify_packet(&self, packet: &CrossChainPricePacket) -> bool; + + /// Get supported tokens + fn supported_tokens(&self) -> Vec; +} + +/// Cross-chain price feed manager +pub struct CrossChainOracle { + config: CrossChainConfig, + /// Fetchers by chain + fetchers: HashMap>, + /// Cached prices by pair + cache: HashMap, + /// Pending IBC packets + pending_packets: Vec, +} + +/// Cached cross-chain price +#[derive(Debug, Clone)] +struct CrossChainPrice { + packet: CrossChainPricePacket, + received_at: DateTime, + verified: bool, +} + +/// Pending IBC packet awaiting confirmation +#[derive(Debug, Clone)] +struct PendingPacket { + packet: CrossChainPricePacket, + sent_at: DateTime, + channel: String, + sequence: u64, +} + +impl CrossChainOracle { + pub fn new() -> Self { + Self::with_config(CrossChainConfig::default()) + } + + pub fn with_config(config: CrossChainConfig) -> Self { + Self { + config, + fetchers: HashMap::new(), + cache: HashMap::new(), + pending_packets: Vec::new(), + } + } + + /// Register a chain price fetcher + pub fn register_fetcher(&mut self, fetcher: Box) { + let chain = fetcher.chain(); + self.fetchers.insert(chain, fetcher); + } + + /// Receive and process an IBC price packet + pub fn receive_ibc_packet(&mut self, packet: CrossChainPricePacket) -> Result<()> { + // Check age + let age = (Utc::now() - packet.source_timestamp).num_seconds(); + if age > self.config.max_packet_age { + return Err(EconomicsError::InvalidPrice("Packet too old".into())); + } + + // Verify based on source + let verified = if packet.source_chain.is_ibc_native() { + packet.verify_merkle_proof() + } else { + packet.verify_signatures(self.config.min_signatures) + }; + + if !verified { + return Err(EconomicsError::InvalidPrice("Packet verification failed".into())); + } + + // Cache the price + let pair_key = format!("{}/{}", packet.token, packet.quote); + self.cache.insert(pair_key, CrossChainPrice { + packet, + received_at: Utc::now(), + verified, + }); + + Ok(()) + } + + /// Fetch price from a specific chain + pub async fn fetch_from_chain( + &mut self, + chain: ChainNetwork, + token: &str, + quote: &str, + ) -> Result { + let fetcher = self.fetchers.get(&chain) + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(format!("{:?}", chain)))?; + + let packet = fetcher.fetch_price(token, quote).await?; + + // Verify and cache + if fetcher.verify_packet(&packet) { + let pair_key = format!("{}/{}", token, quote); + self.cache.insert(pair_key.clone(), CrossChainPrice { + packet: packet.clone(), + received_at: Utc::now(), + verified: true, + }); + } + + Ok(packet) + } + + /// Get cached price for a token pair + pub fn get_price(&self, token: &str, quote: &str) -> Option { + let pair_key = format!("{}/{}", token, quote); + self.cache.get(&pair_key) + .filter(|c| c.verified) + .filter(|c| (Utc::now() - c.received_at).num_seconds() < self.config.max_packet_age) + .map(|c| c.packet.price) + } + + /// Get price with full packet info + pub fn get_price_with_info(&self, token: &str, quote: &str) -> Option<&CrossChainPricePacket> { + let pair_key = format!("{}/{}", token, quote); + self.cache.get(&pair_key) + .filter(|c| c.verified) + .map(|c| &c.packet) + } + + /// Fetch all tracked prices from all chains + pub async fn fetch_all(&mut self) -> Result> { + let mut results = Vec::new(); + + for (chain, tokens) in &self.config.tracked_tokens.clone() { + if !self.config.enabled_chains.contains(chain) { + continue; + } + + for token in tokens { + match self.fetch_from_chain(*chain, token, "USD").await { + Ok(packet) => results.push(packet), + Err(e) => { + tracing::warn!("Failed to fetch {:?} {} price: {}", chain, token, e); + } + } + } + } + + Ok(results) + } + + /// Create an IBC price request packet + pub fn create_ibc_request( + &self, + target_chain: ChainNetwork, + token: &str, + quote: &str, + ) -> Option { + let channel = target_chain.ibc_channel()?; + + Some(IBCPriceRequest { + target_chain, + channel: channel.to_string(), + token: token.to_string(), + quote: quote.to_string(), + timeout: Utc::now() + Duration::seconds(self.config.ibc_timeout), + }) + } + + /// Get supported chains + pub fn supported_chains(&self) -> &[ChainNetwork] { + &self.config.enabled_chains + } + + /// Get all cached prices + pub fn get_all_prices(&self) -> Vec { + self.cache.values() + .filter(|c| c.verified) + .map(|c| c.packet.to_token_price()) + .collect() + } + + /// Clear stale cache entries + pub fn cleanup_cache(&mut self) { + let max_age = self.config.max_packet_age; + self.cache.retain(|_, v| { + (Utc::now() - v.received_at).num_seconds() < max_age + }); + } +} + +impl Default for CrossChainOracle { + fn default() -> Self { + Self::new() + } +} + +/// IBC price request packet +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IBCPriceRequest { + pub target_chain: ChainNetwork, + pub channel: String, + pub token: String, + pub quote: String, + pub timeout: DateTime, +} + +/// Ethereum price fetcher using Chainlink or Uniswap +pub struct EthereumPriceFetcher { + /// RPC endpoint + rpc_url: String, + /// Chainlink aggregator addresses + chainlink_feeds: HashMap, +} + +impl EthereumPriceFetcher { + pub fn new(rpc_url: impl Into) -> Self { + let mut feeds = HashMap::new(); + feeds.insert("ETH/USD".to_string(), "0x5f4eC3Df9cbd43714FE2740f5E3616155c5b8419".to_string()); + feeds.insert("BTC/USD".to_string(), "0xF4030086522a5bEEa4988F8cA5B36dbC97BeE88c".to_string()); + feeds.insert("USDC/USD".to_string(), "0x8fFfFfd4AfB6115b954Bd326cbe7B4BA576818f6".to_string()); + + Self { + rpc_url: rpc_url.into(), + chainlink_feeds: feeds, + } + } +} + +#[async_trait] +impl ChainPriceFetcher for EthereumPriceFetcher { + fn chain(&self) -> ChainNetwork { + ChainNetwork::Ethereum + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + let pair = format!("{}/{}", token, quote); + let _feed_addr = self.chainlink_feeds.get(&pair) + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.clone()))?; + + // In production: Call Chainlink aggregator via ethers-rs + // For now, return mock data + Ok(CrossChainPricePacket { + source_chain: ChainNetwork::Ethereum, + token: token.to_string(), + quote: quote.to_string(), + price: Decimal::new(200000, 2), // Mock: $2000 for ETH + source_block: 19000000, + source_timestamp: Utc::now(), + proof: None, + signatures: vec![ + OracleSignature { + signer: "chainlink".to_string(), + signature: vec![0; 65], + timestamp: Utc::now(), + }, + ], + }) + } + + fn verify_packet(&self, packet: &CrossChainPricePacket) -> bool { + packet.source_chain == ChainNetwork::Ethereum + && !packet.signatures.is_empty() + } + + fn supported_tokens(&self) -> Vec { + self.chainlink_feeds.keys().cloned().collect() + } +} + +/// Cosmos/IBC price fetcher +pub struct CosmosPriceFetcher { + /// Light client connection + light_client_id: String, + /// Chain ID + chain_id: String, +} + +impl CosmosPriceFetcher { + pub fn new(light_client_id: impl Into, chain_id: impl Into) -> Self { + Self { + light_client_id: light_client_id.into(), + chain_id: chain_id.into(), + } + } +} + +#[async_trait] +impl ChainPriceFetcher for CosmosPriceFetcher { + fn chain(&self) -> ChainNetwork { + ChainNetwork::Cosmos + } + + async fn fetch_price(&self, token: &str, quote: &str) -> Result { + // In production: Query via IBC light client + Ok(CrossChainPricePacket { + source_chain: ChainNetwork::Cosmos, + token: token.to_string(), + quote: quote.to_string(), + price: Decimal::new(1000, 2), // Mock: $10 for ATOM + source_block: 15000000, + source_timestamp: Utc::now(), + proof: Some(MerkleProof { + path: vec![], + root: [0; 32], + leaf: [0; 32], + }), + signatures: vec![], + }) + } + + fn verify_packet(&self, packet: &CrossChainPricePacket) -> bool { + packet.source_chain == ChainNetwork::Cosmos + && packet.proof.is_some() + } + + fn supported_tokens(&self) -> Vec { + vec!["ATOM/USD".to_string()] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_chain_network() { + assert_eq!(ChainNetwork::Ethereum.native_token(), "ETH"); + assert!(ChainNetwork::Cosmos.is_ibc_native()); + assert!(!ChainNetwork::Ethereum.is_ibc_native()); + } + + #[test] + fn test_packet_confidence() { + let packet = CrossChainPricePacket { + source_chain: ChainNetwork::Ethereum, + token: "ETH".to_string(), + quote: "USD".to_string(), + price: dec!(2000), + source_block: 19000000, + source_timestamp: Utc::now(), + proof: Some(MerkleProof { + path: vec![], + root: [0; 32], + leaf: [0; 32], + }), + signatures: vec![ + OracleSignature { + signer: "a".to_string(), + signature: vec![], + timestamp: Utc::now(), + }, + OracleSignature { + signer: "b".to_string(), + signature: vec![], + timestamp: Utc::now(), + }, + OracleSignature { + signer: "c".to_string(), + signature: vec![], + timestamp: Utc::now(), + }, + ], + }; + + let confidence = packet.calculate_confidence(); + assert!(confidence > 0.5); + } + + #[test] + fn test_cross_chain_oracle() { + let mut oracle = CrossChainOracle::new(); + + let packet = CrossChainPricePacket { + source_chain: ChainNetwork::Cosmos, + token: "ATOM".to_string(), + quote: "USD".to_string(), + price: dec!(10.50), + source_block: 15000000, + source_timestamp: Utc::now(), + proof: Some(MerkleProof { + path: vec![], + root: [0; 32], + leaf: [0; 32], + }), + signatures: vec![], + }; + + oracle.receive_ibc_packet(packet).unwrap(); + + let price = oracle.get_price("ATOM", "USD"); + assert_eq!(price, Some(dec!(10.50))); + } +} diff --git a/crates/synor-economics/src/oracle/decentralized.rs b/crates/synor-economics/src/oracle/decentralized.rs new file mode 100644 index 0000000..3053b48 --- /dev/null +++ b/crates/synor-economics/src/oracle/decentralized.rs @@ -0,0 +1,617 @@ +//! Decentralized Oracle Network (Chainlink-style) +//! +//! Multiple independent oracle nodes submit prices with threshold signatures. +//! Aggregation happens on-chain with Byzantine fault tolerance. + +use crate::error::{EconomicsError, Result}; +use crate::oracle::{PriceSource, TokenPrice}; +use crate::SynorDecimal; +use chrono::{DateTime, Duration, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Oracle node identity +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct OracleNode { + /// Unique node identifier + pub node_id: String, + /// Node's public key for signature verification + pub public_key: Vec, + /// Stake amount (for weighted voting) + pub stake: SynorDecimal, + /// Reputation score (0.0 - 1.0) + pub reputation: f64, + /// Whether node is currently active + pub is_active: bool, + /// Last heartbeat timestamp + pub last_heartbeat: DateTime, +} + +impl OracleNode { + pub fn new(node_id: impl Into, public_key: Vec, stake: SynorDecimal) -> Self { + Self { + node_id: node_id.into(), + public_key, + stake, + reputation: 1.0, + is_active: true, + last_heartbeat: Utc::now(), + } + } + + /// Check if node is eligible to submit prices + pub fn is_eligible(&self, min_stake: SynorDecimal, min_reputation: f64) -> bool { + self.is_active + && self.stake >= min_stake + && self.reputation >= min_reputation + && !self.is_stale(Duration::minutes(5)) + } + + /// Check if node heartbeat is stale + pub fn is_stale(&self, max_age: Duration) -> bool { + Utc::now() - self.last_heartbeat > max_age + } +} + +/// Price submission from an oracle node +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriceSubmission { + /// Submitting node ID + pub node_id: String, + /// Token pair (e.g., "SYNOR/USD") + pub pair: String, + /// Submitted price + pub price: SynorDecimal, + /// Submission timestamp + pub timestamp: DateTime, + /// Cryptographic signature + pub signature: Vec, + /// Round number for this aggregation + pub round: u64, +} + +/// Aggregation round for collecting submissions +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AggregationRound { + /// Round number + pub round: u64, + /// Token pair + pub pair: String, + /// Start time + pub started_at: DateTime, + /// Submission deadline + pub deadline: DateTime, + /// Collected submissions + pub submissions: Vec, + /// Whether round is finalized + pub finalized: bool, + /// Final aggregated price (if finalized) + pub final_price: Option, +} + +impl AggregationRound { + pub fn new(round: u64, pair: impl Into, duration: Duration) -> Self { + let now = Utc::now(); + Self { + round, + pair: pair.into(), + started_at: now, + deadline: now + duration, + submissions: Vec::new(), + finalized: false, + final_price: None, + } + } + + /// Check if round is still accepting submissions + pub fn is_open(&self) -> bool { + !self.finalized && Utc::now() < self.deadline + } + + /// Add a submission to this round + pub fn add_submission(&mut self, submission: PriceSubmission) -> Result<()> { + if self.finalized { + return Err(EconomicsError::InvalidPrice("Round already finalized".into())); + } + if Utc::now() >= self.deadline { + return Err(EconomicsError::InvalidPrice("Round deadline passed".into())); + } + if submission.round != self.round { + return Err(EconomicsError::InvalidPrice("Wrong round number".into())); + } + + // Check for duplicate submission from same node + if self.submissions.iter().any(|s| s.node_id == submission.node_id) { + return Err(EconomicsError::InvalidPrice("Duplicate submission".into())); + } + + self.submissions.push(submission); + Ok(()) + } +} + +/// Configuration for decentralized oracle network +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecentralizedOracleConfig { + /// Minimum number of submissions required + pub min_submissions: usize, + /// Maximum submissions to accept + pub max_submissions: usize, + /// Aggregation round duration + pub round_duration: Duration, + /// Minimum stake to participate + pub min_stake: SynorDecimal, + /// Minimum reputation to participate + pub min_reputation: f64, + /// Maximum price deviation from median (percentage) + pub max_deviation: Decimal, + /// Use stake-weighted aggregation + pub stake_weighted: bool, + /// Byzantine fault tolerance threshold (e.g., 0.33 for 1/3) + pub bft_threshold: f64, +} + +impl Default for DecentralizedOracleConfig { + fn default() -> Self { + Self { + min_submissions: 3, + max_submissions: 21, + round_duration: Duration::seconds(30), + min_stake: Decimal::new(1000, 0), // 1000 SYNOR minimum + min_reputation: 0.5, + max_deviation: Decimal::new(5, 2), // 5% + stake_weighted: true, + bft_threshold: 0.33, + } + } +} + +/// Aggregation strategy for combining prices +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AggregationStrategy { + /// Simple median (outlier resistant) + Median, + /// Stake-weighted median + StakeWeightedMedian, + /// Trimmed mean (remove top/bottom 10%) + TrimmedMean, + /// Reputation-weighted average + ReputationWeighted, +} + +/// Decentralized oracle network +pub struct DecentralizedOracle { + config: DecentralizedOracleConfig, + /// Registered oracle nodes + nodes: HashMap, + /// Current aggregation rounds by pair + current_rounds: HashMap, + /// Historical rounds + history: Vec, + /// Current round number + round_counter: u64, + /// Aggregation strategy + strategy: AggregationStrategy, +} + +impl DecentralizedOracle { + pub fn new() -> Self { + Self::with_config(DecentralizedOracleConfig::default()) + } + + pub fn with_config(config: DecentralizedOracleConfig) -> Self { + Self { + config, + nodes: HashMap::new(), + current_rounds: HashMap::new(), + history: Vec::new(), + round_counter: 0, + strategy: AggregationStrategy::StakeWeightedMedian, + } + } + + /// Register a new oracle node + pub fn register_node(&mut self, node: OracleNode) -> Result<()> { + if node.stake < self.config.min_stake { + return Err(EconomicsError::InsufficientFunds { + required: self.config.min_stake, + available: node.stake, + }); + } + + self.nodes.insert(node.node_id.clone(), node); + Ok(()) + } + + /// Remove an oracle node + pub fn remove_node(&mut self, node_id: &str) -> Option { + self.nodes.remove(node_id) + } + + /// Update node heartbeat + pub fn heartbeat(&mut self, node_id: &str) -> Result<()> { + let node = self.nodes.get_mut(node_id) + .ok_or_else(|| EconomicsError::InvalidPrice(format!("Unknown node: {}", node_id)))?; + node.last_heartbeat = Utc::now(); + Ok(()) + } + + /// Start a new aggregation round for a pair + pub fn start_round(&mut self, pair: impl Into) -> u64 { + let pair = pair.into(); + self.round_counter += 1; + let round = AggregationRound::new( + self.round_counter, + pair.clone(), + self.config.round_duration + ); + self.current_rounds.insert(pair, round); + self.round_counter + } + + /// Submit a price for the current round + pub fn submit_price(&mut self, submission: PriceSubmission) -> Result<()> { + // Verify node exists and is eligible + let node = self.nodes.get(&submission.node_id) + .ok_or_else(|| EconomicsError::InvalidPrice("Unknown node".into()))?; + + if !node.is_eligible(self.config.min_stake, self.config.min_reputation) { + return Err(EconomicsError::InvalidPrice("Node not eligible".into())); + } + + // TODO: Verify signature using node.public_key + // For now, we trust the submission + + // Add to current round + let round = self.current_rounds.get_mut(&submission.pair) + .ok_or_else(|| EconomicsError::InvalidPrice("No active round for pair".into()))?; + + round.add_submission(submission) + } + + /// Finalize a round and aggregate prices + pub fn finalize_round(&mut self, pair: &str) -> Result { + // First check state and get submissions (immutable borrow) + let (is_finalized, existing_price, submissions) = { + let round = self.current_rounds.get(pair) + .ok_or_else(|| EconomicsError::PriceFeedUnavailable(pair.to_string()))?; + + if round.finalized { + return round.final_price.ok_or_else(|| + EconomicsError::InvalidPrice("Round has no price".into()) + ); + } + + // Check minimum submissions + if round.submissions.len() < self.config.min_submissions { + return Err(EconomicsError::InvalidPrice(format!( + "Insufficient submissions: {} < {}", + round.submissions.len(), + self.config.min_submissions + ))); + } + + (round.finalized, round.final_price, round.submissions.clone()) + }; + + if is_finalized { + return existing_price.ok_or_else(|| + EconomicsError::InvalidPrice("Round has no price".into()) + ); + } + + // Filter outliers and aggregate (using cloned submissions) + let final_price = self.aggregate_prices_from_vec(pair, &submissions)?; + + // Now get mutable reference to update + if let Some(round) = self.current_rounds.get_mut(pair) { + round.finalized = true; + round.final_price = Some(final_price); + } + + // Update node reputations based on accuracy + self.update_reputations(pair, final_price); + + // Move to history + if let Some(completed) = self.current_rounds.remove(pair) { + self.history.push(completed); + // Keep only last 1000 rounds + if self.history.len() > 1000 { + self.history.remove(0); + } + } + + Ok(final_price) + } + + /// Aggregate prices from a vector of submissions (owned) + fn aggregate_prices_from_vec(&self, pair: &str, submissions: &[PriceSubmission]) -> Result { + if submissions.is_empty() { + return Err(EconomicsError::PriceFeedUnavailable(pair.to_string())); + } + + // Filter outliers first + let filtered = self.filter_outliers_vec(submissions); + if filtered.is_empty() { + return Err(EconomicsError::InvalidPrice("All submissions were outliers".into())); + } + + let filtered_refs: Vec<_> = filtered.iter().collect(); + match self.strategy { + AggregationStrategy::Median => self.calculate_median(&filtered_refs), + AggregationStrategy::StakeWeightedMedian => self.calculate_stake_weighted_median(&filtered_refs), + AggregationStrategy::TrimmedMean => self.calculate_trimmed_mean(&filtered_refs), + AggregationStrategy::ReputationWeighted => self.calculate_reputation_weighted(&filtered_refs), + } + } + + /// Filter out outlier submissions (returns owned vector) + fn filter_outliers_vec(&self, submissions: &[PriceSubmission]) -> Vec { + if submissions.len() < 3 { + return submissions.to_vec(); + } + + // Calculate median + let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect(); + prices.sort(); + let median = prices[prices.len() / 2]; + + // Filter submissions within max_deviation of median + submissions + .iter() + .filter(|s| { + let deviation = (s.price - median).abs() / median; + deviation <= self.config.max_deviation + }) + .cloned() + .collect() + } + + /// Calculate simple median + fn calculate_median(&self, submissions: &[&PriceSubmission]) -> Result { + let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect(); + prices.sort(); + Ok(prices[prices.len() / 2]) + } + + /// Calculate stake-weighted median + fn calculate_stake_weighted_median(&self, submissions: &[&PriceSubmission]) -> Result { + // Get stake for each submission + let mut weighted: Vec<(SynorDecimal, SynorDecimal)> = submissions + .iter() + .filter_map(|s| { + self.nodes.get(&s.node_id).map(|n| (s.price, n.stake)) + }) + .collect(); + + if weighted.is_empty() { + return self.calculate_median(submissions); + } + + // Sort by price + weighted.sort_by(|a, b| a.0.cmp(&b.0)); + + // Find weighted median + let total_stake: SynorDecimal = weighted.iter().map(|(_, s)| *s).sum(); + let half_stake = total_stake / Decimal::from(2); + + let mut cumulative = Decimal::ZERO; + for (price, stake) in &weighted { + cumulative += *stake; + if cumulative >= half_stake { + return Ok(*price); + } + } + + Ok(weighted.last().map(|(p, _)| *p).unwrap_or(Decimal::ZERO)) + } + + /// Calculate trimmed mean (remove top/bottom 10%) + fn calculate_trimmed_mean(&self, submissions: &[&PriceSubmission]) -> Result { + let mut prices: Vec<_> = submissions.iter().map(|s| s.price).collect(); + prices.sort(); + + let trim_count = (prices.len() as f64 * 0.1).ceil() as usize; + let trimmed = &prices[trim_count..prices.len() - trim_count]; + + if trimmed.is_empty() { + return self.calculate_median(submissions); + } + + let sum: SynorDecimal = trimmed.iter().copied().sum(); + Ok(sum / Decimal::from(trimmed.len())) + } + + /// Calculate reputation-weighted average + fn calculate_reputation_weighted(&self, submissions: &[&PriceSubmission]) -> Result { + let mut weighted_sum = Decimal::ZERO; + let mut total_weight = Decimal::ZERO; + + for sub in submissions { + let reputation = self.nodes.get(&sub.node_id) + .map(|n| n.reputation) + .unwrap_or(0.5); + + let weight = Decimal::from_f64_retain(reputation).unwrap_or(Decimal::ONE); + weighted_sum += sub.price * weight; + total_weight += weight; + } + + if total_weight == Decimal::ZERO { + return self.calculate_median(submissions); + } + + Ok(weighted_sum / total_weight) + } + + /// Update node reputations based on submission accuracy + fn update_reputations(&mut self, _pair: &str, final_price: SynorDecimal) { + // Get submissions from current round before it was moved + let submissions: Vec<_> = self.history.last() + .map(|r| r.submissions.clone()) + .unwrap_or_default(); + + for sub in submissions { + if let Some(node) = self.nodes.get_mut(&sub.node_id) { + let deviation = (sub.price - final_price).abs() / final_price; + + // Increase reputation for accurate submissions, decrease for inaccurate + if deviation <= Decimal::new(1, 2) { // Within 1% + node.reputation = (node.reputation + 0.01).min(1.0); + } else if deviation > self.config.max_deviation { + node.reputation = (node.reputation - 0.05).max(0.0); + } + } + } + } + + /// Get current round status + pub fn get_round_status(&self, pair: &str) -> Option<&AggregationRound> { + self.current_rounds.get(pair) + } + + /// Get number of active nodes + pub fn active_node_count(&self) -> usize { + self.nodes.values() + .filter(|n| n.is_eligible(self.config.min_stake, self.config.min_reputation)) + .count() + } + + /// Check if network has sufficient nodes for BFT + pub fn has_quorum(&self) -> bool { + let active = self.active_node_count(); + let required = (active as f64 * (1.0 - self.config.bft_threshold)).ceil() as usize; + active >= self.config.min_submissions && required >= 2 + } + + /// Get all registered nodes + pub fn get_nodes(&self) -> Vec<&OracleNode> { + self.nodes.values().collect() + } + + /// Convert finalized price to TokenPrice + pub fn to_token_price(&self, pair: &str) -> Option { + self.history.iter() + .rev() + .find(|r| r.pair == pair && r.finalized) + .and_then(|r| r.final_price.map(|price| { + let parts: Vec<_> = pair.split('/').collect(); + TokenPrice { + token: parts.get(0).unwrap_or(&"").to_string(), + quote: parts.get(1).unwrap_or(&"").to_string(), + price, + timestamp: r.deadline, + source: PriceSource::Aggregated, + confidence: 1.0, + } + })) + } +} + +impl Default for DecentralizedOracle { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + fn create_test_nodes() -> Vec { + (0..5).map(|i| { + OracleNode::new( + format!("node_{}", i), + vec![i as u8; 32], + dec!(10000), // 10k stake + ) + }).collect() + } + + #[test] + fn test_register_nodes() { + let mut oracle = DecentralizedOracle::new(); + + for node in create_test_nodes() { + oracle.register_node(node).unwrap(); + } + + assert_eq!(oracle.active_node_count(), 5); + assert!(oracle.has_quorum()); + } + + #[test] + fn test_aggregation_round() { + let mut oracle = DecentralizedOracle::new(); + + for node in create_test_nodes() { + oracle.register_node(node).unwrap(); + } + + // Start round + let round = oracle.start_round("SYNOR/USD"); + assert_eq!(round, 1); + + // Submit prices + let prices = [dec!(1.00), dec!(1.01), dec!(1.02), dec!(0.99), dec!(1.00)]; + for (i, price) in prices.iter().enumerate() { + let submission = PriceSubmission { + node_id: format!("node_{}", i), + pair: "SYNOR/USD".to_string(), + price: *price, + timestamp: Utc::now(), + signature: vec![], + round: 1, + }; + oracle.submit_price(submission).unwrap(); + } + + // Finalize + let final_price = oracle.finalize_round("SYNOR/USD").unwrap(); + + // Should be close to median (~1.00) + assert!(final_price >= dec!(0.99) && final_price <= dec!(1.02)); + } + + #[test] + fn test_outlier_filtering() { + let mut oracle = DecentralizedOracle::new(); + + for node in create_test_nodes() { + oracle.register_node(node).unwrap(); + } + + oracle.start_round("SYNOR/USD"); + + // Submit with one outlier + let prices = [dec!(1.00), dec!(1.01), dec!(1.02), dec!(1.00), dec!(5.00)]; // 5.00 is outlier + for (i, price) in prices.iter().enumerate() { + let submission = PriceSubmission { + node_id: format!("node_{}", i), + pair: "SYNOR/USD".to_string(), + price: *price, + timestamp: Utc::now(), + signature: vec![], + round: 1, + }; + oracle.submit_price(submission).unwrap(); + } + + let final_price = oracle.finalize_round("SYNOR/USD").unwrap(); + + // Outlier should be filtered, price should be ~1.00-1.02 + assert!(final_price >= dec!(0.99) && final_price <= dec!(1.03)); + } + + #[test] + fn test_insufficient_stake() { + let mut oracle = DecentralizedOracle::new(); + + let low_stake_node = OracleNode::new("poor_node", vec![0; 32], dec!(100)); // Below min + let result = oracle.register_node(low_stake_node); + + assert!(result.is_err()); + } +} diff --git a/crates/synor-economics/src/oracle/derivatives.rs b/crates/synor-economics/src/oracle/derivatives.rs new file mode 100644 index 0000000..80babfa --- /dev/null +++ b/crates/synor-economics/src/oracle/derivatives.rs @@ -0,0 +1,927 @@ +//! Options & Futures Pricing +//! +//! Black-Scholes-Merton model for European options pricing, +//! along with Greeks calculation and futures pricing. + +use crate::error::{EconomicsError, Result}; +use crate::SynorDecimal; +use chrono::{DateTime, Duration, Timelike, Utc}; +use rust_decimal::Decimal; +use rust_decimal::prelude::ToPrimitive; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::f64::consts::PI; + +/// Option type +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum OptionType { + Call, + Put, +} + +/// Option style +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OptionStyle { + /// Can only be exercised at expiration + European, + /// Can be exercised any time before expiration + American, +} + +/// Option contract specification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OptionContract { + /// Underlying asset symbol + pub underlying: String, + /// Strike price + pub strike: SynorDecimal, + /// Expiration timestamp + pub expiration: DateTime, + /// Call or Put + pub option_type: OptionType, + /// European or American + pub style: OptionStyle, + /// Contract multiplier (e.g., 100 for stock options) + pub multiplier: SynorDecimal, +} + +impl OptionContract { + /// Create a new option contract + pub fn new( + underlying: impl Into, + strike: SynorDecimal, + expiration: DateTime, + option_type: OptionType, + ) -> Self { + Self { + underlying: underlying.into(), + strike, + expiration, + option_type, + style: OptionStyle::European, + multiplier: Decimal::ONE, + } + } + + /// Time to expiration in years + pub fn time_to_expiry(&self) -> f64 { + let seconds = (self.expiration - Utc::now()).num_seconds() as f64; + seconds / (365.25 * 24.0 * 60.0 * 60.0) + } + + /// Check if option is expired + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expiration + } + + /// Check if option is in the money + pub fn is_itm(&self, spot: SynorDecimal) -> bool { + match self.option_type { + OptionType::Call => spot > self.strike, + OptionType::Put => spot < self.strike, + } + } + + /// Calculate intrinsic value + pub fn intrinsic_value(&self, spot: SynorDecimal) -> SynorDecimal { + match self.option_type { + OptionType::Call => (spot - self.strike).max(Decimal::ZERO), + OptionType::Put => (self.strike - spot).max(Decimal::ZERO), + } + } +} + +/// The Greeks - option sensitivities +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OptionGreeks { + /// Price sensitivity to underlying (1st derivative) + pub delta: f64, + /// Delta sensitivity to underlying (2nd derivative) + pub gamma: f64, + /// Price sensitivity to time decay + pub theta: f64, + /// Price sensitivity to volatility + pub vega: f64, + /// Price sensitivity to interest rate + pub rho: f64, + /// Vega sensitivity to volatility + pub vanna: f64, + /// Gamma sensitivity to volatility + pub volga: f64, +} + +/// Full option pricing result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OptionPricing { + /// Theoretical price + pub price: SynorDecimal, + /// Intrinsic value + pub intrinsic: SynorDecimal, + /// Time value (extrinsic) + pub time_value: SynorDecimal, + /// Option greeks + pub greeks: OptionGreeks, + /// Implied volatility (if calculated from market price) + pub implied_vol: Option, + /// Pricing model used + pub model: String, + /// Calculation timestamp + pub timestamp: DateTime, +} + +/// Standard normal CDF approximation (Abramowitz & Stegun) +fn norm_cdf(x: f64) -> f64 { + let a1 = 0.254829592; + let a2 = -0.284496736; + let a3 = 1.421413741; + let a4 = -1.453152027; + let a5 = 1.061405429; + let p = 0.3275911; + + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let x = x.abs(); + + let t = 1.0 / (1.0 + p * x); + let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x / 2.0).exp(); + + 0.5 * (1.0 + sign * y) +} + +/// Standard normal PDF +fn norm_pdf(x: f64) -> f64 { + (-x * x / 2.0).exp() / (2.0 * PI).sqrt() +} + +/// Black-Scholes-Merton pricing model +pub struct BlackScholes { + /// Risk-free interest rate (annualized) + pub risk_free_rate: f64, + /// Dividend yield (annualized) + pub dividend_yield: f64, +} + +impl BlackScholes { + pub fn new(risk_free_rate: f64, dividend_yield: f64) -> Self { + Self { + risk_free_rate, + dividend_yield, + } + } + + /// Calculate d1 and d2 parameters + fn d1_d2(&self, spot: f64, strike: f64, time: f64, vol: f64) -> (f64, f64) { + let d1 = ((spot / strike).ln() + + (self.risk_free_rate - self.dividend_yield + vol * vol / 2.0) * time) + / (vol * time.sqrt()); + let d2 = d1 - vol * time.sqrt(); + (d1, d2) + } + + /// Price a European option + pub fn price(&self, contract: &OptionContract, spot: SynorDecimal, vol: f64) -> Result { + if contract.is_expired() { + // At expiration, option is worth intrinsic value + let intrinsic = contract.intrinsic_value(spot); + return Ok(OptionPricing { + price: intrinsic, + intrinsic, + time_value: Decimal::ZERO, + greeks: OptionGreeks { + delta: if contract.is_itm(spot) { + match contract.option_type { + OptionType::Call => 1.0, + OptionType::Put => -1.0, + } + } else { + 0.0 + }, + gamma: 0.0, + theta: 0.0, + vega: 0.0, + rho: 0.0, + vanna: 0.0, + volga: 0.0, + }, + implied_vol: None, + model: "Black-Scholes".to_string(), + timestamp: Utc::now(), + }); + } + + let s = spot.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid spot price".into()) + )?; + let k = contract.strike.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid strike price".into()) + )?; + let t = contract.time_to_expiry(); + + if t <= 0.0 || vol <= 0.0 { + return Err(EconomicsError::InvalidPrice("Invalid parameters".into())); + } + + let (d1, d2) = self.d1_d2(s, k, t, vol); + + let r = self.risk_free_rate; + let q = self.dividend_yield; + + let price_f64 = match contract.option_type { + OptionType::Call => { + s * (-q * t).exp() * norm_cdf(d1) - k * (-r * t).exp() * norm_cdf(d2) + } + OptionType::Put => { + k * (-r * t).exp() * norm_cdf(-d2) - s * (-q * t).exp() * norm_cdf(-d1) + } + }; + + let greeks = self.calculate_greeks(contract, s, k, t, vol, d1, d2); + + let price = Decimal::from_f64_retain(price_f64) + .ok_or_else(|| EconomicsError::InvalidPrice("Price calculation overflow".into()))?; + let intrinsic = contract.intrinsic_value(spot); + let time_value = (price - intrinsic).max(Decimal::ZERO); + + Ok(OptionPricing { + price, + intrinsic, + time_value, + greeks, + implied_vol: Some(vol), + model: "Black-Scholes".to_string(), + timestamp: Utc::now(), + }) + } + + /// Calculate option Greeks + fn calculate_greeks( + &self, + contract: &OptionContract, + s: f64, + k: f64, + t: f64, + vol: f64, + d1: f64, + d2: f64, + ) -> OptionGreeks { + let r = self.risk_free_rate; + let q = self.dividend_yield; + let sqrt_t = t.sqrt(); + + let n_d1 = norm_cdf(d1); + let n_d2 = norm_cdf(d2); + let n_prime_d1 = norm_pdf(d1); + + let (delta, theta_sign) = match contract.option_type { + OptionType::Call => ((-q * t).exp() * n_d1, 1.0), + OptionType::Put => ((-q * t).exp() * (n_d1 - 1.0), -1.0), + }; + + // Gamma (same for calls and puts) + let gamma = (-q * t).exp() * n_prime_d1 / (s * vol * sqrt_t); + + // Vega (same for calls and puts) + let vega = s * (-q * t).exp() * n_prime_d1 * sqrt_t / 100.0; // Per 1% vol change + + // Theta + let theta_common = -(s * vol * (-q * t).exp() * n_prime_d1) / (2.0 * sqrt_t); + let theta = match contract.option_type { + OptionType::Call => { + theta_common + + q * s * (-q * t).exp() * n_d1 + - r * k * (-r * t).exp() * n_d2 + } + OptionType::Put => { + theta_common + - q * s * (-q * t).exp() * (1.0 - n_d1) + + r * k * (-r * t).exp() * (1.0 - n_d2) + } + } / 365.0; // Per day + + // Rho + let rho = match contract.option_type { + OptionType::Call => k * t * (-r * t).exp() * n_d2 / 100.0, + OptionType::Put => -k * t * (-r * t).exp() * (1.0 - n_d2) / 100.0, + }; + + // Vanna: d(Delta)/d(Vol) + let vanna = -(-q * t).exp() * n_prime_d1 * d2 / vol; + + // Volga: d(Vega)/d(Vol) + let volga = vega * d1 * d2 / vol; + + OptionGreeks { + delta, + gamma, + theta, + vega, + rho, + vanna, + volga, + } + } + + /// Calculate implied volatility from market price using Newton-Raphson + pub fn implied_volatility( + &self, + contract: &OptionContract, + spot: SynorDecimal, + market_price: SynorDecimal, + ) -> Result { + let target = market_price.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid market price".into()) + )?; + + // Initial guess based on time value + let intrinsic = contract.intrinsic_value(spot).to_f64().unwrap_or(0.0); + let time_value = (target - intrinsic).max(0.0); + let s = spot.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid spot".into()) + )?; + let t = contract.time_to_expiry(); + + // Brenner-Subrahmanyam approximation for initial guess + let mut vol = (2.0 * PI / t).sqrt() * time_value / s; + vol = vol.max(0.01).min(5.0); // Bound between 1% and 500% + + // Newton-Raphson iteration + for _ in 0..100 { + let pricing = self.price(contract, spot, vol)?; + let price = pricing.price.to_f64().unwrap_or(0.0); + let vega = pricing.greeks.vega * 100.0; // Convert back from per-1% to per-1 + + let diff = price - target; + + if diff.abs() < 0.0001 { + return Ok(vol); + } + + if vega.abs() < 0.0001 { + // Vega too small, can't continue Newton-Raphson + break; + } + + vol -= diff / vega; + vol = vol.max(0.001).min(10.0); // Keep bounded + } + + // Fall back to bisection if Newton-Raphson fails + self.implied_vol_bisection(contract, spot, market_price) + } + + /// Bisection method for IV (more robust, slower) + fn implied_vol_bisection( + &self, + contract: &OptionContract, + spot: SynorDecimal, + market_price: SynorDecimal, + ) -> Result { + let target = market_price.to_f64().unwrap_or(0.0); + let mut low = 0.001; + let mut high = 5.0; + + for _ in 0..100 { + let mid = (low + high) / 2.0; + let price = self.price(contract, spot, mid)?.price.to_f64().unwrap_or(0.0); + + if (price - target).abs() < 0.0001 { + return Ok(mid); + } + + if price < target { + low = mid; + } else { + high = mid; + } + } + + Ok((low + high) / 2.0) + } +} + +impl Default for BlackScholes { + fn default() -> Self { + Self::new(0.05, 0.0) // 5% risk-free rate, no dividend + } +} + +/// Futures contract specification +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FuturesContract { + /// Underlying asset symbol + pub underlying: String, + /// Contract expiration + pub expiration: DateTime, + /// Contract size + pub contract_size: SynorDecimal, + /// Tick size (minimum price movement) + pub tick_size: SynorDecimal, + /// Tick value (dollar value per tick) + pub tick_value: SynorDecimal, +} + +impl FuturesContract { + pub fn new( + underlying: impl Into, + expiration: DateTime, + contract_size: SynorDecimal, + ) -> Self { + Self { + underlying: underlying.into(), + expiration, + contract_size, + tick_size: Decimal::new(1, 2), // 0.01 + tick_value: Decimal::new(10, 0), // $10 per tick + } + } + + /// Time to expiration in years + pub fn time_to_expiry(&self) -> f64 { + let seconds = (self.expiration - Utc::now()).num_seconds() as f64; + seconds / (365.25 * 24.0 * 60.0 * 60.0) + } + + /// Check if contract is expired + pub fn is_expired(&self) -> bool { + Utc::now() >= self.expiration + } +} + +/// Futures pricing result +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FuturesPricing { + /// Fair value (theoretical price) + pub fair_value: SynorDecimal, + /// Basis (futures - spot) + pub basis: SynorDecimal, + /// Basis in percentage + pub basis_pct: f64, + /// Cost of carry + pub cost_of_carry: SynorDecimal, + /// Calculation timestamp + pub timestamp: DateTime, +} + +/// Futures pricing model +pub struct FuturesModel { + /// Risk-free interest rate + pub risk_free_rate: f64, + /// Storage cost (for commodities) + pub storage_cost: f64, + /// Convenience yield (for commodities) + pub convenience_yield: f64, +} + +impl FuturesModel { + pub fn new(risk_free_rate: f64) -> Self { + Self { + risk_free_rate, + storage_cost: 0.0, + convenience_yield: 0.0, + } + } + + /// For commodities with storage costs + pub fn with_costs(risk_free_rate: f64, storage_cost: f64, convenience_yield: f64) -> Self { + Self { + risk_free_rate, + storage_cost, + convenience_yield, + } + } + + /// Calculate futures fair value using cost-of-carry model + /// F = S * e^((r + u - y) * T) + /// where r = risk-free rate, u = storage cost, y = convenience yield + pub fn price(&self, contract: &FuturesContract, spot: SynorDecimal) -> Result { + let s = spot.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid spot price".into()) + )?; + + let t = contract.time_to_expiry(); + if t < 0.0 { + return Err(EconomicsError::InvalidPrice("Contract expired".into())); + } + + let cost_of_carry = self.risk_free_rate + self.storage_cost - self.convenience_yield; + let fair_value_f64 = s * (cost_of_carry * t).exp(); + + let fair_value = Decimal::from_f64_retain(fair_value_f64) + .ok_or_else(|| EconomicsError::InvalidPrice("Overflow in calculation".into()))?; + + let basis = fair_value - spot; + let basis_pct = if s > 0.0 { + (fair_value_f64 - s) / s * 100.0 + } else { + 0.0 + }; + + let coc = Decimal::from_f64_retain(cost_of_carry * t * s) + .unwrap_or(Decimal::ZERO); + + Ok(FuturesPricing { + fair_value, + basis, + basis_pct, + cost_of_carry: coc, + timestamp: Utc::now(), + }) + } + + /// Calculate implied repo rate from futures price + /// R = (F/S - 1) / T + pub fn implied_repo_rate(&self, contract: &FuturesContract, spot: SynorDecimal, futures_price: SynorDecimal) -> Result { + let s = spot.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid spot".into()) + )?; + let f = futures_price.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid futures price".into()) + )?; + let t = contract.time_to_expiry(); + + if t <= 0.0 || s <= 0.0 { + return Err(EconomicsError::InvalidPrice("Invalid parameters".into())); + } + + Ok((f / s).ln() / t) + } +} + +impl Default for FuturesModel { + fn default() -> Self { + Self::new(0.05) + } +} + +/// Perpetual swap pricing (no expiry) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PerpetualPricing { + /// Mark price + pub mark_price: SynorDecimal, + /// Index price (spot) + pub index_price: SynorDecimal, + /// Premium/discount from index + pub premium: SynorDecimal, + /// Funding rate (8h) + pub funding_rate: f64, + /// Next funding time + pub next_funding: DateTime, + /// Open interest + pub open_interest: SynorDecimal, +} + +/// Perpetual swap model +pub struct PerpetualModel { + /// Funding rate interval (hours) + pub funding_interval_hours: u32, + /// Interest rate component + pub interest_rate: f64, + /// Premium/discount dampener + pub dampener: f64, +} + +impl PerpetualModel { + pub fn new() -> Self { + Self { + funding_interval_hours: 8, + interest_rate: 0.0001, // 0.01% per 8h (roughly 3% annualized) + dampener: 0.0005, // 0.05% dampener + } + } + + /// Calculate funding rate + /// Funding = (Mark - Index) / Index * factor, clamped by interest rate bounds + pub fn calculate_funding( + &self, + mark_price: SynorDecimal, + index_price: SynorDecimal, + ) -> Result { + let mark = mark_price.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid mark price".into()) + )?; + let index = index_price.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid index price".into()) + )?; + + if index <= 0.0 { + return Err(EconomicsError::InvalidPrice("Invalid index".into())); + } + + // Premium rate + let premium_rate = (mark - index) / index; + + // Funding rate = Premium Rate + Interest Rate (clamped) + let funding = if premium_rate.abs() > self.dampener { + if premium_rate > 0.0 { + premium_rate - self.dampener + self.interest_rate + } else { + premium_rate + self.dampener + self.interest_rate + } + } else { + self.interest_rate + }; + + // Clamp funding rate + Ok(funding.max(-0.03).min(0.03)) // Max 3% per funding period + } + + /// Price a perpetual position + pub fn price( + &self, + index_price: SynorDecimal, + mark_price: SynorDecimal, + open_interest: SynorDecimal, + ) -> Result { + let funding_rate = self.calculate_funding(mark_price, index_price)?; + + let premium = mark_price - index_price; + + // Next funding time + let now = Utc::now(); + let hours_since_midnight = now.time().hour(); + let next_funding_hour = ((hours_since_midnight / self.funding_interval_hours) + 1) + * self.funding_interval_hours; + let next_funding = now.date_naive() + .and_hms_opt(next_funding_hour % 24, 0, 0) + .map(|dt| DateTime::from_naive_utc_and_offset(dt, Utc)) + .unwrap_or(now + Duration::hours(self.funding_interval_hours as i64)); + + Ok(PerpetualPricing { + mark_price, + index_price, + premium, + funding_rate, + next_funding, + open_interest, + }) + } +} + +impl Default for PerpetualModel { + fn default() -> Self { + Self::new() + } +} + +/// Derivatives pricing oracle +pub struct DerivativesOracle { + /// Black-Scholes model + pub options_model: BlackScholes, + /// Futures model + pub futures_model: FuturesModel, + /// Perpetual model + pub perpetual_model: PerpetualModel, + /// Volatility surface by underlying + vol_surfaces: HashMap, +} + +/// Volatility surface for options pricing +#[derive(Debug, Clone)] +pub struct VolatilitySurface { + /// Underlying asset + pub underlying: String, + /// ATM volatility + pub atm_vol: f64, + /// Skew (vol difference between OTM puts and calls) + pub skew: f64, + /// Term structure (vol change per year of expiry) + pub term_slope: f64, + /// Last update + pub updated_at: DateTime, +} + +impl VolatilitySurface { + pub fn new(underlying: impl Into, atm_vol: f64) -> Self { + Self { + underlying: underlying.into(), + atm_vol, + skew: 0.0, + term_slope: 0.0, + updated_at: Utc::now(), + } + } + + /// Get volatility for specific strike and expiry + pub fn get_vol(&self, moneyness: f64, time_to_expiry: f64) -> f64 { + // Simple parameterization: vol = ATM + skew * moneyness + term * sqrt(T) + let skew_adj = self.skew * moneyness; + let term_adj = self.term_slope * time_to_expiry.sqrt(); + (self.atm_vol + skew_adj + term_adj).max(0.01) + } +} + +impl DerivativesOracle { + pub fn new() -> Self { + Self { + options_model: BlackScholes::default(), + futures_model: FuturesModel::default(), + perpetual_model: PerpetualModel::default(), + vol_surfaces: HashMap::new(), + } + } + + /// Set volatility surface for an underlying + pub fn set_vol_surface(&mut self, surface: VolatilitySurface) { + self.vol_surfaces.insert(surface.underlying.clone(), surface); + } + + /// Price an option using the volatility surface + pub fn price_option( + &self, + contract: &OptionContract, + spot: SynorDecimal, + ) -> Result { + let s = spot.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid spot".into()) + )?; + let k = contract.strike.to_f64().ok_or_else(|| + EconomicsError::InvalidPrice("Invalid strike".into()) + )?; + + // Get volatility from surface or use default + let vol = if let Some(surface) = self.vol_surfaces.get(&contract.underlying) { + let moneyness = (k / s).ln(); + let t = contract.time_to_expiry(); + surface.get_vol(moneyness, t) + } else { + 0.30 // Default 30% vol + }; + + self.options_model.price(contract, spot, vol) + } + + /// Price a futures contract + pub fn price_futures( + &self, + contract: &FuturesContract, + spot: SynorDecimal, + ) -> Result { + self.futures_model.price(contract, spot) + } + + /// Price a perpetual swap + pub fn price_perpetual( + &self, + index_price: SynorDecimal, + mark_price: SynorDecimal, + open_interest: SynorDecimal, + ) -> Result { + self.perpetual_model.price(index_price, mark_price, open_interest) + } + + /// Calculate implied vol from market price + pub fn get_implied_vol( + &self, + contract: &OptionContract, + spot: SynorDecimal, + market_price: SynorDecimal, + ) -> Result { + self.options_model.implied_volatility(contract, spot, market_price) + } +} + +impl Default for DerivativesOracle { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rust_decimal_macros::dec; + + #[test] + fn test_option_intrinsic_value() { + let call = OptionContract::new("ETH", dec!(2000), Utc::now() + Duration::days(30), OptionType::Call); + let put = OptionContract::new("ETH", dec!(2000), Utc::now() + Duration::days(30), OptionType::Put); + + // ITM call + assert_eq!(call.intrinsic_value(dec!(2100)), dec!(100)); + // OTM call + assert_eq!(call.intrinsic_value(dec!(1900)), dec!(0)); + + // ITM put + assert_eq!(put.intrinsic_value(dec!(1900)), dec!(100)); + // OTM put + assert_eq!(put.intrinsic_value(dec!(2100)), dec!(0)); + } + + #[test] + fn test_black_scholes_call() { + let model = BlackScholes::new(0.05, 0.0); + let contract = OptionContract::new( + "ETH", + dec!(2000), + Utc::now() + Duration::days(30), + OptionType::Call, + ); + + let pricing = model.price(&contract, dec!(2000), 0.50).unwrap(); + + // ATM call should have positive price + assert!(pricing.price > Decimal::ZERO); + // Delta should be around 0.5 for ATM + assert!(pricing.greeks.delta > 0.4 && pricing.greeks.delta < 0.6); + // Gamma should be positive + assert!(pricing.greeks.gamma > 0.0); + // Theta should be negative (time decay) + assert!(pricing.greeks.theta < 0.0); + // Vega should be positive + assert!(pricing.greeks.vega > 0.0); + } + + #[test] + fn test_black_scholes_put() { + let model = BlackScholes::new(0.05, 0.0); + let contract = OptionContract::new( + "ETH", + dec!(2000), + Utc::now() + Duration::days(30), + OptionType::Put, + ); + + let pricing = model.price(&contract, dec!(2000), 0.50).unwrap(); + + // ATM put should have positive price + assert!(pricing.price > Decimal::ZERO); + // Delta should be around -0.5 for ATM put + assert!(pricing.greeks.delta > -0.6 && pricing.greeks.delta < -0.4); + } + + #[test] + fn test_put_call_parity() { + let model = BlackScholes::new(0.05, 0.0); + let expiry = Utc::now() + Duration::days(30); + + let call = OptionContract::new("ETH", dec!(2000), expiry, OptionType::Call); + let put = OptionContract::new("ETH", dec!(2000), expiry, OptionType::Put); + + let call_price = model.price(&call, dec!(2000), 0.50).unwrap().price; + let put_price = model.price(&put, dec!(2000), 0.50).unwrap().price; + + // Put-call parity: C - P = S - K * e^(-rT) + // At the money: C - P ≈ S * (1 - e^(-rT)) + let parity_diff = call_price - put_price; + assert!(parity_diff.abs() < dec!(10)); // Should be small for ATM + } + + #[test] + fn test_implied_volatility() { + let model = BlackScholes::new(0.05, 0.0); + let contract = OptionContract::new( + "ETH", + dec!(2000), + Utc::now() + Duration::days(30), + OptionType::Call, + ); + + // Price with known vol + let vol = 0.50; + let pricing = model.price(&contract, dec!(2000), vol).unwrap(); + + // Calculate IV from that price + let iv = model.implied_volatility(&contract, dec!(2000), pricing.price).unwrap(); + + // Should match original vol + assert!((iv - vol).abs() < 0.01); + } + + #[test] + fn test_futures_pricing() { + let model = FuturesModel::new(0.05); + let contract = FuturesContract::new("ETH", Utc::now() + Duration::days(90), dec!(1)); + + let pricing = model.price(&contract, dec!(2000)).unwrap(); + + // Futures should be at premium (positive cost of carry) + assert!(pricing.fair_value > dec!(2000)); + assert!(pricing.basis > Decimal::ZERO); + } + + #[test] + fn test_perpetual_funding() { + let model = PerpetualModel::new(); + + // Premium (mark > index) = positive funding (longs pay shorts) + let funding = model.calculate_funding(dec!(2020), dec!(2000)).unwrap(); + assert!(funding > 0.0); + + // Discount (mark < index) = negative funding (shorts pay longs) + let funding = model.calculate_funding(dec!(1980), dec!(2000)).unwrap(); + assert!(funding < 0.0); + } + + #[test] + fn test_volatility_surface() { + let mut surface = VolatilitySurface::new("ETH", 0.50); + surface.skew = -0.1; // Negative skew (puts more expensive) + + // OTM put (negative moneyness) should have higher vol + let otm_put_vol = surface.get_vol(-0.2, 0.25); + let atm_vol = surface.get_vol(0.0, 0.25); + let otm_call_vol = surface.get_vol(0.2, 0.25); + + assert!(otm_put_vol > atm_vol); + assert!(otm_call_vol < atm_vol); + } +} diff --git a/crates/synor-economics/src/oracle/liquidation.rs b/crates/synor-economics/src/oracle/liquidation.rs new file mode 100644 index 0000000..b214bae --- /dev/null +++ b/crates/synor-economics/src/oracle/liquidation.rs @@ -0,0 +1,730 @@ +//! Liquidation Oracles for DeFi Lending +//! +//! Specialized price feeds optimized for lending protocols with: +//! - Tighter freshness requirements +//! - Liquidation-specific price calculations +//! - Collateral factor management +//! - Health factor monitoring + +use crate::error::{EconomicsError, Result}; +use crate::oracle::{PriceOracle, PriceSource}; +use crate::SynorDecimal; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// Collateral asset configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollateralAsset { + /// Asset symbol + pub symbol: String, + /// Collateral factor (e.g., 0.75 = 75% LTV) + pub collateral_factor: Decimal, + /// Liquidation threshold (e.g., 0.80 = liquidate at 80%) + pub liquidation_threshold: Decimal, + /// Liquidation bonus (e.g., 0.05 = 5% bonus for liquidators) + pub liquidation_bonus: Decimal, + /// Maximum supply cap + pub supply_cap: Option, + /// Is borrowing enabled + pub borrow_enabled: bool, + /// Reserve factor (protocol fee) + pub reserve_factor: Decimal, + /// Price oracle volatility multiplier (for risky assets) + pub volatility_multiplier: Decimal, +} + +impl CollateralAsset { + /// Create a standard collateral configuration + pub fn standard(symbol: impl Into) -> Self { + Self { + symbol: symbol.into(), + collateral_factor: Decimal::new(75, 2), // 75% + liquidation_threshold: Decimal::new(80, 2), // 80% + liquidation_bonus: Decimal::new(5, 2), // 5% + supply_cap: None, + borrow_enabled: true, + reserve_factor: Decimal::new(10, 2), // 10% + volatility_multiplier: Decimal::ONE, + } + } + + /// Create for stablecoin (higher collateral factor) + pub fn stablecoin(symbol: impl Into) -> Self { + Self { + symbol: symbol.into(), + collateral_factor: Decimal::new(90, 2), // 90% + liquidation_threshold: Decimal::new(95, 2), // 95% + liquidation_bonus: Decimal::new(2, 2), // 2% + supply_cap: None, + borrow_enabled: true, + reserve_factor: Decimal::new(5, 2), // 5% + volatility_multiplier: Decimal::ONE, + } + } + + /// Create for volatile asset (lower collateral factor) + pub fn volatile(symbol: impl Into) -> Self { + Self { + symbol: symbol.into(), + collateral_factor: Decimal::new(50, 2), // 50% + liquidation_threshold: Decimal::new(65, 2), // 65% + liquidation_bonus: Decimal::new(10, 2), // 10% + supply_cap: None, + borrow_enabled: true, + reserve_factor: Decimal::new(20, 2), // 20% + volatility_multiplier: Decimal::new(12, 1), // 1.2x + } + } +} + +/// User position in the lending protocol +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LendingPosition { + /// User account ID + pub account_id: String, + /// Collateral deposits by asset + pub collateral: HashMap, + /// Borrowings by asset + pub borrows: HashMap, + /// Interest owed by asset + pub interest_owed: HashMap, + /// Last update timestamp + pub last_update: DateTime, +} + +impl LendingPosition { + pub fn new(account_id: impl Into) -> Self { + Self { + account_id: account_id.into(), + collateral: HashMap::new(), + borrows: HashMap::new(), + interest_owed: HashMap::new(), + last_update: Utc::now(), + } + } + + /// Add collateral deposit + pub fn deposit(&mut self, asset: impl Into, amount: SynorDecimal) { + let asset = asset.into(); + *self.collateral.entry(asset).or_insert(Decimal::ZERO) += amount; + self.last_update = Utc::now(); + } + + /// Borrow against collateral + pub fn borrow(&mut self, asset: impl Into, amount: SynorDecimal) { + let asset = asset.into(); + *self.borrows.entry(asset).or_insert(Decimal::ZERO) += amount; + self.last_update = Utc::now(); + } + + /// Repay borrowed amount + pub fn repay(&mut self, asset: impl Into, amount: SynorDecimal) { + let asset = asset.into(); + if let Some(borrowed) = self.borrows.get_mut(&asset) { + *borrowed = (*borrowed - amount).max(Decimal::ZERO); + } + self.last_update = Utc::now(); + } + + /// Withdraw collateral + pub fn withdraw(&mut self, asset: impl Into, amount: SynorDecimal) -> Result<()> { + let asset = asset.into(); + let current = self.collateral.get(&asset).copied().unwrap_or(Decimal::ZERO); + if amount > current { + return Err(EconomicsError::InsufficientFunds { + required: amount, + available: current, + }); + } + *self.collateral.entry(asset).or_insert(Decimal::ZERO) -= amount; + self.last_update = Utc::now(); + Ok(()) + } +} + +/// Health status of a position +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthStatus { + /// Health factor (> 1.0 is healthy, < 1.0 is liquidatable) + pub health_factor: Decimal, + /// Total collateral value in USD + pub total_collateral_usd: SynorDecimal, + /// Risk-adjusted collateral value + pub adjusted_collateral_usd: SynorDecimal, + /// Total borrow value in USD + pub total_borrow_usd: SynorDecimal, + /// Borrow utilization rate + pub utilization_rate: Decimal, + /// Maximum additional borrow capacity in USD + pub borrow_capacity_usd: SynorDecimal, + /// Is position liquidatable + pub is_liquidatable: bool, + /// Timestamp of calculation + pub timestamp: DateTime, + /// Price sources used + pub price_sources: Vec, +} + +/// Liquidation event +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiquidationEvent { + /// Position being liquidated + pub account_id: String, + /// Debt asset being repaid + pub debt_asset: String, + /// Debt amount repaid + pub debt_amount: SynorDecimal, + /// Collateral asset seized + pub collateral_asset: String, + /// Collateral amount seized + pub collateral_amount: SynorDecimal, + /// Liquidator address + pub liquidator: String, + /// Liquidation bonus earned + pub bonus_amount: SynorDecimal, + /// Timestamp + pub timestamp: DateTime, + /// Price used for liquidation + pub price_at_liquidation: SynorDecimal, +} + +/// Liquidation price feed with strict freshness +#[derive(Debug, Clone)] +pub struct LiquidationPrice { + /// Asset symbol + pub asset: String, + /// Current price + pub price: SynorDecimal, + /// Price source + pub source: PriceSource, + /// Timestamp + pub timestamp: DateTime, + /// Confidence score + pub confidence: f64, + /// Is price fresh enough for liquidations + pub is_fresh: bool, + /// Time until stale (seconds) + pub freshness_remaining: i64, +} + +/// Configuration for liquidation oracle +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiquidationOracleConfig { + /// Maximum price age for liquidations (seconds) + pub max_price_age: i64, + /// Minimum confidence required + pub min_confidence: f64, + /// Minimum price sources required + pub min_sources: usize, + /// Grace period before liquidation (seconds) + pub liquidation_grace_period: i64, + /// Minimum liquidation amount (USD) + pub min_liquidation_amount: SynorDecimal, + /// Maximum liquidation percentage per transaction + pub max_liquidation_pct: Decimal, + /// Enable partial liquidations + pub partial_liquidation: bool, + /// Close factor (max debt repayable at once) + pub close_factor: Decimal, +} + +impl Default for LiquidationOracleConfig { + fn default() -> Self { + Self { + max_price_age: 60, // 1 minute (stricter than general oracle) + min_confidence: 0.9, + min_sources: 2, + liquidation_grace_period: 300, // 5 minutes + min_liquidation_amount: Decimal::new(10, 0), // $10 + max_liquidation_pct: Decimal::new(50, 2), // 50% at a time + partial_liquidation: true, + close_factor: Decimal::new(50, 2), // 50% + } + } +} + +/// Liquidation oracle for DeFi lending +pub struct LiquidationOracle { + config: LiquidationOracleConfig, + /// Base price oracle + price_oracle: PriceOracle, + /// Collateral asset configurations + assets: HashMap, + /// User positions + positions: HashMap, + /// Liquidation events history + liquidation_history: Vec, + /// Cached liquidation prices + price_cache: HashMap, +} + +impl LiquidationOracle { + pub fn new(price_oracle: PriceOracle) -> Self { + Self::with_config(price_oracle, LiquidationOracleConfig::default()) + } + + pub fn with_config(price_oracle: PriceOracle, config: LiquidationOracleConfig) -> Self { + Self { + config, + price_oracle, + assets: HashMap::new(), + positions: HashMap::new(), + liquidation_history: Vec::new(), + price_cache: HashMap::new(), + } + } + + /// Register a collateral asset + pub fn register_asset(&mut self, asset: CollateralAsset) { + self.assets.insert(asset.symbol.clone(), asset); + } + + /// Get or create a user position + pub fn get_position(&self, account_id: &str) -> Option<&LendingPosition> { + self.positions.get(account_id) + } + + /// Create a new position + pub fn create_position(&mut self, account_id: impl Into) -> &mut LendingPosition { + let account_id = account_id.into(); + self.positions.entry(account_id.clone()) + .or_insert_with(|| LendingPosition::new(account_id)) + } + + /// Get liquidation-safe price for an asset + pub fn get_liquidation_price(&mut self, asset: &str) -> Result { + // Check cache first + if let Some(cached) = self.price_cache.get(asset) { + if cached.is_fresh { + return Ok(cached.clone()); + } + } + + // Get fresh price from oracle + let aggregated = self.price_oracle.get_aggregated_price(asset, "USD")?; + + let age = (Utc::now() - aggregated.last_update).num_seconds(); + let is_fresh = age < self.config.max_price_age + && aggregated.confidence >= self.config.min_confidence + && aggregated.source_count >= self.config.min_sources; + + let liq_price = LiquidationPrice { + asset: asset.to_string(), + price: aggregated.twap, // Use TWAP for liquidations + source: PriceSource::Aggregated, + timestamp: aggregated.last_update, + confidence: aggregated.confidence, + is_fresh, + freshness_remaining: self.config.max_price_age - age, + }; + + self.price_cache.insert(asset.to_string(), liq_price.clone()); + Ok(liq_price) + } + + /// Calculate health status for a position + pub fn calculate_health(&mut self, account_id: &str) -> Result { + // Clone position data to avoid borrow conflicts with get_liquidation_price + let (collateral, borrows, interest_owed) = { + let position = self.positions.get(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + ( + position.collateral.clone(), + position.borrows.clone(), + position.interest_owed.clone(), + ) + }; + + let mut total_collateral_usd = Decimal::ZERO; + let mut adjusted_collateral_usd = Decimal::ZERO; + let mut total_borrow_usd = Decimal::ZERO; + let mut price_sources = Vec::new(); + + // Calculate collateral value + for (asset, amount) in &collateral { + let price = self.get_liquidation_price(asset)?; + if !price.is_fresh { + return Err(EconomicsError::StalePrice { + asset: asset.clone(), + age_seconds: -price.freshness_remaining, + }); + } + + let asset_config = self.assets.get(asset) + .ok_or_else(|| EconomicsError::InvalidPrice(format!("Unknown asset: {}", asset)))?; + + let value = *amount * price.price; + total_collateral_usd += value; + adjusted_collateral_usd += value * asset_config.liquidation_threshold; + price_sources.push(format!("{}:{}", asset, price.source)); + } + + // Calculate borrow value + for (asset, amount) in &borrows { + let price = self.get_liquidation_price(asset)?; + if !price.is_fresh { + return Err(EconomicsError::StalePrice { + asset: asset.clone(), + age_seconds: -price.freshness_remaining, + }); + } + + let value = *amount * price.price; + + // Add interest owed + let interest = interest_owed.get(asset).copied().unwrap_or(Decimal::ZERO); + total_borrow_usd += value + (interest * price.price); + } + + // Calculate health factor + let health_factor = if total_borrow_usd == Decimal::ZERO { + Decimal::new(999, 0) // Effectively infinite + } else { + adjusted_collateral_usd / total_borrow_usd + }; + + // Calculate utilization + let utilization_rate = if adjusted_collateral_usd == Decimal::ZERO { + Decimal::ZERO + } else { + total_borrow_usd / adjusted_collateral_usd + }; + + // Calculate remaining borrow capacity + let borrow_capacity_usd = (adjusted_collateral_usd - total_borrow_usd).max(Decimal::ZERO); + + Ok(HealthStatus { + health_factor, + total_collateral_usd, + adjusted_collateral_usd, + total_borrow_usd, + utilization_rate, + borrow_capacity_usd, + is_liquidatable: health_factor < Decimal::ONE, + timestamp: Utc::now(), + price_sources, + }) + } + + /// Check if a position can be liquidated + pub fn can_liquidate(&mut self, account_id: &str) -> Result { + let health = self.calculate_health(account_id)?; + Ok(health.is_liquidatable) + } + + /// Calculate liquidation amounts + pub fn calculate_liquidation( + &mut self, + account_id: &str, + debt_asset: &str, + collateral_asset: &str, + ) -> Result { + let health = self.calculate_health(account_id)?; + if !health.is_liquidatable { + return Err(EconomicsError::InvalidPrice("Position is healthy".into())); + } + + let position = self.positions.get(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + let debt_amount = position.borrows.get(debt_asset).copied().unwrap_or(Decimal::ZERO); + let collateral_amount = position.collateral.get(collateral_asset).copied().unwrap_or(Decimal::ZERO); + + if debt_amount == Decimal::ZERO { + return Err(EconomicsError::InvalidPrice("No debt to repay".into())); + } + if collateral_amount == Decimal::ZERO { + return Err(EconomicsError::InvalidPrice("No collateral to seize".into())); + } + + let debt_price = self.get_liquidation_price(debt_asset)?; + let collateral_price = self.get_liquidation_price(collateral_asset)?; + + let collateral_config = self.assets.get(collateral_asset) + .ok_or_else(|| EconomicsError::InvalidPrice(format!("Unknown asset: {}", collateral_asset)))?; + + // Max debt repayable = close_factor * total_debt + let max_debt_repay = debt_amount * self.config.close_factor; + let max_debt_usd = max_debt_repay * debt_price.price; + + // Calculate collateral to seize with bonus + let bonus_multiplier = Decimal::ONE + collateral_config.liquidation_bonus; + let collateral_to_seize_usd = max_debt_usd * bonus_multiplier; + let collateral_to_seize = collateral_to_seize_usd / collateral_price.price; + + // Cap at available collateral + let actual_collateral_seized = collateral_to_seize.min(collateral_amount); + let actual_debt_repaid = if actual_collateral_seized < collateral_to_seize { + // Partial liquidation + (actual_collateral_seized * collateral_price.price) / (bonus_multiplier * debt_price.price) + } else { + max_debt_repay + }; + + let bonus_amount = actual_collateral_seized * collateral_config.liquidation_bonus / bonus_multiplier; + + Ok(LiquidationCalculation { + account_id: account_id.to_string(), + debt_asset: debt_asset.to_string(), + debt_to_repay: actual_debt_repaid, + collateral_asset: collateral_asset.to_string(), + collateral_to_seize: actual_collateral_seized, + liquidation_bonus: bonus_amount, + debt_price: debt_price.price, + collateral_price: collateral_price.price, + health_factor_before: health.health_factor, + }) + } + + /// Execute a liquidation + pub fn execute_liquidation( + &mut self, + account_id: &str, + debt_asset: &str, + collateral_asset: &str, + liquidator: &str, + ) -> Result { + let calc = self.calculate_liquidation(account_id, debt_asset, collateral_asset)?; + + // Update position + let position = self.positions.get_mut(account_id) + .ok_or_else(|| EconomicsError::AccountNotFound(account_id.to_string()))?; + + // Reduce debt + if let Some(debt) = position.borrows.get_mut(debt_asset) { + *debt = (*debt - calc.debt_to_repay).max(Decimal::ZERO); + } + + // Reduce collateral + if let Some(collateral) = position.collateral.get_mut(collateral_asset) { + *collateral = (*collateral - calc.collateral_to_seize).max(Decimal::ZERO); + } + + let event = LiquidationEvent { + account_id: account_id.to_string(), + debt_asset: debt_asset.to_string(), + debt_amount: calc.debt_to_repay, + collateral_asset: collateral_asset.to_string(), + collateral_amount: calc.collateral_to_seize, + liquidator: liquidator.to_string(), + bonus_amount: calc.liquidation_bonus, + timestamp: Utc::now(), + price_at_liquidation: calc.collateral_price, + }; + + self.liquidation_history.push(event.clone()); + Ok(event) + } + + /// Get positions at risk of liquidation + pub fn get_at_risk_positions(&mut self, threshold: Decimal) -> Vec<(String, HealthStatus)> { + let account_ids: Vec<_> = self.positions.keys().cloned().collect(); + let mut at_risk = Vec::new(); + + for account_id in account_ids { + if let Ok(health) = self.calculate_health(&account_id) { + if health.health_factor < threshold { + at_risk.push((account_id, health)); + } + } + } + + // Sort by health factor (most at risk first) + at_risk.sort_by(|a, b| a.1.health_factor.cmp(&b.1.health_factor)); + at_risk + } + + /// Get liquidation history + pub fn get_liquidation_history(&self) -> &[LiquidationEvent] { + &self.liquidation_history + } + + /// Calculate protocol reserves from liquidations + pub fn calculate_reserves(&self) -> HashMap { + let mut reserves: HashMap = HashMap::new(); + + for event in &self.liquidation_history { + // Protocol gets a portion of the liquidation bonus + if let Some(asset_config) = self.assets.get(&event.collateral_asset) { + let protocol_share = event.bonus_amount * asset_config.reserve_factor; + *reserves.entry(event.collateral_asset.clone()).or_insert(Decimal::ZERO) += protocol_share; + } + } + + reserves + } + + /// Get statistics + pub fn get_stats(&self) -> LiquidationStats { + let total_positions = self.positions.len(); + let total_liquidations = self.liquidation_history.len(); + + let total_debt_liquidated: SynorDecimal = self.liquidation_history.iter() + .map(|e| e.debt_amount) + .sum(); + + let total_collateral_seized: SynorDecimal = self.liquidation_history.iter() + .map(|e| e.collateral_amount) + .sum(); + + let unique_liquidated: std::collections::HashSet<_> = self.liquidation_history.iter() + .map(|e| &e.account_id) + .collect(); + + LiquidationStats { + total_positions, + total_liquidations, + unique_accounts_liquidated: unique_liquidated.len(), + total_debt_liquidated, + total_collateral_seized, + } + } +} + +/// Calculated liquidation parameters +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiquidationCalculation { + pub account_id: String, + pub debt_asset: String, + pub debt_to_repay: SynorDecimal, + pub collateral_asset: String, + pub collateral_to_seize: SynorDecimal, + pub liquidation_bonus: SynorDecimal, + pub debt_price: SynorDecimal, + pub collateral_price: SynorDecimal, + pub health_factor_before: Decimal, +} + +/// Liquidation statistics +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LiquidationStats { + pub total_positions: usize, + pub total_liquidations: usize, + pub unique_accounts_liquidated: usize, + pub total_debt_liquidated: SynorDecimal, + pub total_collateral_seized: SynorDecimal, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::oracle::{OracleConfig, PriceOracle, TokenPrice}; + use rust_decimal_macros::dec; + + fn setup_oracle() -> LiquidationOracle { + let mut price_oracle = PriceOracle::with_config(OracleConfig::default()); + + // Add prices from multiple sources for test validity + price_oracle.update_price(TokenPrice::new("ETH", "USD", dec!(2000), PriceSource::Internal)).unwrap(); + price_oracle.update_price(TokenPrice::new("ETH", "USD", dec!(2000), PriceSource::Aggregated)).unwrap(); + price_oracle.update_price(TokenPrice::new("SYNOR", "USD", dec!(1), PriceSource::Internal)).unwrap(); + price_oracle.update_price(TokenPrice::new("SYNOR", "USD", dec!(1), PriceSource::Aggregated)).unwrap(); + price_oracle.update_price(TokenPrice::new("USDC", "USD", dec!(1), PriceSource::Internal)).unwrap(); + price_oracle.update_price(TokenPrice::new("USDC", "USD", dec!(1), PriceSource::Aggregated)).unwrap(); + + // Use test config with relaxed freshness requirements + let test_config = LiquidationOracleConfig { + max_price_age: 3600, // 1 hour for tests + min_confidence: 0.5, // Lower confidence threshold + min_sources: 1, // Single source OK for tests + ..Default::default() + }; + + let mut oracle = LiquidationOracle::with_config(price_oracle, test_config); + + // Register assets + oracle.register_asset(CollateralAsset::volatile("ETH")); + oracle.register_asset(CollateralAsset::standard("SYNOR")); + oracle.register_asset(CollateralAsset::stablecoin("USDC")); + + oracle + } + + #[test] + fn test_collateral_asset_configs() { + let standard = CollateralAsset::standard("ETH"); + assert_eq!(standard.collateral_factor, dec!(0.75)); + + let stable = CollateralAsset::stablecoin("USDC"); + assert_eq!(stable.collateral_factor, dec!(0.90)); + + let volatile = CollateralAsset::volatile("SHIB"); + assert_eq!(volatile.collateral_factor, dec!(0.50)); + } + + #[test] + fn test_healthy_position() { + let mut oracle = setup_oracle(); + + // Create position with good health + let pos = oracle.create_position("user1"); + pos.deposit("ETH", dec!(1)); // $2000 worth + pos.borrow("USDC", dec!(500)); // Borrow $500 + + let health = oracle.calculate_health("user1").unwrap(); + + // ETH liquidation threshold = 65%, so adjusted = $2000 * 0.65 = $1300 + // Borrow = $500 + // Health = 1300 / 500 = 2.6 + assert!(health.health_factor > Decimal::ONE); + assert!(!health.is_liquidatable); + } + + #[test] + fn test_liquidatable_position() { + let mut oracle = setup_oracle(); + + // Create position close to liquidation + let pos = oracle.create_position("user2"); + pos.deposit("ETH", dec!(1)); // $2000 worth + pos.borrow("USDC", dec!(1500)); // Borrow $1500 + + let health = oracle.calculate_health("user2").unwrap(); + + // ETH liquidation threshold = 65%, so adjusted = $2000 * 0.65 = $1300 + // Borrow = $1500 + // Health = 1300 / 1500 = 0.867 + assert!(health.health_factor < Decimal::ONE); + assert!(health.is_liquidatable); + } + + #[test] + fn test_liquidation_calculation() { + let mut oracle = setup_oracle(); + + // Create unhealthy position + let pos = oracle.create_position("user3"); + pos.deposit("ETH", dec!(1)); + pos.borrow("USDC", dec!(1500)); + + let calc = oracle.calculate_liquidation("user3", "USDC", "ETH").unwrap(); + + // Should be able to liquidate + assert!(calc.debt_to_repay > Decimal::ZERO); + assert!(calc.collateral_to_seize > Decimal::ZERO); + assert!(calc.liquidation_bonus > Decimal::ZERO); + } + + #[test] + fn test_at_risk_positions() { + let mut oracle = setup_oracle(); + + // Create healthy position + let pos1 = oracle.create_position("healthy"); + pos1.deposit("ETH", dec!(10)); + pos1.borrow("USDC", dec!(1000)); + + // Create at-risk position + let pos2 = oracle.create_position("risky"); + pos2.deposit("ETH", dec!(1)); + pos2.borrow("USDC", dec!(1200)); + + let at_risk = oracle.get_at_risk_positions(dec!(1.5)); + + // Only risky position should be flagged + assert_eq!(at_risk.len(), 1); + assert_eq!(at_risk[0].0, "risky"); + } +} diff --git a/crates/synor-economics/src/oracle/mod.rs b/crates/synor-economics/src/oracle/mod.rs index e620ead..9e70159 100644 --- a/crates/synor-economics/src/oracle/mod.rs +++ b/crates/synor-economics/src/oracle/mod.rs @@ -1,10 +1,48 @@ //! Price Oracle //! //! Aggregates SYNOR/USD prices from multiple sources with TWAP for stability. +//! Includes advanced features: +//! - Decentralized oracle network (Chainlink-style) +//! - Circuit breakers for flash crash protection +//! - Cross-chain price feeds via IBC +//! - ML-based anomaly detection +//! - Liquidation oracles for DeFi lending +//! - Options/Futures pricing (Black-Scholes) +mod anomaly; +mod circuit_breaker; +mod cross_chain; +mod decentralized; +mod derivatives; +mod liquidation; mod price_feed; mod twap; +pub use anomaly::{ + Anomaly, AnomalyData, AnomalyDetector, AnomalyDetectorConfig, AnomalyStats, AnomalyType, + MarketDataPoint, RecommendedAction, +}; +pub use circuit_breaker::{ + CircuitBreakerConfig, CircuitBreakerManager, CircuitBreakerStats, CircuitEvent, CircuitState, + TriggerReason, +}; +pub use cross_chain::{ + ChainNetwork, ChainPriceFetcher, CrossChainConfig, CrossChainOracle, CrossChainPricePacket, + IBCPriceRequest, MerkleProof, +}; +pub use decentralized::{ + AggregationRound, AggregationStrategy, DecentralizedOracle, DecentralizedOracleConfig, + OracleNode, PriceSubmission, +}; +pub use derivatives::{ + BlackScholes, DerivativesOracle, FuturesContract, FuturesModel, FuturesPricing, OptionContract, + OptionGreeks, OptionPricing, OptionStyle, OptionType, PerpetualModel, PerpetualPricing, + VolatilitySurface, +}; +pub use liquidation::{ + CollateralAsset, HealthStatus, LendingPosition, LiquidationCalculation, LiquidationEvent, + LiquidationOracle, LiquidationOracleConfig, LiquidationPrice, LiquidationStats, +}; pub use price_feed::{PriceFeed, PriceSource}; pub use twap::TwapCalculator; diff --git a/crates/synor-economics/src/oracle/price_feed.rs b/crates/synor-economics/src/oracle/price_feed.rs index 576add5..31d6e26 100644 --- a/crates/synor-economics/src/oracle/price_feed.rs +++ b/crates/synor-economics/src/oracle/price_feed.rs @@ -32,6 +32,10 @@ pub enum PriceSource { Binance, /// Uniswap TWAP Uniswap, + /// Cross-chain via IBC + CrossChain, + /// Aggregated from multiple sources + Aggregated, /// Custom/external source Custom(u8), } @@ -48,6 +52,8 @@ impl std::fmt::Display for PriceSource { PriceSource::CoinMarketCap => write!(f, "coinmarketcap"), PriceSource::Binance => write!(f, "binance"), PriceSource::Uniswap => write!(f, "uniswap"), + PriceSource::CrossChain => write!(f, "cross_chain"), + PriceSource::Aggregated => write!(f, "aggregated"), PriceSource::Custom(id) => write!(f, "custom_{}", id), } }