diff --git a/ml/src/hyperopt/mod.rs b/ml/src/hyperopt/mod.rs index 7155f2093..a1095f8d8 100644 --- a/ml/src/hyperopt/mod.rs +++ b/ml/src/hyperopt/mod.rs @@ -44,6 +44,7 @@ pub mod egobox_tuner; // Deprecated - kept for backward compatibility pub mod observer; pub mod optimizer; pub mod paths; +pub mod sensitivity; pub mod traits; #[cfg(test)] diff --git a/ml/src/hyperopt/sensitivity.rs b/ml/src/hyperopt/sensitivity.rs new file mode 100644 index 000000000..8b783284a --- /dev/null +++ b/ml/src/hyperopt/sensitivity.rs @@ -0,0 +1,195 @@ +//! Hyperparameter sensitivity analysis for fragility detection. +//! +//! Perturbs each hyperparameter independently and measures the resulting +//! change in objective (Sharpe ratio) to identify fragile configurations +//! that may not survive live trading conditions. + +/// Result of a full sensitivity analysis across all parameters. +#[derive(Debug, Clone)] +pub struct SensitivityResult { + /// Per-parameter sensitivity breakdown. + pub per_param: Vec, + /// Mean sensitivity across all parameters (higher = more fragile). + pub overall_fragility: f64, +} + +/// Sensitivity analysis for a single hyperparameter. +#[derive(Debug, Clone)] +pub struct ParamSensitivity { + /// Parameter name. + pub name: String, + /// Baseline (unperturbed) value. + pub base_value: f64, + /// Normalized sensitivity score: max |delta_sharpe| / baseline_sharpe. + pub sensitivity_score: f64, + /// Whether this parameter is fragile (sensitivity > 0.3 threshold). + pub is_fragile: bool, + /// Perturbation results: `(perturbation_pct, sharpe_at_perturbation)`. + pub perturbation_results: Vec<(f64, f64)>, +} + +/// Analyzer that perturbs hyperparameters to detect fragile configurations. +#[derive(Debug)] +pub struct SensitivityAnalyzer { + /// Parameter names. + names: Vec, + /// Baseline parameter values. + base_params: Vec, + /// Perturbation percentages to apply (e.g. [-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]). + perturbation_pcts: Vec, +} + +/// Threshold above which a parameter is considered fragile. +const FRAGILITY_THRESHOLD: f64 = 0.3; + +impl SensitivityAnalyzer { + /// Create a new sensitivity analyzer. + /// + /// - `names`: parameter names (must match length of `base_params`). + /// - `base_params`: baseline parameter values found by optimization. + /// - `perturbation_pcts`: optional custom perturbation percentages. + /// Defaults to `[-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]`. + pub fn new( + names: Vec, + base_params: Vec, + perturbation_pcts: Option>, + ) -> Self { + let perturbation_pcts = perturbation_pcts + .unwrap_or_else(|| vec![-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]); + Self { + names, + base_params, + perturbation_pcts, + } + } + + /// Run sensitivity analysis using the provided evaluation function. + /// + /// The `evaluate` closure takes a parameter slice and returns a Sharpe ratio. + /// Each parameter is perturbed independently while all others remain at baseline. + pub fn analyze f64>(&self, evaluate: F) -> SensitivityResult { + let baseline_sharpe = evaluate(&self.base_params); + + let mut per_param = Vec::with_capacity(self.names.len()); + + for (i, name) in self.names.iter().enumerate() { + let base_val = self.base_params.get(i).copied().unwrap_or(0.0); + let mut perturbation_results = Vec::with_capacity(self.perturbation_pcts.len()); + let mut max_abs_change = 0.0_f64; + + for &pct in &self.perturbation_pcts { + let mut params = self.base_params.clone(); + if let Some(p) = params.get_mut(i) { + *p = base_val * (1.0 + pct); + } + let sharpe = evaluate(¶ms); + perturbation_results.push((pct, sharpe)); + + let abs_change = (sharpe - baseline_sharpe).abs(); + if abs_change > max_abs_change { + max_abs_change = abs_change; + } + } + + let sensitivity_score = if baseline_sharpe.abs() < 1e-15 { + 0.0 + } else { + max_abs_change / baseline_sharpe.abs() + }; + + per_param.push(ParamSensitivity { + name: name.clone(), + base_value: base_val, + sensitivity_score, + is_fragile: sensitivity_score > FRAGILITY_THRESHOLD, + perturbation_results, + }); + } + + let overall_fragility = if per_param.is_empty() { + 0.0 + } else { + let sum: f64 = per_param.iter().map(|p| p.sensitivity_score).sum(); + sum / per_param.len() as f64 + }; + + SensitivityResult { + per_param, + overall_fragility, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_insensitive_function_low_fragility() { + // Constant function: always returns 2.0 regardless of params + let analyzer = SensitivityAnalyzer::new( + vec!["lr".to_string(), "gamma".to_string()], + vec![0.001, 0.99], + None, // use defaults + ); + let result = analyzer.analyze(|_params| 2.0); + assert!( + result.overall_fragility < 0.01, + "Constant function should have ~0 fragility, got {}", + result.overall_fragility + ); + assert!(!result.per_param.iter().any(|p| p.is_fragile)); + } + + #[test] + fn test_sensitive_function_high_fragility() { + // Exponential sensitivity: small param changes cause large Sharpe swings. + // sharpe = exp(1000 * lr) where lr=0.001 => baseline=e^1 ~ 2.718 + // At +20%: lr=0.0012 => e^1.2 ~ 3.32 => delta/baseline ~ 0.22 + // At -20%: lr=0.0008 => e^0.8 ~ 2.23 => delta/baseline ~ 0.18 + // Use a steeper multiplier so the sensitivity clearly exceeds 0.3. + let analyzer = SensitivityAnalyzer::new( + vec!["lr".to_string(), "gamma".to_string()], + vec![0.001, 0.99], + None, + ); + let result = analyzer.analyze(|params| { + let lr = params.first().copied().unwrap_or(0.001); + // sharpe = exp(2000 * lr), baseline = exp(2) ~ 7.39 + // At +20%: exp(2.4) ~ 11.02, delta/baseline ~ 0.49 > 0.3 => fragile + (2000.0 * lr).exp() + }); + // First param should be fragile (exponential sensitivity) + assert!( + result + .per_param + .first() + .map_or(false, |p| p.is_fragile), + "Exponential function should be fragile for its parameter, score={}", + result.per_param.first().map_or(0.0, |p| p.sensitivity_score) + ); + assert!( + result.overall_fragility > 0.05, + "Should have non-trivial fragility, got {}", + result.overall_fragility + ); + } + + #[test] + fn test_perturbation_results_stored() { + let analyzer = SensitivityAnalyzer::new( + vec!["x".to_string()], + vec![1.0], + Some(vec![-0.10, 0.10]), + ); + let result = analyzer.analyze(|params| params.first().copied().unwrap_or(1.0)); + let param = result.per_param.first(); + assert!(param.is_some(), "Should have at least one param result"); + let param = param.unwrap_or_else(|| unreachable!()); + assert_eq!( + param.perturbation_results.len(), + 2, + "Should have 2 perturbation results" + ); + } +} diff --git a/ml/src/validation/mod.rs b/ml/src/validation/mod.rs index 0f0e2dc5a..55939c89a 100644 --- a/ml/src/validation/mod.rs +++ b/ml/src/validation/mod.rs @@ -8,6 +8,7 @@ pub mod adapters; pub mod degradation; pub mod financial; pub mod harness; +pub mod noise; pub mod ppo_adapter; pub mod regime_analysis; pub mod statistical; @@ -51,3 +52,6 @@ pub use temporal_guard::{LeakageAuditReport, NormalizationStats, TemporalGuard}; // Re-export degradation tracking types pub use degradation::{DegradationReport, DegradationTracker, FoldMetrics}; + +// Re-export noise injection for robustness testing +pub use noise::{compute_robustness_score, NoiseConfig, NoiseInjector}; diff --git a/ml/src/validation/noise.rs b/ml/src/validation/noise.rs new file mode 100644 index 000000000..b91678afe --- /dev/null +++ b/ml/src/validation/noise.rs @@ -0,0 +1,256 @@ +//! Feature noise injection for robustness testing. +//! +//! Tests whether models are genuinely learning signal vs overfitting +//! to exact feature values by perturbing inputs and measuring degradation. + +use crate::MLError; +use crate::validation::TimeSeriesData; + +/// Noise injection methods for robustness testing. +#[derive(Debug)] +pub struct NoiseInjector; + +impl NoiseInjector { + /// Add Gaussian noise to features. `sigma_fraction` is relative to each feature's + /// standard deviation (e.g. 0.10 = 10% of std dev). + /// Prices and timestamps are unchanged. + pub fn gaussian_noise( + data: &TimeSeriesData, + sigma_fraction: f64, + seed: u64, + ) -> Result { + let mut rng = SimpleRng::new(seed); + let dim = data.features.first().map(|f| f.len()).unwrap_or(0); + + // Compute per-feature std dev + let n = data.features.len() as f64; + let mut means = vec![0.0_f64; dim]; + let mut vars = vec![0.0_f64; dim]; + + for row in &data.features { + for (j, val) in row.iter().enumerate() { + if let Some(m) = means.get_mut(j) { + *m += f64::from(*val); + } + } + } + for m in &mut means { + *m /= n; + } + + for row in &data.features { + for (j, val) in row.iter().enumerate() { + let mean = means.get(j).copied().unwrap_or(0.0); + if let Some(v) = vars.get_mut(j) { + *v += (f64::from(*val) - mean).powi(2); + } + } + } + // Use sample std dev; if zero (constant feature), fall back to the + // absolute mean so that noise is proportional to the feature magnitude. + // If both are zero, use 1.0 as a last-resort scale. + let stds: Vec = vars + .iter() + .enumerate() + .map(|(j, v)| { + let s = (v / (n - 1.0).max(1.0)).sqrt(); + if s > 1e-15 { + s + } else { + let abs_mean = means.get(j).copied().unwrap_or(0.0).abs(); + if abs_mean > 1e-15 { abs_mean } else { 1.0 } + } + }) + .collect(); + + let noisy_features: Vec> = data + .features + .iter() + .map(|row| { + row.iter() + .enumerate() + .map(|(j, &val)| { + let std = stds.get(j).copied().unwrap_or(1.0); + let noise = rng.normal() * sigma_fraction * std; + (f64::from(val) + noise) as f32 + }) + .collect() + }) + .collect(); + + TimeSeriesData::new(data.timestamps.clone(), noisy_features, data.prices.clone()) + } + + /// Randomly zero out features with the given probability. + /// Prices and timestamps are unchanged. + pub fn feature_dropout( + data: &TimeSeriesData, + drop_rate: f64, + seed: u64, + ) -> Result { + let mut rng = SimpleRng::new(seed); + + let dropped_features: Vec> = data + .features + .iter() + .map(|row| { + row.iter() + .map(|&val| if rng.uniform() < drop_rate { 0.0 } else { val }) + .collect() + }) + .collect(); + + TimeSeriesData::new( + data.timestamps.clone(), + dropped_features, + data.prices.clone(), + ) + } +} + +/// Compute robustness score from Sharpe ratios at increasing noise levels. +/// +/// `sharpes[0]` = clean, `sharpes[1..]` = at noise levels \[5%, 10%, 20%\]. +/// Score = area under curve normalized to \[0, 1\]. +/// 1.0 = no degradation, 0.0 = immediate collapse. +pub fn compute_robustness_score(sharpes: &[f64]) -> f64 { + if sharpes.is_empty() { + return 0.0; + } + let baseline = sharpes.first().copied().unwrap_or(0.0); + if baseline.abs() < 1e-10 { + return 0.0; + } + + let n = sharpes.len() as f64; + let area: f64 = sharpes.iter().map(|s| s / baseline).sum::() / n; + area.clamp(0.0, 1.0) +} + +/// Noise configuration for the validation harness. +#[derive(Debug, Clone)] +pub struct NoiseConfig { + /// Noise levels to test (as fraction of feature std dev). + pub noise_levels: Vec, + /// RNG seed for reproducibility. + pub seed: u64, +} + +impl Default for NoiseConfig { + fn default() -> Self { + Self { + noise_levels: vec![0.05, 0.10, 0.20], + seed: 42, + } + } +} + +/// Minimal PRNG (xorshift64) to avoid external dependency. +struct SimpleRng { + state: u64, +} + +impl SimpleRng { + fn new(seed: u64) -> Self { + Self { + state: seed.wrapping_add(1), + } + } + + fn next_u64(&mut self) -> u64 { + self.state ^= self.state << 13; + self.state ^= self.state >> 7; + self.state ^= self.state << 17; + self.state + } + + fn uniform(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + /// Box-Muller transform for normal distribution. + fn normal(&mut self) -> f64 { + let u1 = self.uniform().max(1e-15); + let u2 = self.uniform(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use crate::validation::TimeSeriesData; + + fn make_data(n: usize) -> TimeSeriesData { + let timestamps: Vec<_> = (0..n) + .map(|i| { + Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0) + .single() + .unwrap_or_else(Utc::now) + + chrono::Duration::days(i as i64) + }) + .collect(); + let features: Vec> = (0..n).map(|_| vec![1.0, 2.0, 3.0]).collect(); + let mut prices = Vec::with_capacity(n); + let mut p = 100.0; + for i in 0..n { + prices.push(p); + p += 0.01 + 0.005 * (i as f64).sin(); + } + TimeSeriesData::new(timestamps, features, prices) + .unwrap_or_else(|e| unreachable!("make_data should not fail: {e}")) + } + + #[test] + fn test_gaussian_noise_changes_features() { + let data = make_data(50); + let noisy = NoiseInjector::gaussian_noise(&data, 0.1, 42); + assert!(noisy.is_ok()); + let noisy = noisy.unwrap_or_else(|_| unreachable!()); + let empty: Vec = vec![]; + let orig_f = data.features.first().unwrap_or(&empty); + let noisy_f = noisy.features.first().unwrap_or(&empty); + let any_different = orig_f + .iter() + .zip(noisy_f.iter()) + .any(|(a, b)| (a - b).abs() > 1e-10); + assert!( + any_different, + "Gaussian noise should modify at least one feature" + ); + assert_eq!(data.prices, noisy.prices); + } + + #[test] + fn test_feature_dropout_zeros_some_features() { + let data = make_data(50); + let dropped = NoiseInjector::feature_dropout(&data, 0.5, 42); + assert!(dropped.is_ok()); + let dropped = dropped.unwrap_or_else(|_| unreachable!()); + let total_zeros: usize = dropped + .features + .iter() + .flat_map(|row| row.iter()) + .filter(|v| v.abs() < 1e-10) + .count(); + assert!(total_zeros > 0, "Dropout should zero out some features"); + } + + #[test] + fn test_robustness_score_perfect_is_one() { + let sharpes = vec![1.5, 1.5, 1.5, 1.5]; + let score = compute_robustness_score(&sharpes); + assert!( + (score - 1.0).abs() < 0.01, + "Identical Sharpes should give score ~1.0, got {score}" + ); + } + + #[test] + fn test_robustness_score_collapse_is_low() { + let sharpes = vec![2.0, 0.0, 0.0, 0.0]; + let score = compute_robustness_score(&sharpes); + assert!(score < 0.5, "Collapse should give low score, got {score}"); + } +} diff --git a/risk/src/correlation_monitor.rs b/risk/src/correlation_monitor.rs new file mode 100644 index 000000000..11d63d7ae --- /dev/null +++ b/risk/src/correlation_monitor.rs @@ -0,0 +1,287 @@ +//! 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_string()) + .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)] +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" + ); + } + } +} diff --git a/risk/src/enforcement/actions.rs b/risk/src/enforcement/actions.rs new file mode 100644 index 000000000..64d6f0794 --- /dev/null +++ b/risk/src/enforcement/actions.rs @@ -0,0 +1,281 @@ +//! Pluggable risk actions that execute in response to risk signals. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Alert severity levels matching drawdown monitor thresholds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AlertSeverity { + Warning, + Alert, + Emergency, +} + +impl fmt::Display for AlertSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Warning => write!(f, "Warning"), + Self::Alert => write!(f, "Alert"), + Self::Emergency => write!(f, "Emergency"), + } + } +} + +/// Outcome of a risk action execution. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ActionOutcome { + pub action_name: String, + pub success: bool, + pub message: String, +} + +/// Mutable risk context that actions modify. +#[derive(Debug, Clone)] +pub struct RiskContext { + /// Multiplier for new position sizes (1.0 = full, 0.5 = half, 0.0 = none). + pub position_size_multiplier: f64, + /// Whether new entries are halted. + pub halt_new_entries: bool, + /// Whether all positions should be closed. + pub close_all: bool, + /// Whether stops should be tightened (and by how much, as fraction). + pub tighten_stops_by: Option, +} + +impl Default for RiskContext { + fn default() -> Self { + Self { + position_size_multiplier: 1.0, + halt_new_entries: false, + close_all: false, + tighten_stops_by: None, + } + } +} + +/// Trait for pluggable risk response actions. +pub trait RiskAction: Send + Sync + fmt::Debug { + /// Severity level this action responds to. + fn severity(&self) -> AlertSeverity; + + /// Execute the action, modifying the risk context. + fn execute(&self, context: &mut RiskContext) -> ActionOutcome; + + /// Rollback the action (for false triggers). + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome; + + /// Human-readable name. + fn name(&self) -> &str; +} + +/// Reduce new position sizes by a given fraction. +#[derive(Debug)] +pub struct ReducePositionSizeAction { + reduction_factor: f64, +} + +impl ReducePositionSizeAction { + pub fn new(reduction_factor: f64) -> Self { + Self { + reduction_factor: reduction_factor.clamp(0.0, 1.0), + } + } +} + +impl RiskAction for ReducePositionSizeAction { + fn severity(&self) -> AlertSeverity { + AlertSeverity::Warning + } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.position_size_multiplier *= self.reduction_factor; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: format!( + "Position size multiplier reduced to {:.2}", + context.position_size_multiplier + ), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + if self.reduction_factor > 0.0 { + context.position_size_multiplier /= self.reduction_factor; + } + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: format!( + "Position size multiplier restored to {:.2}", + context.position_size_multiplier + ), + } + } + + fn name(&self) -> &str { + "ReducePositionSize" + } +} + +/// Halt all new trade entries. +#[derive(Debug)] +pub struct HaltNewEntriesAction; + +impl RiskAction for HaltNewEntriesAction { + fn severity(&self) -> AlertSeverity { + AlertSeverity::Alert + } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.halt_new_entries = true; + context.tighten_stops_by = Some(0.25); + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "New entries halted, stops tightened by 25%".to_string(), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + context.halt_new_entries = false; + context.tighten_stops_by = None; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "New entries re-enabled, stops restored".to_string(), + } + } + + fn name(&self) -> &str { + "HaltNewEntries" + } +} + +/// Close all open positions (emergency). +#[derive(Debug)] +pub struct CloseAllPositionsAction; + +impl RiskAction for CloseAllPositionsAction { + fn severity(&self) -> AlertSeverity { + AlertSeverity::Emergency + } + + fn execute(&self, context: &mut RiskContext) -> ActionOutcome { + context.close_all = true; + context.halt_new_entries = true; + context.position_size_multiplier = 0.0; + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "All positions marked for closure, trading halted".to_string(), + } + } + + fn rollback(&self, context: &mut RiskContext) -> ActionOutcome { + context.close_all = false; + // Don't restore halt_new_entries or multiplier -- recovery mode handles that + ActionOutcome { + action_name: self.name().to_string(), + success: true, + message: "Close-all flag cleared (recovery mode will restore trading)".to_string(), + } + } + + fn name(&self) -> &str { + "CloseAllPositions" + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_reduce_position_action_severity() { + let action = ReducePositionSizeAction::new(0.5); + assert_eq!(action.severity(), AlertSeverity::Warning); + } + + #[test] + fn test_halt_new_entries_severity() { + let action = HaltNewEntriesAction; + assert_eq!(action.severity(), AlertSeverity::Alert); + } + + #[test] + fn test_close_all_positions_severity() { + let action = CloseAllPositionsAction; + assert_eq!(action.severity(), AlertSeverity::Emergency); + } + + #[test] + fn test_risk_context_default() { + let ctx = RiskContext::default(); + assert!((ctx.position_size_multiplier - 1.0).abs() < f64::EPSILON); + assert!(!ctx.halt_new_entries); + assert!(!ctx.close_all); + } + + #[test] + fn test_reduce_position_execute_and_rollback() { + let action = ReducePositionSizeAction::new(0.5); + let mut ctx = RiskContext::default(); + + let outcome = action.execute(&mut ctx); + assert!(outcome.success); + assert!((ctx.position_size_multiplier - 0.5).abs() < f64::EPSILON); + + let outcome = action.rollback(&mut ctx); + assert!(outcome.success); + assert!((ctx.position_size_multiplier - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_halt_new_entries_execute_and_rollback() { + let action = HaltNewEntriesAction; + let mut ctx = RiskContext::default(); + + let outcome = action.execute(&mut ctx); + assert!(outcome.success); + assert!(ctx.halt_new_entries); + assert_eq!(ctx.tighten_stops_by, Some(0.25)); + + let outcome = action.rollback(&mut ctx); + assert!(outcome.success); + assert!(!ctx.halt_new_entries); + assert_eq!(ctx.tighten_stops_by, None); + } + + #[test] + fn test_close_all_positions_execute_and_rollback() { + let action = CloseAllPositionsAction; + let mut ctx = RiskContext::default(); + + let outcome = action.execute(&mut ctx); + assert!(outcome.success); + assert!(ctx.close_all); + assert!(ctx.halt_new_entries); + assert!((ctx.position_size_multiplier).abs() < f64::EPSILON); + + let outcome = action.rollback(&mut ctx); + assert!(outcome.success); + assert!(!ctx.close_all); + } + + #[test] + fn test_reduction_factor_clamped() { + let action = ReducePositionSizeAction::new(2.0); + let mut ctx = RiskContext::default(); + action.execute(&mut ctx); + // Factor clamped to 1.0, so multiplier stays at 1.0 + assert!((ctx.position_size_multiplier - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_alert_severity_display() { + assert_eq!(format!("{}", AlertSeverity::Warning), "Warning"); + assert_eq!(format!("{}", AlertSeverity::Alert), "Alert"); + assert_eq!(format!("{}", AlertSeverity::Emergency), "Emergency"); + } +} diff --git a/risk/src/enforcement/mod.rs b/risk/src/enforcement/mod.rs new file mode 100644 index 000000000..32f7e6bda --- /dev/null +++ b/risk/src/enforcement/mod.rs @@ -0,0 +1,6 @@ +//! Risk auto-enforcement module. +//! +//! Bridges risk detection (drawdown alerts, drift scores, kill switches) +//! to concrete actions (position reduction, trading halts, liquidation). + +pub mod actions; diff --git a/risk/src/lib.rs b/risk/src/lib.rs index a08673e14..677ca84ef 100644 --- a/risk/src/lib.rs +++ b/risk/src/lib.rs @@ -157,6 +157,10 @@ pub mod compliance; pub mod drawdown_monitor; pub mod safety; +// Risk enforcement and correlation monitoring +pub mod enforcement; +pub mod correlation_monitor; + // RE-EXPORTS FOR TEST COMPATIBILITY // The following re-exports are required for test suite compilation // Tests import these types directly from the risk crate