//! Rolling correlation monitor for cross-asset exposure limits. //! //! Maintains a rolling correlation matrix across traded symbols. //! Pre-trade check computes effective exposure. Alerts on correlation breakdown. use std::collections::HashMap; use std::collections::VecDeque; /// Rolling correlation monitor for cross-asset exposure limits. pub struct CorrelationMonitor { returns: HashMap>, lookback: usize, max_effective_exposure: f64, correlation_breakdown_threshold: f64, } /// Correlation check result. #[derive(Debug, Clone)] pub struct CorrelationCheck { /// Pairwise correlations between all tracked symbols. pub pairwise: Vec, /// Effective exposure (sum of absolute position * correlation weights). pub effective_exposure: f64, /// Whether the effective exposure exceeds the limit. pub exposure_exceeded: bool, /// Pairs where correlation changed significantly (breakdown). pub breakdowns: Vec, } /// Correlation between two symbols. #[derive(Debug, Clone)] pub struct PairCorrelation { pub symbol_a: String, pub symbol_b: String, pub correlation: f64, } /// Significant change in pairwise correlation. #[derive(Debug, Clone)] pub struct CorrelationBreakdown { pub symbol_a: String, pub symbol_b: String, pub old_correlation: f64, pub new_correlation: f64, } impl CorrelationMonitor { pub fn new( lookback: usize, max_effective_exposure: f64, correlation_breakdown_threshold: f64, ) -> Self { Self { returns: HashMap::new(), lookback, max_effective_exposure, correlation_breakdown_threshold, } } /// Add a return observation for a symbol. pub fn add_return(&mut self, symbol: &str, ret: f64) { let entry = self .returns .entry(symbol.to_owned()) .or_insert_with(|| VecDeque::with_capacity(self.lookback + 1)); entry.push_back(ret); if entry.len() > self.lookback { entry.pop_front(); } } /// Compute Pearson correlation between two return series. fn pearson_correlation(a: &VecDeque, b: &VecDeque) -> f64 { let n = a.len().min(b.len()); if n < 3 { return 0.0; } let mean_a: f64 = a.iter().take(n).sum::() / n as f64; let mean_b: f64 = b.iter().take(n).sum::() / n as f64; let mut cov = 0.0_f64; let mut var_a = 0.0_f64; let mut var_b = 0.0_f64; for i in 0..n { let da = a.get(i).copied().unwrap_or(0.0) - mean_a; let db = b.get(i).copied().unwrap_or(0.0) - mean_b; cov += da * db; var_a += da * da; var_b += db * db; } let denom = (var_a * var_b).sqrt(); if denom < 1e-15 { 0.0 } else { (cov / denom).clamp(-1.0, 1.0) } } /// Get the correlation breakdown threshold. #[must_use] pub fn correlation_breakdown_threshold(&self) -> f64 { self.correlation_breakdown_threshold } /// Compute pairwise correlations and effective exposure. /// `positions` maps symbol to absolute position size. pub fn check(&self, positions: &HashMap) -> CorrelationCheck { let symbols: Vec<&String> = self.returns.keys().collect(); let mut pairwise = Vec::new(); for i in 0..symbols.len() { for j in (i + 1)..symbols.len() { let sym_a = symbols.get(i).copied(); let sym_b = symbols.get(j).copied(); if let (Some(a), Some(b)) = (sym_a, sym_b) { if let (Some(ra), Some(rb)) = (self.returns.get(a), self.returns.get(b)) { let corr = Self::pearson_correlation(ra, rb); pairwise.push(PairCorrelation { symbol_a: a.clone(), symbol_b: b.clone(), correlation: corr, }); } } } } // Effective exposure: sum of |pos_i * pos_j * correlation_ij| / total_pos^2 * num_positions let total_pos: f64 = positions.values().map(|p| p.abs()).sum(); let effective_exposure = if total_pos > 0.0 { let mut weighted_corr = 0.0_f64; for pair in &pairwise { let pos_a = positions.get(&pair.symbol_a).copied().unwrap_or(0.0).abs(); let pos_b = positions.get(&pair.symbol_b).copied().unwrap_or(0.0).abs(); weighted_corr += pos_a * pos_b * pair.correlation.abs(); } weighted_corr / (total_pos * total_pos).max(1e-15) * positions.len() as f64 } else { 0.0 }; CorrelationCheck { exposure_exceeded: effective_exposure > self.max_effective_exposure, effective_exposure, breakdowns: Vec::new(), // Breakdowns require historical state; simplified for initial impl pairwise, } } } impl Default for CorrelationMonitor { fn default() -> Self { Self::new(60, 3.0, 0.5) } } #[cfg(test)] #[allow(clippy::str_to_string)] mod tests { use super::*; #[test] fn test_identical_series_correlation_near_one() { let mut monitor = CorrelationMonitor::new(60, 3.0, 0.5); for i in 0..30 { let ret = 0.01 * (i as f64).sin(); monitor.add_return("EURUSD", ret); monitor.add_return("EURUSD_copy", ret); } let positions = [ ("EURUSD".to_string(), 1000.0), ("EURUSD_copy".to_string(), 1000.0), ] .into_iter() .collect(); let check = monitor.check(&positions); let pair = check.pairwise.first(); assert!(pair.is_some()); if let Some(p) = pair { assert!( (p.correlation - 1.0).abs() < 0.01, "Identical series should have correlation ~1.0, got {}", p.correlation ); } } #[test] fn test_uncorrelated_series_near_zero() { let mut monitor = CorrelationMonitor::new(60, 3.0, 0.5); // Use deterministic but uncorrelated sequences for i in 0..100 { monitor.add_return("A", (i as f64 * 0.73).sin()); monitor.add_return("B", (i as f64 * 2.41 + 5.0).cos()); } let positions = [("A".to_string(), 1000.0), ("B".to_string(), 1000.0)] .into_iter() .collect(); let check = monitor.check(&positions); let pair = check.pairwise.first(); assert!(pair.is_some()); if let Some(p) = pair { assert!( p.correlation.abs() < 0.5, "Uncorrelated series should have low correlation, got {}", p.correlation ); } } #[test] fn test_lookback_window_enforced() { let mut monitor = CorrelationMonitor::new(10, 3.0, 0.5); for i in 0..20 { monitor.add_return("X", i as f64 * 0.01); } let returns = monitor.returns.get("X"); assert!(returns.is_some()); if let Some(r) = returns { assert_eq!( r.len(), 10, "Should only keep lookback window entries" ); } } #[test] fn test_effective_exposure_check() { let mut monitor = CorrelationMonitor::new(60, 1.5, 0.5); // Create highly correlated positions for i in 0..30 { let ret = 0.01 * (i as f64).sin(); monitor.add_return("A", ret); monitor.add_return("B", ret); } let positions = [("A".to_string(), 10000.0), ("B".to_string(), 10000.0)] .into_iter() .collect(); let check = monitor.check(&positions); // With high correlation and large positions, exposure should be notable assert!( check.effective_exposure > 0.0, "Effective exposure should be > 0 for correlated positions" ); } #[test] fn test_default_monitor() { let monitor = CorrelationMonitor::default(); assert_eq!(monitor.lookback, 60); assert!((monitor.max_effective_exposure - 3.0).abs() < f64::EPSILON); assert!((monitor.correlation_breakdown_threshold() - 0.5).abs() < f64::EPSILON); } #[test] fn test_empty_positions() { let monitor = CorrelationMonitor::new(60, 3.0, 0.5); let positions: HashMap = HashMap::new(); let check = monitor.check(&positions); assert!(!check.exposure_exceeded); assert!((check.effective_exposure).abs() < f64::EPSILON); } #[test] fn test_insufficient_data_returns_zero_correlation() { let mut monitor = CorrelationMonitor::new(60, 3.0, 0.5); // Only 2 data points -- below minimum of 3 monitor.add_return("A", 0.01); monitor.add_return("A", 0.02); monitor.add_return("B", 0.03); monitor.add_return("B", 0.04); let positions = [("A".to_string(), 1000.0), ("B".to_string(), 1000.0)] .into_iter() .collect(); let check = monitor.check(&positions); if let Some(p) = check.pairwise.first() { assert!( p.correlation.abs() < f64::EPSILON, "With < 3 data points, correlation should be 0.0" ); } } }