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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
359
tests/integration/ml_order_pipeline_test.rs
Normal file
359
tests/integration/ml_order_pipeline_test.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
339
tests/integration/risk_killswitch_test.rs
Normal file
339
tests/integration/risk_killswitch_test.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user