Files
foxhunt/testing/integration/integration/risk_killswitch_test.rs
jgrusewski 9c3d741a08 refactor: restructure repo — crates/, bin/, testing/ layout
Move 17 library crates into crates/, CLI binary into bin/fxt,
consolidate 10 test crates into testing/, split config crate
from deployment config files.

Root directory reduced from 38+ to ~17 directories.
All Cargo.toml paths and build.rs proto refs updated.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 11:56:00 +01:00

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