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>
340 lines
11 KiB
Rust
340 lines
11 KiB
Rust
//! 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"
|
|
);
|
|
}
|