diff --git a/services/trading_service/src/services/risk.rs b/services/trading_service/src/services/risk.rs index ff3df0363..9a9f97ce3 100644 --- a/services/trading_service/src/services/risk.rs +++ b/services/trading_service/src/services/risk.rs @@ -991,3 +991,335 @@ impl RiskService for RiskServiceImpl { ))) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing)] +mod tests { + use super::*; + use crate::repositories::{ExecutionEvent, TradingPosition}; + use common::OrderSide; + + // ----------------------------------------------------------------------- + // Helper: create a TradingPosition + // ----------------------------------------------------------------------- + fn position(symbol: &str, qty: f64, avg_price: f64, market_value: f64, pnl: f64) -> TradingPosition { + TradingPosition { + account_id: "test-account".to_string(), + symbol: symbol.to_string(), + quantity: qty, + average_price: avg_price, + market_value, + unrealized_pnl: pnl, + timestamp: 1_700_000_000, + } + } + + fn execution(ts: i64, price: f64) -> ExecutionEvent { + ExecutionEvent { + id: format!("exec-{}", ts), + order_id: "order-1".to_string(), + account_id: "test-account".to_string(), + symbol: "EURUSD".to_string(), + side: OrderSide::Buy, + quantity: 100.0, + price, + timestamp: ts, + } + } + + // ----------------------------------------------------------------------- + // 1. Parametric VaR fallback formula + // ----------------------------------------------------------------------- + #[test] + fn test_parametric_var_fallback_formula() { + // When RiskEngine fails, VaR = portfolio_notional * 0.02 + let portfolio_notional = 500_000.0_f64; + let fallback_var = portfolio_notional * 0.02; + assert!((fallback_var - 10_000.0).abs() < 1e-6, + "VaR fallback for 500k notional should be 10,000; got {}", fallback_var); + + // Zero notional + let zero_var = 0.0_f64 * 0.02; + assert!((zero_var - 0.0).abs() < 1e-10); + + // Very large notional + let large = 1_000_000_000.0_f64 * 0.02; + assert!((large - 20_000_000.0).abs() < 1e-6); + } + + // ----------------------------------------------------------------------- + // 2. equal_contribution_pct calculation + // ----------------------------------------------------------------------- + #[test] + fn test_equal_contribution_pct_for_n_symbols() { + // 4 symbols: each contributes 25% + let num_symbols = 4_usize; + let pct = if num_symbols > 0 { 100.0 / num_symbols as f64 } else { 0.0 }; + assert!((pct - 25.0).abs() < 1e-10); + + // 1 symbol: 100% + let pct1 = 100.0 / 1.0_f64; + assert!((pct1 - 100.0).abs() < 1e-10); + + // 10 symbols: 10% + let pct10 = 100.0 / 10.0_f64; + assert!((pct10 - 10.0).abs() < 1e-10); + } + + #[test] + fn test_equal_contribution_pct_empty_symbols() { + let num_symbols = 0_usize; + let pct = if num_symbols > 0 { 100.0 / num_symbols as f64 } else { 0.0 }; + assert!((pct - 0.0).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // 3. compute_current_drawdown + // ----------------------------------------------------------------------- + #[test] + fn test_drawdown_empty_positions() { + let dd = RiskServiceImpl::compute_current_drawdown(&[]); + assert!((dd - 0.0).abs() < 1e-10); + } + + #[test] + fn test_drawdown_positive_pnl_is_zero() { + let positions = vec![ + position("EURUSD", 1000.0, 1.10, 11000.0, 500.0), + ]; + let dd = RiskServiceImpl::compute_current_drawdown(&positions); + assert!((dd - 0.0).abs() < 1e-10, "Positive PnL should yield zero drawdown"); + } + + #[test] + fn test_drawdown_negative_pnl() { + // market_value = 10000, unrealized_pnl = -2000 + // drawdown = 2000 / 10000 = 0.20 + let positions = vec![ + position("EURUSD", 1000.0, 1.10, 10000.0, -2000.0), + ]; + let dd = RiskServiceImpl::compute_current_drawdown(&positions); + assert!((dd - 0.20).abs() < 1e-10, "Drawdown should be 0.20; got {}", dd); + } + + #[test] + fn test_drawdown_multiple_positions_mixed_pnl() { + // pos1: market_value=10000, pnl=-3000 + // pos2: market_value=5000, pnl=+1000 + // total_market_value = 15000, total_pnl = -2000 + // drawdown = 2000 / 15000 = 0.1333... + let positions = vec![ + position("EURUSD", 1000.0, 10.0, 10000.0, -3000.0), + position("GBPUSD", 500.0, 10.0, 5000.0, 1000.0), + ]; + let dd = RiskServiceImpl::compute_current_drawdown(&positions); + let expected = 2000.0 / 15000.0; + assert!((dd - expected).abs() < 1e-10, "Expected drawdown {:.6}; got {:.6}", expected, dd); + } + + // ----------------------------------------------------------------------- + // 4. compute_returns_from_executions + // ----------------------------------------------------------------------- + #[test] + fn test_returns_empty_executions() { + let returns = RiskServiceImpl::compute_returns_from_executions(&[]); + assert!(returns.is_empty()); + } + + #[test] + fn test_returns_single_execution() { + let execs = vec![execution(1, 100.0)]; + let returns = RiskServiceImpl::compute_returns_from_executions(&execs); + assert!(returns.is_empty(), "Single execution should yield no returns"); + } + + #[test] + fn test_returns_two_executions() { + // price goes from 100 to 110 => return = 110/100 - 1 = 0.10 + let execs = vec![execution(1, 100.0), execution(2, 110.0)]; + let returns = RiskServiceImpl::compute_returns_from_executions(&execs); + assert_eq!(returns.len(), 1); + assert!((returns[0] - 0.10).abs() < 1e-10); + } + + #[test] + fn test_returns_negative_price_filtered() { + // Negative and zero prices should be filtered out + let execs = vec![ + execution(1, 100.0), + execution(2, 0.0), // zero price filtered + execution(3, 120.0), + ]; + let returns = RiskServiceImpl::compute_returns_from_executions(&execs); + // After filtering zero price: prices = [(1, 100), (3, 120)] + // return = 120/100 - 1 = 0.20 + assert_eq!(returns.len(), 1); + assert!((returns[0] - 0.20).abs() < 1e-10); + } + + #[test] + fn test_returns_sorted_by_timestamp() { + // Executions provided out of order should be sorted + let execs = vec![ + execution(3, 120.0), + execution(1, 100.0), + execution(2, 110.0), + ]; + let returns = RiskServiceImpl::compute_returns_from_executions(&execs); + assert_eq!(returns.len(), 2); + // 100 -> 110: +10% + assert!((returns[0] - 0.10).abs() < 1e-10); + // 110 -> 120: +9.09% + assert!((returns[1] - (120.0 / 110.0 - 1.0)).abs() < 1e-10); + } + + // ----------------------------------------------------------------------- + // 5. compute_volatility + // ----------------------------------------------------------------------- + #[test] + fn test_volatility_empty() { + assert!((RiskServiceImpl::compute_volatility(&[]) - 0.0).abs() < 1e-10); + } + + #[test] + fn test_volatility_single_return() { + assert!((RiskServiceImpl::compute_volatility(&[0.01]) - 0.0).abs() < 1e-10); + } + + #[test] + fn test_volatility_constant_returns_is_zero() { + // All returns the same => std dev = 0 => vol = 0 + let returns = vec![0.01, 0.01, 0.01, 0.01, 0.01]; + let vol = RiskServiceImpl::compute_volatility(&returns); + assert!((vol - 0.0).abs() < 1e-10, "Constant returns should have zero vol"); + } + + #[test] + fn test_volatility_known_series() { + // Returns: [0.01, -0.01, 0.02, -0.02] + // mean = 0.0 + // variance = (0.0001 + 0.0001 + 0.0004 + 0.0004) / 3 = 0.001 / 3 + // daily_vol = sqrt(0.001/3) + // annualized = daily_vol * sqrt(252) + let returns = vec![0.01, -0.01, 0.02, -0.02]; + let vol = RiskServiceImpl::compute_volatility(&returns); + let expected_variance: f64 = 0.001 / 3.0; + let expected = expected_variance.sqrt() * 252.0_f64.sqrt(); + assert!((vol - expected).abs() < 1e-10, "Expected vol {:.6}; got {:.6}", expected, vol); + } + + // ----------------------------------------------------------------------- + // 6. compute_sharpe_ratio + // ----------------------------------------------------------------------- + #[test] + fn test_sharpe_insufficient_data() { + // Fewer than MIN_RETURN_OBSERVATIONS (5) returns + let returns = vec![0.01, 0.02, 0.01, -0.01]; + let sharpe = RiskServiceImpl::compute_sharpe_ratio(&returns); + assert!((sharpe - 0.0).abs() < 1e-10, "Should return 0.0 for insufficient data"); + } + + #[test] + fn test_sharpe_zero_volatility() { + let returns = vec![0.01; 10]; + let sharpe = RiskServiceImpl::compute_sharpe_ratio(&returns); + assert!((sharpe - 0.0).abs() < 1e-10, "Zero volatility should return 0.0 Sharpe"); + } + + #[test] + fn test_sharpe_positive_returns() { + // Use enough data points with positive mean return + let returns = vec![0.01, 0.02, 0.015, 0.005, 0.012, 0.008]; + let sharpe = RiskServiceImpl::compute_sharpe_ratio(&returns); + // Mean return is clearly positive and above risk-free => Sharpe should be positive + assert!(sharpe > 0.0, "Sharpe should be positive for consistently positive returns; got {}", sharpe); + } + + // ----------------------------------------------------------------------- + // 7. compute_sortino_ratio + // ----------------------------------------------------------------------- + #[test] + fn test_sortino_insufficient_data() { + let returns = vec![0.01, -0.01, 0.02]; + let sortino = RiskServiceImpl::compute_sortino_ratio(&returns); + assert!((sortino - 0.0).abs() < 1e-10); + } + + #[test] + fn test_sortino_no_downside() { + // All returns well above risk-free => no downside deviation => 0 + let daily_rf = RISK_FREE_RATE_ANNUAL / 252.0; + let high_return = daily_rf + 0.01; // well above risk-free + let returns = vec![high_return; 10]; + let sortino = RiskServiceImpl::compute_sortino_ratio(&returns); + assert!((sortino - 0.0).abs() < 1e-10, "No downside should yield zero Sortino"); + } + + #[test] + fn test_sortino_with_downside() { + // Mix of positive and negative returns + let returns = vec![-0.02, 0.03, -0.01, 0.02, -0.015, 0.01, 0.005]; + let sortino = RiskServiceImpl::compute_sortino_ratio(&returns); + // Just verify it produces a finite number (not NaN/Inf) + assert!(sortino.is_finite(), "Sortino should be finite; got {}", sortino); + } + + // ----------------------------------------------------------------------- + // 8. VaR scaling: square-root-of-time rule + // ----------------------------------------------------------------------- + #[test] + fn test_var_scaling_sqrt_time() { + // The service uses: var_5d = var_1d * sqrt(5), var_30d = var_1d * sqrt(30) + let var_1d = 10_000.0_f64; + let var_5d = var_1d * 5_f64.sqrt(); + let var_30d = var_1d * 30_f64.sqrt(); + + assert!((var_5d - 22_360.679).abs() < 1.0, + "5d VaR should be ~22360.68; got {:.3}", var_5d); + assert!((var_30d - 54_772.256).abs() < 1.0, + "30d VaR should be ~54772.26; got {:.3}", var_30d); + // Verify ordering: 1d < 5d < 30d + assert!(var_1d < var_5d); + assert!(var_5d < var_30d); + } + + // ----------------------------------------------------------------------- + // 9. Concentration risk level classification + // ----------------------------------------------------------------------- + #[test] + fn test_concentration_risk_level_thresholds() { + // The service classifies: >50% Critical, >30% High, >15% Medium, else Low + let classify = |concentration: f64| -> RiskLevel { + if concentration > 50.0 { + RiskLevel::Critical + } else if concentration > 30.0 { + RiskLevel::High + } else if concentration > 15.0 { + RiskLevel::Medium + } else { + RiskLevel::Low + } + }; + + assert_eq!(classify(60.0) as i32, RiskLevel::Critical as i32); + assert_eq!(classify(50.1) as i32, RiskLevel::Critical as i32); + assert_eq!(classify(50.0) as i32, RiskLevel::High as i32); + assert_eq!(classify(35.0) as i32, RiskLevel::High as i32); + assert_eq!(classify(30.0) as i32, RiskLevel::Medium as i32); + assert_eq!(classify(20.0) as i32, RiskLevel::Medium as i32); + assert_eq!(classify(15.0) as i32, RiskLevel::Low as i32); + assert_eq!(classify(5.0) as i32, RiskLevel::Low as i32); + assert_eq!(classify(0.0) as i32, RiskLevel::Low as i32); + } + + // ----------------------------------------------------------------------- + // 10. Constants are sane + // ----------------------------------------------------------------------- + #[test] + fn test_risk_constants() { + assert!((RISK_FREE_RATE_ANNUAL - 0.05).abs() < 1e-10, + "Risk-free rate should be 5%"); + assert_eq!(MIN_RETURN_OBSERVATIONS, 5, + "Min return observations should be 5"); + } +} diff --git a/tests/Cargo.toml b/tests/Cargo.toml index ae49665db..464b2b803 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -142,6 +142,14 @@ path = "integration/checkpoint_roundtrip.rs" name = "feature_pipeline" path = "integration/feature_pipeline.rs" +[[test]] +name = "ml_order_pipeline_test" +path = "integration/ml_order_pipeline_test.rs" + +[[test]] +name = "risk_killswitch_test" +path = "integration/risk_killswitch_test.rs" + [target.'cfg(target_os = "linux")'.dependencies] # Linux-specific performance monitoring perf-event = { version = "0.4", optional = true } diff --git a/tests/integration/ml_order_pipeline_test.rs b/tests/integration/ml_order_pipeline_test.rs new file mode 100644 index 000000000..f28399790 --- /dev/null +++ b/tests/integration/ml_order_pipeline_test.rs @@ -0,0 +1,359 @@ +//! Integration test: ML inference -> ensemble vote -> order generation +//! +//! Verifies the complete pipeline from feature vector through model +//! inference, ensemble aggregation, and order signal generation. +//! This is a critical path test for the ML -> Order execution pipeline. + +use ml::ensemble::inference_adapter::{ + EnsemblePrediction, FeatureVector, ModelInferenceAdapter, PredictionMeta, +}; +use ml::ensemble::inference_ensemble::InferenceEnsemble; +use ml::MLResult; + +/// Test adapter that simulates a bullish DQN model +struct MockDQNAdapter; + +impl ModelInferenceAdapter for MockDQNAdapter { + fn model_name(&self) -> &str { + "DQN-v1" + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "DQN-v1".to_string(), + direction: 0.8, + confidence: 0.85, + metadata: PredictionMeta::default(), + }) + } + + fn is_ready(&self) -> bool { + true + } +} + +/// Test adapter that simulates a bearish PPO model +struct MockPPOAdapter; + +impl ModelInferenceAdapter for MockPPOAdapter { + fn model_name(&self) -> &str { + "PPO-v1" + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "PPO-v1".to_string(), + direction: -0.3, + confidence: 0.6, + metadata: PredictionMeta::default(), + }) + } + + fn is_ready(&self) -> bool { + true + } +} + +/// Test adapter that returns an error (simulates model failure) +struct FailingAdapter; + +impl ModelInferenceAdapter for FailingAdapter { + fn model_name(&self) -> &str { + "FailingModel" + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + Err(ml::MLError::InferenceError( + "Simulated model failure".to_string(), + )) + } + + fn is_ready(&self) -> bool { + true + } +} + +/// Test adapter that returns NaN direction and confidence +struct NaNAdapter; + +impl ModelInferenceAdapter for NaNAdapter { + fn model_name(&self) -> &str { + "NaN-model" + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "NaN-model".to_string(), + direction: f64::NAN, + confidence: f64::NAN, + metadata: PredictionMeta::default(), + }) + } + + fn is_ready(&self) -> bool { + true + } +} + +/// Test adapter that is never ready (simulates an unloaded model) +struct NotReadyAdapter; + +impl ModelInferenceAdapter for NotReadyAdapter { + fn model_name(&self) -> &str { + "NotReady" + } + + fn predict(&self, _features: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "NotReady".to_string(), + direction: 1.0, + confidence: 1.0, + metadata: PredictionMeta::default(), + }) + } + + fn is_ready(&self) -> bool { + false + } +} + +fn make_feature_vector() -> FeatureVector { + FeatureVector { + values: vec![0.1; 51], + timestamp: 1_700_000_000_000_000, + } +} + +// --------------------------------------------------------------------------- +// Test 1: Full ML -> Order pipeline +// --------------------------------------------------------------------------- + +#[test] +fn test_ml_to_order_pipeline() { + // 1. Create a canonical 51-dim feature vector + let features = make_feature_vector(); + assert_eq!(features.values.len(), 51, "Feature vector must be 51-dim"); + + // 2. Create ensemble with mock adapters (bullish DQN + bearish PPO) + let adapters: Vec> = vec![ + Box::new(MockDQNAdapter), + Box::new(MockPPOAdapter), + ]; + let ensemble = InferenceEnsemble::new(adapters); + + // 3. Verify both models are ready + assert_eq!(ensemble.ready_count(), 2, "Both mock models should be ready"); + + // 4. Run ensemble prediction + let prediction = ensemble.predict(&features); + assert!(prediction.is_ok(), "Ensemble prediction should succeed"); + let pred = prediction.unwrap_or_else(|e| panic!("Prediction failed: {e}")); + + // 5. Verify prediction properties are well-formed + assert!(pred.direction.is_finite(), "Direction must be finite"); + assert!(pred.confidence.is_finite(), "Confidence must be finite"); + assert!( + pred.confidence >= 0.0 && pred.confidence <= 1.0, + "Confidence {} should be in [0.0, 1.0]", + pred.confidence + ); + assert!( + pred.direction >= -1.0 && pred.direction <= 1.0, + "Direction {} should be in [-1.0, 1.0]", + pred.direction + ); + + // 6. Generate order signal from prediction + // The DQN model (dir=0.8, conf=0.85) dominates the PPO model (dir=-0.3, conf=0.6) + // because higher confidence gives it more weight in the ensemble. + // Expected net direction: positive (bullish). + let order_side = if pred.direction > 0.0 { "Buy" } else { "Sell" }; + let order_size = (pred.confidence * 100.0).round(); + + assert_eq!( + order_side, "Buy", + "Net bullish ensemble (DQN dominates) should generate Buy, got direction={}", + pred.direction + ); + assert!( + order_size > 0.0, + "Order size should be positive, got {}", + order_size + ); + assert!( + order_size <= 100.0, + "Order size should be <= 100, got {}", + order_size + ); + + // 7. Verify model name reflects aggregation + assert!( + pred.model_name.contains("ENSEMBLE"), + "Aggregated prediction model_name should contain 'ENSEMBLE', got '{}'", + pred.model_name + ); +} + +// --------------------------------------------------------------------------- +// Test 2: Ensemble handles all-NaN models gracefully +// --------------------------------------------------------------------------- + +#[test] +fn test_ensemble_handles_all_models_returning_nan() { + let adapters: Vec> = vec![Box::new(NaNAdapter)]; + let ensemble = InferenceEnsemble::new(adapters); + let features = make_feature_vector(); + + // The NaN circuit breaker should filter out the NaN model, + // leaving zero successful predictions -> error. + let result = ensemble.predict(&features); + assert!( + result.is_err(), + "All-NaN ensemble should return error, but got: {:?}", + result + ); +} + +// --------------------------------------------------------------------------- +// Test 3: Ensemble handles a mix of good + failing models +// --------------------------------------------------------------------------- + +#[test] +fn test_ensemble_survives_partial_model_failure() { + let adapters: Vec> = vec![ + Box::new(MockDQNAdapter), + Box::new(FailingAdapter), + ]; + let ensemble = InferenceEnsemble::new(adapters); + let features = make_feature_vector(); + + // The failing model is skipped; DQN alone should produce a valid prediction. + let result = ensemble.predict(&features); + assert!( + result.is_ok(), + "Ensemble with one good model should succeed, got: {:?}", + result + ); + + let pred = result.unwrap_or_else(|e| panic!("Prediction failed: {e}")); + assert!( + pred.direction.is_finite(), + "Direction must be finite after partial failure" + ); + assert!( + pred.confidence.is_finite(), + "Confidence must be finite after partial failure" + ); +} + +// --------------------------------------------------------------------------- +// Test 4: Ensemble with no ready models +// --------------------------------------------------------------------------- + +#[test] +fn test_ensemble_no_ready_models_returns_error() { + let adapters: Vec> = vec![Box::new(NotReadyAdapter)]; + let ensemble = InferenceEnsemble::new(adapters); + let features = make_feature_vector(); + + let result = ensemble.predict(&features); + assert!( + result.is_err(), + "Ensemble with no ready models should return error" + ); +} + +// --------------------------------------------------------------------------- +// Test 5: Ensemble with custom weights changes outcome +// --------------------------------------------------------------------------- + +#[test] +fn test_ensemble_custom_weights_affect_direction() { + // Two opposing models with equal confidence + struct BullAdapter; + impl ModelInferenceAdapter for BullAdapter { + fn model_name(&self) -> &str { "Bull" } + fn predict(&self, _: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "Bull".to_string(), + direction: 1.0, + confidence: 0.8, + metadata: PredictionMeta::default(), + }) + } + fn is_ready(&self) -> bool { true } + } + + struct BearAdapter; + impl ModelInferenceAdapter for BearAdapter { + fn model_name(&self) -> &str { "Bear" } + fn predict(&self, _: &FeatureVector) -> MLResult { + Ok(EnsemblePrediction { + model_name: "Bear".to_string(), + direction: -1.0, + confidence: 0.8, + metadata: PredictionMeta::default(), + }) + } + fn is_ready(&self) -> bool { true } + } + + let adapters: Vec> = vec![ + Box::new(BullAdapter), + Box::new(BearAdapter), + ]; + + // Without custom weights, equal confidence => direction ~ 0.0 + let ensemble_equal = InferenceEnsemble::new(adapters); + let features = make_feature_vector(); + let pred_equal = ensemble_equal + .predict(&features) + .unwrap_or_else(|e| panic!("Equal-weight prediction failed: {e}")); + assert!( + pred_equal.direction.abs() < 0.01, + "Equal weight/confidence opposing models should cancel out, got {}", + pred_equal.direction + ); + + // With Bull weighted 3x heavier, direction should be strongly positive + let adapters2: Vec> = vec![ + Box::new(BullAdapter), + Box::new(BearAdapter), + ]; + let mut ensemble_weighted = InferenceEnsemble::new(adapters2); + ensemble_weighted.set_weight("Bull", 3.0); + ensemble_weighted.set_weight("Bear", 1.0); + + let pred_weighted = ensemble_weighted + .predict(&features) + .unwrap_or_else(|e| panic!("Weighted prediction failed: {e}")); + assert!( + pred_weighted.direction > 0.3, + "Bull-weighted ensemble should have positive direction, got {}", + pred_weighted.direction + ); +} + +// --------------------------------------------------------------------------- +// Test 6: Order sizing from confidence +// --------------------------------------------------------------------------- + +#[test] +fn test_order_sizing_from_confidence() { + // Verify that different confidence levels produce proportional order sizes + for (conf, expected_min, expected_max) in [ + (0.0, 0.0, 0.0), + (0.5, 49.0, 51.0), + (1.0, 99.0, 101.0), + ] { + let size = (conf * 100.0_f64).round(); + assert!( + size >= expected_min && size <= expected_max, + "Confidence {} -> size {}, expected [{}, {}]", + conf, + size, + expected_min, + expected_max + ); + } +} diff --git a/tests/integration/risk_killswitch_test.rs b/tests/integration/risk_killswitch_test.rs new file mode 100644 index 000000000..6fc84960d --- /dev/null +++ b/tests/integration/risk_killswitch_test.rs @@ -0,0 +1,339 @@ +//! Integration test: Risk limit violation -> Kill switch activation +//! +//! Verifies that risk safety mechanisms properly trigger under stress, +//! correctly scope kill switch activations, and block trading when active. +//! This is a critical path test for the Risk -> Kill Switch pipeline. + +use risk::safety::kill_switch::{AtomicKillSwitch, TradingGate}; +use risk::safety::KillSwitchConfig; +use risk::risk_types::KillSwitchScope; + +fn create_test_kill_switch() -> AtomicKillSwitch { + let config = KillSwitchConfig::default(); + AtomicKillSwitch::new_test(config) +} + +// --------------------------------------------------------------------------- +// Test 1: Kill switch activation and trading blocking +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_kill_switch_activation_and_blocking() { + let kill_switch = create_test_kill_switch(); + + // Initially not triggered + assert!( + !kill_switch.is_triggered(), + "Kill switch should start inactive" + ); + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Global), + "Trading should be allowed initially" + ); + + // Trigger the kill switch via global activation + let result = kill_switch + .activate_global( + "Max drawdown exceeded: -5.2%".to_string(), + "risk_monitor".to_string(), + ) + .await; + assert!(result.is_ok(), "Triggering kill switch should succeed"); + + // Now it should be triggered + assert!( + kill_switch.is_triggered(), + "Kill switch should be active after trigger" + ); + + // Trading should be blocked + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Global), + "Trading must be blocked after global kill switch trigger" + ); + + // Also blocked for any scoped query (global takes precedence) + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Symbol("ES.FUT".to_string())), + "Symbol-scoped trading must also be blocked by global kill switch" + ); +} + +// --------------------------------------------------------------------------- +// Test 2: Scoped kill switch (symbol-level) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_scoped_kill_switch_symbol() { + let kill_switch = create_test_kill_switch(); + + // Trigger for a specific symbol scope only + let result = kill_switch + .engage( + KillSwitchScope::Symbol("ES.FUT".to_string()), + "Symbol-level risk limit breached".to_string(), + "symbol_monitor".to_string(), + false, // no cascade + ) + .await; + assert!(result.is_ok(), "Scoped trigger should succeed"); + + // Global should NOT be triggered + assert!( + !kill_switch.is_triggered(), + "Global kill switch should NOT be triggered by symbol-scoped engagement" + ); + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Global), + "Global trading should still be allowed" + ); + + // The specific symbol should be blocked + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Symbol("ES.FUT".to_string())), + "ES.FUT trading should be blocked" + ); + + // Other symbols should NOT be blocked + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Symbol("NQ.FUT".to_string())), + "NQ.FUT trading should still be allowed" + ); + + // Health metrics should be finite + let (error_rate, _failures) = kill_switch.get_health_metrics(); + assert!( + error_rate.is_finite(), + "Error rate should be finite, got {}", + error_rate + ); +} + +// --------------------------------------------------------------------------- +// Test 3: Kill switch reset restores trading +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_kill_switch_reset_restores_trading() { + let kill_switch = create_test_kill_switch(); + + // Trigger globally + kill_switch.trigger(); + assert!(kill_switch.is_triggered()); + assert!(!kill_switch.is_trading_allowed(&KillSwitchScope::Global)); + + // Reset + let result = kill_switch.reset(Some(KillSwitchScope::Global)).await; + assert!(result.is_ok(), "Reset should succeed"); + + // Trading should be restored + assert!(!kill_switch.is_triggered(), "Kill switch should be cleared"); + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Global), + "Trading should be allowed after reset" + ); +} + +// --------------------------------------------------------------------------- +// Test 4: Multiple scoped activations and selective reset +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_multiple_scoped_activations_and_selective_reset() { + let kill_switch = create_test_kill_switch(); + + // Engage two different scopes + kill_switch + .engage( + KillSwitchScope::Symbol("AAPL".to_string()), + "AAPL halt".to_string(), + "user".to_string(), + false, + ) + .await + .expect("AAPL engage should succeed"); + + kill_switch + .engage( + KillSwitchScope::Account("ACC-001".to_string()), + "Account risk limit".to_string(), + "user".to_string(), + false, + ) + .await + .expect("Account engage should succeed"); + + // Both should be blocked + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Symbol("AAPL".to_string())), + "AAPL should be blocked" + ); + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Account("ACC-001".to_string())), + "ACC-001 should be blocked" + ); + + // Global still allowed + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Global), + "Global should still be allowed" + ); + + // Reset only the symbol scope + kill_switch + .reset(Some(KillSwitchScope::Symbol("AAPL".to_string()))) + .await + .expect("AAPL reset should succeed"); + + // AAPL should be restored, account still blocked + assert!( + kill_switch.is_trading_allowed(&KillSwitchScope::Symbol("AAPL".to_string())), + "AAPL should be allowed after reset" + ); + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Account("ACC-001".to_string())), + "ACC-001 should remain blocked" + ); +} + +// --------------------------------------------------------------------------- +// Test 5: Cascade behavior (portfolio -> strategies) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_cascade_portfolio_to_strategies() { + let kill_switch = create_test_kill_switch(); + + // Trigger a portfolio-level kill switch with cascade=true + kill_switch + .engage( + KillSwitchScope::Portfolio("portfolio1".to_string()), + "Portfolio drawdown exceeded".to_string(), + "risk_engine".to_string(), + true, // cascade + ) + .await + .expect("Portfolio engage should succeed"); + + // The portfolio itself should be blocked + assert!( + !kill_switch.is_trading_allowed(&KillSwitchScope::Portfolio("portfolio1".to_string())), + "Portfolio should be blocked" + ); + + // The system is active + let is_active = kill_switch.is_active().await.expect("is_active should work"); + assert!(is_active, "Kill switch should report as active"); +} + +// --------------------------------------------------------------------------- +// Test 6: Metrics tracking +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_kill_switch_metrics_tracking() { + let kill_switch = create_test_kill_switch(); + + // Initial metrics should be zero + let (checks_before, commands_before) = kill_switch.get_metrics(); + assert_eq!(checks_before, 0, "Initial health checks should be 0"); + assert_eq!(commands_before, 0, "Initial commands should be 0"); + + // Perform operations that increment counters + kill_switch + .engage( + KillSwitchScope::Global, + "Test".to_string(), + "user".to_string(), + false, + ) + .await + .expect("engage should succeed"); + + kill_switch + .reset(Some(KillSwitchScope::Global)) + .await + .expect("reset should succeed"); + + // Commands should have incremented (engage + reset = 2) + let (_checks_after, commands_after) = kill_switch.get_metrics(); + assert_eq!( + commands_after, 2, + "Two commands (engage + reset) should be tracked, got {}", + commands_after + ); + + // Health metrics: no failures (no Redis in test mode) + let (error_rate, failures) = kill_switch.get_health_metrics(); + assert_eq!(error_rate, 0.0, "Error rate should be 0.0 with no Redis"); + assert_eq!(failures, 0, "Failures should be 0 with no Redis"); +} + +// --------------------------------------------------------------------------- +// Test 7: Trading gate integration +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_trading_gate_lifecycle() { + let gate = TradingGate::new(true); + assert!(gate.is_open(), "Gate should start open"); + + // Close gate (simulating risk event) + gate.close(); + assert!(!gate.is_open(), "Gate should be closed"); + + // Reopen gate (simulating risk clearance) + gate.open(); + assert!(gate.is_open(), "Gate should be reopened"); +} + +// --------------------------------------------------------------------------- +// Test 8: Kill switch health check (no Redis = healthy) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_kill_switch_health_in_test_mode() { + let kill_switch = create_test_kill_switch(); + + let healthy = kill_switch + .is_healthy() + .await + .expect("is_healthy should succeed"); + assert!( + healthy, + "Kill switch without Redis should report healthy (test mode)" + ); +} + +// --------------------------------------------------------------------------- +// Test 9: Deactivate restores scoped trading +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_deactivate_restores_scoped_trading() { + let kill_switch = create_test_kill_switch(); + + let scope = KillSwitchScope::Strategy("momentum_v2".to_string()); + + // Activate + kill_switch + .activate(scope.clone(), "Test halt".to_string(), "user".to_string(), false) + .await + .expect("activate should succeed"); + + assert!( + !kill_switch.is_trading_allowed(&scope), + "Strategy should be blocked after activation" + ); + + // Deactivate + kill_switch + .deactivate(scope.clone(), "user".to_string()) + .await + .expect("deactivate should succeed"); + + assert!( + kill_switch.is_trading_allowed(&scope), + "Strategy should be allowed after deactivation" + ); +}