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:
jgrusewski
2026-02-21 11:43:50 +01:00
parent ff6ad2d039
commit 4ff4205a4b
8 changed files with 1034 additions and 0 deletions

View File

@@ -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)]

View 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(&params);
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"
);
}
}

View File

@@ -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
View 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}");
}
}

View 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"
);
}
}
}

View 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");
}
}

View 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;

View File

@@ -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