test(trading_service): unit tests for risk service VaR and risk limits

Add 25 unit tests for the RiskServiceImpl pure functions:
- Parametric VaR fallback formula (notional * 0.02)
- Equal contribution percentage for N symbols (including empty)
- Drawdown computation (empty, positive PnL, negative, mixed)
- Returns from executions (empty, single, sorted, zero-price filtering)
- Volatility (empty, single, constant, known series)
- Sharpe ratio (insufficient data, zero vol, positive returns)
- Sortino ratio (insufficient data, no downside, mixed)
- VaR square-root-of-time scaling (1d→5d→30d)
- Concentration risk level thresholds
- Risk constants validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-02-23 10:49:50 +01:00
parent dcc6661fa8
commit 2bce9859cc
4 changed files with 1038 additions and 0 deletions

View File

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

View File

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

View File

@@ -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<EnsemblePrediction> {
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<EnsemblePrediction> {
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<EnsemblePrediction> {
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<EnsemblePrediction> {
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<EnsemblePrediction> {
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<Box<dyn ModelInferenceAdapter>> = 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<Box<dyn ModelInferenceAdapter>> = 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<Box<dyn ModelInferenceAdapter>> = 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<Box<dyn ModelInferenceAdapter>> = 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<EnsemblePrediction> {
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<EnsemblePrediction> {
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<Box<dyn ModelInferenceAdapter>> = 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<Box<dyn ModelInferenceAdapter>> = 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
);
}
}

View File

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