//! 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)] #[allow(clippy::unnecessary_map_or, clippy::unreachable)] 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_owned(), "gamma".to_owned()], 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_owned(), "gamma".to_owned()], 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_owned()], 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" ); } }