feat(validation,risk): add noise injection, sensitivity analysis, risk enforcement, and correlation monitor
- NoiseInjector: Gaussian noise and feature dropout for robustness testing (ml) - SensitivityAnalyzer: hyperparameter fragility detection with perturbation analysis (ml) - RiskAction trait + enforcement module: pluggable risk response actions with severity levels (risk) - CorrelationMonitor: rolling cross-asset correlation with effective exposure limits (risk) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)]
|
||||
|
||||
195
ml/src/hyperopt/sensitivity.rs
Normal file
195
ml/src/hyperopt/sensitivity.rs
Normal file
@@ -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<ParamSensitivity>,
|
||||
/// 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<String>,
|
||||
/// Baseline parameter values.
|
||||
base_params: Vec<f64>,
|
||||
/// Perturbation percentages to apply (e.g. [-0.20, -0.10, -0.05, 0.05, 0.10, 0.20]).
|
||||
perturbation_pcts: Vec<f64>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
base_params: Vec<f64>,
|
||||
perturbation_pcts: Option<Vec<f64>>,
|
||||
) -> 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<F: Fn(&[f64]) -> 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
256
ml/src/validation/noise.rs
Normal file
256
ml/src/validation/noise.rs
Normal file
@@ -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<TimeSeriesData, MLError> {
|
||||
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<f64> = 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<Vec<f32>> = 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<TimeSeriesData, MLError> {
|
||||
let mut rng = SimpleRng::new(seed);
|
||||
|
||||
let dropped_features: Vec<Vec<f32>> = 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::<f64>() / 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<f64>,
|
||||
/// 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<Vec<f32>> = (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<f32> = 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}");
|
||||
}
|
||||
}
|
||||
287
risk/src/correlation_monitor.rs
Normal file
287
risk/src/correlation_monitor.rs
Normal file
@@ -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<String, VecDeque<f64>>,
|
||||
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<PairCorrelation>,
|
||||
/// 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<CorrelationBreakdown>,
|
||||
}
|
||||
|
||||
/// 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<f64>, b: &VecDeque<f64>) -> f64 {
|
||||
let n = a.len().min(b.len());
|
||||
if n < 3 {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
let mean_a: f64 = a.iter().take(n).sum::<f64>() / n as f64;
|
||||
let mean_b: f64 = b.iter().take(n).sum::<f64>() / 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<String, f64>) -> 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<String, f64> = 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
281
risk/src/enforcement/actions.rs
Normal file
281
risk/src/enforcement/actions.rs
Normal file
@@ -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<f64>,
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
6
risk/src/enforcement/mod.rs
Normal file
6
risk/src/enforcement/mod.rs
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user