Wave 64-65 cleanup: Proto regeneration and build system updates from Tonic 0.12→0.14 upgrade Files updated: - Cargo.lock: Dependency resolution for Tonic 0.14.2 - All build.rs: Updated for tonic-prost-build - Proto files: Regenerated with tonic-prost 0.14 - Examples/tests: Updated for new gRPC API 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
976 lines
38 KiB
Rust
976 lines
38 KiB
Rust
//! Risk Limit Enforcement Integration Tests
|
|
//!
|
|
//! This module provides comprehensive integration tests for risk limit enforcement
|
|
//! within the Foxhunt HFT system, including position limits, exposure limits,
|
|
//! drawdown protection, and real-time risk monitoring.
|
|
#![allow(unused_crate_dependencies)]
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::{Arc, atomic::{AtomicU64, AtomicBool, Ordering}};
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::sync::{RwLock, Mutex};
|
|
use uuid::Uuid;
|
|
use serde_json::json;
|
|
use chrono::{DateTime, Utc};
|
|
|
|
use tli::prelude::*;
|
|
use crate::fixtures::{IntegrationTestConfig, TestEnvironment, TestMetricsCollector};
|
|
use crate::mocks::{MockTradingService, MockRiskService, TestDatabaseManager};
|
|
|
|
/// Risk limit enforcement integration tests
|
|
pub struct RiskEnforcementTests {
|
|
client_suite: TliClientSuite,
|
|
mock_trading_service: MockTradingService,
|
|
mock_risk_service: MockRiskService,
|
|
test_db: TestDatabaseManager,
|
|
risk_manager: Arc<RiskManager>,
|
|
position_tracker: Arc<PositionTracker>,
|
|
metrics: Arc<RiskMetrics>,
|
|
config: IntegrationTestConfig,
|
|
test_accounts: Arc<RwLock<HashMap<String, TestAccount>>>,
|
|
}
|
|
|
|
/// Risk enforcement performance metrics
|
|
#[derive(Debug, Default)]
|
|
pub struct RiskMetrics {
|
|
pub risk_check_latency: AtomicU64,
|
|
pub position_update_latency: AtomicU64,
|
|
pub limit_breach_detection_latency: AtomicU64,
|
|
pub risk_checks_performed: AtomicU64,
|
|
pub orders_rejected: AtomicU64,
|
|
pub positions_liquidated: AtomicU64,
|
|
pub limit_breaches_detected: AtomicU64,
|
|
pub emergency_stops_triggered: AtomicU64,
|
|
}
|
|
|
|
impl RiskMetrics {
|
|
pub fn new() -> Self {
|
|
Self::default()
|
|
}
|
|
|
|
pub fn record_risk_check(&self, latency_ns: u64) {
|
|
self.risk_check_latency.store(latency_ns, Ordering::Relaxed);
|
|
self.risk_checks_performed.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn record_position_update(&self, latency_ns: u64) {
|
|
self.position_update_latency.store(latency_ns, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn record_limit_breach(&self, latency_ns: u64) {
|
|
self.limit_breach_detection_latency.store(latency_ns, Ordering::Relaxed);
|
|
self.limit_breaches_detected.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn record_order_rejection(&self) {
|
|
self.orders_rejected.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn record_position_liquidation(&self) {
|
|
self.positions_liquidated.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn record_emergency_stop(&self) {
|
|
self.emergency_stops_triggered.fetch_add(1, Ordering::Relaxed);
|
|
}
|
|
|
|
pub fn get_summary(&self) -> serde_json::Value {
|
|
json!({
|
|
"risk_check_latency_ns": self.risk_check_latency.load(Ordering::Relaxed),
|
|
"position_update_latency_ns": self.position_update_latency.load(Ordering::Relaxed),
|
|
"limit_breach_detection_latency_ns": self.limit_breach_detection_latency.load(Ordering::Relaxed),
|
|
"risk_checks_performed": self.risk_checks_performed.load(Ordering::Relaxed),
|
|
"orders_rejected": self.orders_rejected.load(Ordering::Relaxed),
|
|
"positions_liquidated": self.positions_liquidated.load(Ordering::Relaxed),
|
|
"limit_breaches_detected": self.limit_breaches_detected.load(Ordering::Relaxed),
|
|
"emergency_stops_triggered": self.emergency_stops_triggered.load(Ordering::Relaxed)
|
|
})
|
|
}
|
|
}
|
|
|
|
/// Test account for risk enforcement testing
|
|
#[derive(Debug, Clone)]
|
|
pub struct TestAccount {
|
|
pub account_id: String,
|
|
pub balance: Decimal,
|
|
pub available_balance: Decimal,
|
|
pub position_limits: HashMap<String, Decimal>, // Symbol -> Max position size
|
|
pub exposure_limit: Decimal,
|
|
pub daily_loss_limit: Decimal,
|
|
pub max_drawdown_pct: Decimal,
|
|
pub leverage_limit: Decimal,
|
|
pub positions: HashMap<String, Position>,
|
|
pub daily_pnl: Decimal,
|
|
pub max_daily_drawdown: Decimal,
|
|
}
|
|
|
|
impl TestAccount {
|
|
pub fn new(account_id: &str, balance: Decimal) -> Self {
|
|
Self {
|
|
account_id: account_id.to_string(),
|
|
balance,
|
|
available_balance: balance,
|
|
position_limits: HashMap::new(),
|
|
exposure_limit: balance * Decimal::new(5, 0), // 5x leverage limit
|
|
daily_loss_limit: balance * Decimal::new(10, 2), // 10% daily loss limit
|
|
max_drawdown_pct: Decimal::new(20, 2), // 20% max drawdown
|
|
leverage_limit: Decimal::new(10, 0), // 10x max leverage
|
|
positions: HashMap::new(),
|
|
daily_pnl: Decimal::ZERO,
|
|
max_daily_drawdown: Decimal::ZERO,
|
|
}
|
|
}
|
|
|
|
pub fn set_position_limit(&mut self, symbol: &str, limit: Decimal) {
|
|
self.position_limits.insert(symbol.to_string(), limit);
|
|
}
|
|
|
|
pub fn get_position_limit(&self, symbol: &str) -> Option<Decimal> {
|
|
self.position_limits.get(symbol).copied()
|
|
}
|
|
|
|
pub fn get_current_exposure(&self) -> Decimal {
|
|
self.positions.values()
|
|
.map(|pos| pos.quantity.abs() * pos.average_price)
|
|
.sum()
|
|
}
|
|
|
|
pub fn update_position(&mut self, symbol: &str, quantity: Decimal, price: Decimal) {
|
|
let position = self.positions.entry(symbol.to_string()).or_insert_with(|| {
|
|
Position {
|
|
symbol: symbol.to_string(),
|
|
quantity: Decimal::ZERO,
|
|
average_price: Decimal::ZERO,
|
|
unrealized_pnl: Decimal::ZERO,
|
|
realized_pnl: Decimal::ZERO,
|
|
}
|
|
});
|
|
|
|
// Update position quantity and average price
|
|
if position.quantity.is_zero() {
|
|
position.quantity = quantity;
|
|
position.average_price = price;
|
|
} else if position.quantity.is_sign_positive() == quantity.is_sign_positive() {
|
|
// Adding to position
|
|
let total_cost = position.quantity * position.average_price + quantity * price;
|
|
position.quantity += quantity;
|
|
if !position.quantity.is_zero() {
|
|
position.average_price = total_cost / position.quantity;
|
|
}
|
|
} else {
|
|
// Reducing or reversing position
|
|
let reduction = quantity.abs().min(position.quantity.abs());
|
|
let realized = reduction * (price - position.average_price) *
|
|
if position.quantity.is_sign_positive() { Decimal::ONE } else { -Decimal::ONE };
|
|
|
|
position.realized_pnl += realized;
|
|
position.quantity += quantity;
|
|
|
|
if position.quantity.is_zero() {
|
|
position.average_price = Decimal::ZERO;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Position information
|
|
#[derive(Debug, Clone)]
|
|
pub struct Position {
|
|
pub symbol: String,
|
|
pub quantity: Decimal,
|
|
pub average_price: Decimal,
|
|
pub unrealized_pnl: Decimal,
|
|
pub realized_pnl: Decimal,
|
|
}
|
|
|
|
impl RiskEnforcementTests {
|
|
/// Create new risk enforcement tests instance
|
|
pub async fn new(config: IntegrationTestConfig) -> TliResult<Self> {
|
|
let test_env = TestEnvironment::new(config.clone()).await?;
|
|
|
|
// Initialize mock services
|
|
let mock_trading_service = MockTradingService::new().await?;
|
|
let mock_risk_service = MockRiskService::new().await?;
|
|
let test_db = TestDatabaseManager::new(&config.test_db_url).await?;
|
|
|
|
// Initialize risk management components
|
|
let risk_config = RiskManagerConfig {
|
|
max_position_check_latency_ns: config.max_risk_latency_ns,
|
|
enable_real_time_monitoring: true,
|
|
position_limit_buffer_pct: Decimal::new(5, 2), // 5% buffer
|
|
exposure_limit_buffer_pct: Decimal::new(10, 2), // 10% buffer
|
|
emergency_liquidation_threshold_pct: Decimal::new(95, 2), // 95% of limit
|
|
};
|
|
|
|
let risk_manager = Arc::new(RiskManager::new(risk_config).await?);
|
|
let position_tracker = Arc::new(PositionTracker::new().await?);
|
|
|
|
// Create TLI client suite
|
|
let client_suite = TliClientBuilder::new()
|
|
.with_service_endpoint(
|
|
"trading_service".to_string(),
|
|
format!("http://localhost:{}", mock_trading_service.port())
|
|
)
|
|
.with_service_endpoint(
|
|
"risk_service".to_string(),
|
|
format!("http://localhost:{}", mock_risk_service.port())
|
|
)
|
|
.with_trading_config(TradingClientConfig::default())
|
|
.build()
|
|
.await?;
|
|
|
|
Ok(Self {
|
|
client_suite,
|
|
mock_trading_service,
|
|
mock_risk_service,
|
|
test_db,
|
|
risk_manager,
|
|
position_tracker,
|
|
metrics: Arc::new(RiskMetrics::new()),
|
|
config,
|
|
test_accounts: Arc::new(RwLock::new(HashMap::new())),
|
|
})
|
|
}
|
|
|
|
/// Test position limit enforcement
|
|
pub async fn test_position_limit_enforcement(&mut self) -> TliResult<TestResult> {
|
|
let mut test_result = TestResult::new("position_limit_enforcement");
|
|
let start_time = Instant::now();
|
|
|
|
println!("🔄 Testing position limit enforcement...");
|
|
|
|
// Setup test account with position limits
|
|
let account_id = "POSITION_LIMIT_TEST";
|
|
let mut test_account = TestAccount::new(account_id, Decimal::new(100000, 0)); // $100,000
|
|
test_account.set_position_limit("AAPL", Decimal::new(1000, 0)); // 1,000 shares max
|
|
test_account.set_position_limit("GOOGL", Decimal::new(100, 0)); // 100 shares max
|
|
|
|
// Register account with risk manager
|
|
self.risk_manager.register_account(account_id, &test_account).await?;
|
|
self.test_accounts.write().await.insert(account_id.to_string(), test_account);
|
|
|
|
// Test 1: Order within position limit should be accepted
|
|
let within_limit_start = Instant::now();
|
|
let within_limit_order = SubmitOrderRequest {
|
|
symbol: "AAPL".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 500.0, // Within 1,000 limit
|
|
client_order_id: "within_limit_order".to_string(),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let within_limit_result = if let Some(trading_client) = &self.client_suite.trading_client {
|
|
trading_client.submit_order(within_limit_order).await
|
|
} else {
|
|
return Err(TliError::Other("Trading client not available".to_string()));
|
|
};
|
|
|
|
let risk_check_latency = within_limit_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_risk_check(risk_check_latency);
|
|
|
|
test_result.add_assertion(
|
|
"Order within position limit accepted",
|
|
within_limit_result.is_ok()
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
&format!("Risk check latency < {}µs (got {}ns)",
|
|
self.config.max_risk_latency_ns / 1000, risk_check_latency),
|
|
risk_check_latency < self.config.max_risk_latency_ns
|
|
);
|
|
|
|
// Update position after successful order
|
|
if within_limit_result.is_ok() {
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
account.update_position("AAPL", Decimal::new(500, 0), Decimal::new(150, 0));
|
|
}
|
|
}
|
|
|
|
// Test 2: Order that would exceed position limit should be rejected
|
|
let exceed_limit_start = Instant::now();
|
|
let exceed_limit_order = SubmitOrderRequest {
|
|
symbol: "AAPL".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 600.0, // Would exceed 1,000 limit (500 existing + 600 = 1,100)
|
|
client_order_id: "exceed_limit_order".to_string(),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let exceed_limit_result = if let Some(trading_client) = &self.client_suite.trading_client {
|
|
trading_client.submit_order(exceed_limit_order).await
|
|
} else {
|
|
return Err(TliError::Other("Trading client not available".to_string()));
|
|
};
|
|
|
|
let rejection_latency = exceed_limit_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_risk_check(rejection_latency);
|
|
|
|
let order_rejected = exceed_limit_result.is_err();
|
|
if order_rejected {
|
|
self.metrics.record_order_rejection();
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
"Order exceeding position limit rejected",
|
|
order_rejected
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
&format!("Risk rejection latency < {}µs (got {}ns)",
|
|
self.config.max_risk_latency_ns / 1000, rejection_latency),
|
|
rejection_latency < self.config.max_risk_latency_ns
|
|
);
|
|
|
|
// Test 3: Order that exactly reaches limit should be accepted
|
|
let exact_limit_order = SubmitOrderRequest {
|
|
symbol: "AAPL".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 500.0, // Exactly reaches 1,000 limit
|
|
client_order_id: "exact_limit_order".to_string(),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let exact_limit_result = if let Some(trading_client) = &self.client_suite.trading_client {
|
|
trading_client.submit_order(exact_limit_order).await
|
|
} else {
|
|
return Err(TliError::Other("Trading client not available".to_string()));
|
|
};
|
|
|
|
test_result.add_assertion(
|
|
"Order exactly at position limit accepted",
|
|
exact_limit_result.is_ok()
|
|
);
|
|
|
|
test_result.set_passed(test_result.assertions.iter().all(|a| a.passed));
|
|
test_result.execution_time = start_time.elapsed();
|
|
|
|
// Store position limit test metadata
|
|
test_result.metadata.insert("within_limit_latency_ns".to_string(), json!(risk_check_latency));
|
|
test_result.metadata.insert("rejection_latency_ns".to_string(), json!(rejection_latency));
|
|
test_result.metadata.insert("orders_rejected".to_string(), json!(self.metrics.orders_rejected.load(Ordering::Relaxed)));
|
|
|
|
println!("✅ Position limit enforcement test completed");
|
|
|
|
Ok(test_result)
|
|
}
|
|
|
|
/// Test exposure limit enforcement
|
|
pub async fn test_exposure_limit_enforcement(&mut self) -> TliResult<TestResult> {
|
|
let mut test_result = TestResult::new("exposure_limit_enforcement");
|
|
let start_time = Instant::now();
|
|
|
|
println!("🔄 Testing exposure limit enforcement...");
|
|
|
|
// Setup test account with exposure limits
|
|
let account_id = "EXPOSURE_LIMIT_TEST";
|
|
let balance = Decimal::new(50000, 0); // $50,000
|
|
let mut test_account = TestAccount::new(account_id, balance);
|
|
test_account.exposure_limit = balance * Decimal::new(3, 0); // 3x leverage = $150,000 max exposure
|
|
|
|
self.risk_manager.register_account(account_id, &test_account).await?;
|
|
self.test_accounts.write().await.insert(account_id.to_string(), test_account);
|
|
|
|
// Test 1: Build position within exposure limit
|
|
let symbols_and_prices = vec![
|
|
("AAPL", 150.0, 300.0), // $45,000 exposure
|
|
("GOOGL", 2500.0, 20.0), // $50,000 exposure
|
|
];
|
|
|
|
let mut total_exposure = Decimal::ZERO;
|
|
for (symbol, price, quantity) in &symbols_and_prices {
|
|
let exposure_start = Instant::now();
|
|
let order = SubmitOrderRequest {
|
|
symbol: symbol.to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: *quantity,
|
|
price: Some(*price),
|
|
client_order_id: format!("exposure_order_{}", symbol),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(trading_client) = &self.client_suite.trading_client {
|
|
let result = trading_client.submit_order(order).await;
|
|
|
|
let exposure_check_latency = exposure_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_risk_check(exposure_check_latency);
|
|
|
|
if result.is_ok() {
|
|
total_exposure += Decimal::new(*quantity as i64, 0) * Decimal::new((*price * 100.0) as i64, 2);
|
|
|
|
// Update account position
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
account.update_position(symbol, Decimal::new(*quantity as i64, 0), Decimal::new((*price * 100.0) as i64, 2));
|
|
}
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
&format!("Order for {} within exposure limit accepted", symbol),
|
|
result.is_ok()
|
|
);
|
|
}
|
|
}
|
|
|
|
// Test 2: Order that would exceed exposure limit should be rejected
|
|
let exceed_exposure_start = Instant::now();
|
|
let exceed_order = SubmitOrderRequest {
|
|
symbol: "TSLA".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 300.0, // At $200/share = $60,000, would exceed remaining limit
|
|
price: Some(200.0),
|
|
client_order_id: "exceed_exposure_order".to_string(),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let exceed_result = if let Some(trading_client) = &self.client_suite.trading_client {
|
|
trading_client.submit_order(exceed_order).await
|
|
} else {
|
|
return Err(TliError::Other("Trading client not available".to_string()));
|
|
};
|
|
|
|
let exposure_rejection_latency = exceed_exposure_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_risk_check(exposure_rejection_latency);
|
|
|
|
let exposure_order_rejected = exceed_result.is_err();
|
|
if exposure_order_rejected {
|
|
self.metrics.record_order_rejection();
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
"Order exceeding exposure limit rejected",
|
|
exposure_order_rejected
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
&format!("Exposure check latency < {}µs (got {}ns)",
|
|
self.config.max_risk_latency_ns / 1000, exposure_rejection_latency),
|
|
exposure_rejection_latency < self.config.max_risk_latency_ns
|
|
);
|
|
|
|
// Test 3: Real-time exposure monitoring
|
|
let monitoring_start = Instant::now();
|
|
|
|
// Simulate price movements that increase exposure
|
|
let price_updates = vec![
|
|
("AAPL", 160.0), // +6.67% increase
|
|
("GOOGL", 2700.0), // +8% increase
|
|
];
|
|
|
|
for (symbol, new_price) in price_updates {
|
|
// Update position with new market price
|
|
let position_update_start = Instant::now();
|
|
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
if let Some(position) = account.positions.get_mut(symbol) {
|
|
let old_value = position.quantity * position.average_price;
|
|
let new_value = position.quantity * Decimal::new((new_price * 100.0) as i64, 2);
|
|
position.unrealized_pnl = new_value - old_value;
|
|
}
|
|
}
|
|
|
|
let position_update_latency = position_update_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_position_update(position_update_latency);
|
|
|
|
// Check if exposure limit is breached
|
|
let current_exposure = {
|
|
let accounts = self.test_accounts.read().await;
|
|
accounts.get(account_id).map(|acc| acc.get_current_exposure()).unwrap_or(Decimal::ZERO)
|
|
};
|
|
|
|
let exposure_limit = balance * Decimal::new(3, 0);
|
|
if current_exposure > exposure_limit {
|
|
let breach_latency = monitoring_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_limit_breach(breach_latency);
|
|
}
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
"Real-time exposure monitoring active",
|
|
self.metrics.risk_checks_performed.load(Ordering::Relaxed) > 0
|
|
);
|
|
|
|
test_result.set_passed(test_result.assertions.iter().all(|a| a.passed));
|
|
test_result.execution_time = start_time.elapsed();
|
|
|
|
println!("✅ Exposure limit enforcement test completed");
|
|
|
|
Ok(test_result)
|
|
}
|
|
|
|
/// Test drawdown protection mechanisms
|
|
pub async fn test_drawdown_protection(&mut self) -> TliResult<TestResult> {
|
|
let mut test_result = TestResult::new("drawdown_protection");
|
|
let start_time = Instant::now();
|
|
|
|
println!("🔄 Testing drawdown protection mechanisms...");
|
|
|
|
// Setup test account with drawdown limits
|
|
let account_id = "DRAWDOWN_TEST";
|
|
let initial_balance = Decimal::new(100000, 0); // $100,000
|
|
let mut test_account = TestAccount::new(account_id, initial_balance);
|
|
test_account.daily_loss_limit = initial_balance * Decimal::new(5, 2); // 5% daily loss limit
|
|
test_account.max_drawdown_pct = Decimal::new(10, 2); // 10% max drawdown
|
|
|
|
self.risk_manager.register_account(account_id, &test_account).await?;
|
|
self.test_accounts.write().await.insert(account_id.to_string(), test_account);
|
|
|
|
// Build initial profitable position
|
|
let initial_order = SubmitOrderRequest {
|
|
symbol: "PROFIT_STOCK".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 1000.0,
|
|
price: Some(100.0),
|
|
client_order_id: "initial_position".to_string(),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(trading_client) = &self.client_suite.trading_client {
|
|
let _ = trading_client.submit_order(initial_order).await;
|
|
}
|
|
|
|
// Update position to be profitable initially
|
|
{
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
account.update_position("PROFIT_STOCK", Decimal::new(1000, 0), Decimal::new(10000, 2));
|
|
account.daily_pnl = Decimal::new(5000, 0); // $5,000 profit
|
|
}
|
|
}
|
|
|
|
// Simulate adverse price movements causing losses
|
|
let loss_scenarios = vec![
|
|
("PROFIT_STOCK", 95.0, "2% loss"), // Position value drops to $95,000
|
|
("PROFIT_STOCK", 90.0, "5% loss"), // Position value drops to $90,000
|
|
("PROFIT_STOCK", 85.0, "8% loss"), // Position value drops to $85,000
|
|
];
|
|
|
|
for (symbol, new_price, scenario) in loss_scenarios {
|
|
let drawdown_check_start = Instant::now();
|
|
|
|
// Update position with loss
|
|
let mut current_pnl = Decimal::ZERO;
|
|
{
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
if let Some(position) = account.positions.get_mut(symbol) {
|
|
let new_value = position.quantity * Decimal::new((new_price * 100.0) as i64, 2);
|
|
let cost_basis = position.quantity * position.average_price;
|
|
position.unrealized_pnl = new_value - cost_basis;
|
|
current_pnl = position.unrealized_pnl;
|
|
|
|
// Update daily PnL
|
|
account.daily_pnl = position.unrealized_pnl;
|
|
|
|
// Track maximum drawdown
|
|
if account.daily_pnl < account.max_daily_drawdown {
|
|
account.max_daily_drawdown = account.daily_pnl;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if drawdown limits are breached
|
|
let daily_loss_pct = current_pnl.abs() / initial_balance * Decimal::new(100, 0);
|
|
let max_drawdown_pct = {
|
|
let accounts = self.test_accounts.read().await;
|
|
accounts.get(account_id)
|
|
.map(|acc| acc.max_daily_drawdown.abs() / initial_balance * Decimal::new(100, 0))
|
|
.unwrap_or(Decimal::ZERO)
|
|
};
|
|
|
|
let drawdown_check_latency = drawdown_check_start.elapsed().as_nanos() as u64;
|
|
|
|
// Test if new orders are blocked when approaching limits
|
|
if daily_loss_pct > Decimal::new(4, 2) { // Above 4% loss
|
|
let risk_order = SubmitOrderRequest {
|
|
symbol: "RISKY_STOCK".to_string(),
|
|
side: OrderSide::Buy as i32,
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 100.0,
|
|
price: Some(50.0),
|
|
client_order_id: format!("risk_order_{}", scenario.replace(" ", "_")),
|
|
account_id: Some(account_id.to_string()),
|
|
..Default::default()
|
|
};
|
|
|
|
let risk_order_result = if let Some(trading_client) = &self.client_suite.trading_client {
|
|
trading_client.submit_order(risk_order).await
|
|
} else {
|
|
return Err(TliError::Other("Trading client not available".to_string()));
|
|
};
|
|
|
|
let order_blocked = risk_order_result.is_err();
|
|
if order_blocked {
|
|
self.metrics.record_order_rejection();
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
&format!("New order blocked during {} scenario", scenario),
|
|
order_blocked
|
|
);
|
|
}
|
|
|
|
// Record breach detection if limits exceeded
|
|
if daily_loss_pct > Decimal::new(5, 2) || max_drawdown_pct > Decimal::new(10, 2) {
|
|
self.metrics.record_limit_breach(drawdown_check_latency);
|
|
|
|
test_result.add_assertion(
|
|
&format!("Drawdown limit breach detected for {}", scenario),
|
|
true
|
|
);
|
|
}
|
|
|
|
self.metrics.record_risk_check(drawdown_check_latency);
|
|
|
|
println!("📊 {} scenario: Daily PnL = {:.2}%, Max Drawdown = {:.2}%",
|
|
scenario, daily_loss_pct, max_drawdown_pct);
|
|
}
|
|
|
|
// Test emergency liquidation trigger
|
|
let emergency_start = Instant::now();
|
|
|
|
// Simulate severe loss that triggers emergency liquidation
|
|
{
|
|
let mut accounts = self.test_accounts.write().await;
|
|
if let Some(account) = accounts.get_mut(account_id) {
|
|
account.daily_pnl = initial_balance * Decimal::new(-12, 2); // -12% loss (exceeds 10% limit)
|
|
account.max_daily_drawdown = account.daily_pnl;
|
|
}
|
|
}
|
|
|
|
// Check if emergency stop is triggered
|
|
let emergency_triggered = self.risk_manager.check_emergency_conditions(account_id).await?;
|
|
if emergency_triggered {
|
|
self.metrics.record_emergency_stop();
|
|
|
|
let emergency_latency = emergency_start.elapsed().as_nanos() as u64;
|
|
self.metrics.record_limit_breach(emergency_latency);
|
|
}
|
|
|
|
test_result.add_assertion(
|
|
"Emergency liquidation triggered for severe drawdown",
|
|
emergency_triggered
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
"Drawdown monitoring latency acceptable",
|
|
self.metrics.limit_breach_detection_latency.load(Ordering::Relaxed) < self.config.max_risk_latency_ns * 2
|
|
);
|
|
|
|
test_result.set_passed(test_result.assertions.iter().all(|a| a.passed));
|
|
test_result.execution_time = start_time.elapsed();
|
|
|
|
println!("✅ Drawdown protection test completed");
|
|
|
|
Ok(test_result)
|
|
}
|
|
|
|
/// Test real-time risk monitoring performance
|
|
pub async fn test_real_time_risk_monitoring(&mut self) -> TliResult<TestResult> {
|
|
let mut test_result = TestResult::new("real_time_risk_monitoring");
|
|
let start_time = Instant::now();
|
|
|
|
println!("🔄 Testing real-time risk monitoring performance...");
|
|
|
|
// Setup multiple test accounts for stress testing
|
|
let account_count = 10;
|
|
let orders_per_account = 50;
|
|
|
|
for i in 0..account_count {
|
|
let account_id = format!("MONITOR_TEST_{}", i);
|
|
let test_account = TestAccount::new(&account_id, Decimal::new(50000, 0));
|
|
|
|
self.risk_manager.register_account(&account_id, &test_account).await?;
|
|
self.test_accounts.write().await.insert(account_id, test_account);
|
|
}
|
|
|
|
// Generate concurrent order flow to stress test risk monitoring
|
|
let mut order_tasks = Vec::new();
|
|
let monitoring_start = Instant::now();
|
|
|
|
for account_idx in 0..account_count {
|
|
let account_id = format!("MONITOR_TEST_{}", account_idx);
|
|
let client_suite = self.client_suite.clone();
|
|
let metrics = Arc::clone(&self.metrics);
|
|
|
|
let task = tokio::spawn(async move {
|
|
let mut successful_orders = 0;
|
|
let mut rejected_orders = 0;
|
|
|
|
for order_idx in 0..orders_per_account {
|
|
let order_start = Instant::now();
|
|
|
|
let order = SubmitOrderRequest {
|
|
symbol: format!("STOCK_{}", order_idx % 5),
|
|
side: if order_idx % 2 == 0 { OrderSide::Buy as i32 } else { OrderSide::Sell as i32 },
|
|
order_type: OrderType::Market as i32,
|
|
quantity: 10.0 + (order_idx as f64),
|
|
price: Some(100.0 + (order_idx as f64 * 0.1)),
|
|
client_order_id: format!("monitor_order_{}_{}", account_idx, order_idx),
|
|
account_id: Some(account_id.clone()),
|
|
..Default::default()
|
|
};
|
|
|
|
if let Some(trading_client) = &client_suite.trading_client {
|
|
match trading_client.submit_order(order).await {
|
|
Ok(_) => successful_orders += 1,
|
|
Err(_) => rejected_orders += 1,
|
|
}
|
|
}
|
|
|
|
let order_latency = order_start.elapsed().as_nanos() as u64;
|
|
metrics.record_risk_check(order_latency);
|
|
|
|
// Small delay to simulate realistic order flow
|
|
tokio::time::sleep(Duration::from_millis(1)).await;
|
|
}
|
|
|
|
(successful_orders, rejected_orders)
|
|
});
|
|
|
|
order_tasks.push(task);
|
|
}
|
|
|
|
// Wait for all order tasks to complete
|
|
let task_results: Vec<_> = futures::future::join_all(order_tasks).await;
|
|
let monitoring_duration = monitoring_start.elapsed();
|
|
|
|
// Collect results
|
|
let mut total_successful = 0;
|
|
let mut total_rejected = 0;
|
|
let mut task_errors = 0;
|
|
|
|
for result in task_results {
|
|
match result {
|
|
Ok((successful, rejected)) => {
|
|
total_successful += successful;
|
|
total_rejected += rejected;
|
|
}
|
|
Err(_) => task_errors += 1,
|
|
}
|
|
}
|
|
|
|
let total_orders = account_count * orders_per_account;
|
|
let total_processed = total_successful + total_rejected;
|
|
let processing_rate = total_processed as f64 / monitoring_duration.as_secs_f64();
|
|
|
|
// Performance assertions
|
|
test_result.add_assertion(
|
|
&format!("All {} orders processed", total_orders),
|
|
total_processed == total_orders && task_errors == 0
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
&format!("Processing rate > {} orders/sec (got {:.0})",
|
|
self.config.min_throughput_ops_per_sec, processing_rate),
|
|
processing_rate > self.config.min_throughput_ops_per_sec
|
|
);
|
|
|
|
let avg_risk_check_latency = self.metrics.risk_check_latency.load(Ordering::Relaxed);
|
|
test_result.add_assertion(
|
|
&format!("Average risk check latency < {}µs (got {}ns)",
|
|
self.config.max_risk_latency_ns / 1000, avg_risk_check_latency),
|
|
avg_risk_check_latency < self.config.max_risk_latency_ns
|
|
);
|
|
|
|
test_result.add_assertion(
|
|
"Risk monitoring system stable under load",
|
|
task_errors == 0
|
|
);
|
|
|
|
test_result.set_passed(test_result.assertions.iter().all(|a| a.passed));
|
|
test_result.execution_time = start_time.elapsed();
|
|
|
|
// Store performance metadata
|
|
test_result.metadata.insert("total_orders".to_string(), json!(total_orders));
|
|
test_result.metadata.insert("successful_orders".to_string(), json!(total_successful));
|
|
test_result.metadata.insert("rejected_orders".to_string(), json!(total_rejected));
|
|
test_result.metadata.insert("processing_rate_ops_per_sec".to_string(), json!(processing_rate));
|
|
test_result.metadata.insert("avg_latency_ns".to_string(), json!(avg_risk_check_latency));
|
|
|
|
println!("✅ Real-time risk monitoring test completed: {:.0} orders/sec", processing_rate);
|
|
|
|
Ok(test_result)
|
|
}
|
|
|
|
/// Run all risk enforcement integration tests
|
|
pub async fn run_all_tests(&mut self) -> TliResult<TestSuite> {
|
|
let mut test_suite = TestSuite::new("risk_enforcement_integration");
|
|
println!("🚀 Starting risk limit enforcement integration tests...");
|
|
|
|
// Run individual test methods
|
|
let tests = vec![
|
|
self.test_position_limit_enforcement().await,
|
|
self.test_exposure_limit_enforcement().await,
|
|
self.test_drawdown_protection().await,
|
|
self.test_real_time_risk_monitoring().await,
|
|
];
|
|
|
|
// Collect results
|
|
for test_result in tests {
|
|
match test_result {
|
|
Ok(result) => {
|
|
test_suite.add_test_result(result);
|
|
}
|
|
Err(e) => {
|
|
let mut error_result = TestResult::new("risk_enforcement_test_error");
|
|
error_result.add_error(format!("Test execution failed: {}", e));
|
|
test_suite.add_test_result(error_result);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Calculate overall success
|
|
test_suite.set_passed(test_suite.passed_tests == test_suite.total_tests);
|
|
|
|
// Add risk metrics to test suite metadata
|
|
let metrics_summary = self.metrics.get_summary();
|
|
test_suite.metadata.insert("risk_metrics".to_string(), metrics_summary);
|
|
|
|
println!("🏁 Risk limit enforcement integration tests completed: {}/{} passed",
|
|
test_suite.passed_tests, test_suite.total_tests);
|
|
|
|
Ok(test_suite)
|
|
}
|
|
}
|
|
|
|
/// Test result structure
|
|
#[derive(Debug, Clone)]
|
|
pub struct TestResult {
|
|
pub name: String,
|
|
pub passed: bool,
|
|
pub execution_time: Duration,
|
|
pub assertions: Vec<Assertion>,
|
|
pub errors: Vec<String>,
|
|
pub metadata: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
impl TestResult {
|
|
pub fn new(name: &str) -> Self {
|
|
Self {
|
|
name: name.to_string(),
|
|
passed: false,
|
|
execution_time: Duration::default(),
|
|
assertions: Vec::new(),
|
|
errors: Vec::new(),
|
|
metadata: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn add_assertion(&mut self, description: &str, passed: bool) {
|
|
self.assertions.push(Assertion {
|
|
description: description.to_string(),
|
|
passed,
|
|
});
|
|
}
|
|
|
|
pub fn add_error(&mut self, error: String) {
|
|
self.errors.push(error);
|
|
}
|
|
|
|
pub fn set_passed(&mut self, passed: bool) {
|
|
self.passed = passed;
|
|
}
|
|
}
|
|
|
|
/// Individual test assertion
|
|
#[derive(Debug, Clone)]
|
|
pub struct Assertion {
|
|
pub description: String,
|
|
pub passed: bool,
|
|
}
|
|
|
|
/// Test suite containing multiple test results
|
|
#[derive(Debug, Clone)]
|
|
pub struct TestSuite {
|
|
pub name: String,
|
|
pub tests: Vec<TestResult>,
|
|
pub passed_tests: usize,
|
|
pub total_tests: usize,
|
|
pub passed: bool,
|
|
pub execution_time: Duration,
|
|
pub metadata: HashMap<String, serde_json::Value>,
|
|
}
|
|
|
|
impl TestSuite {
|
|
pub fn new(name: &str) -> Self {
|
|
Self {
|
|
name: name.to_string(),
|
|
tests: Vec::new(),
|
|
passed_tests: 0,
|
|
total_tests: 0,
|
|
passed: false,
|
|
execution_time: Duration::default(),
|
|
metadata: HashMap::new(),
|
|
}
|
|
}
|
|
|
|
pub fn add_test_result(&mut self, test: TestResult) {
|
|
if test.passed {
|
|
self.passed_tests += 1;
|
|
}
|
|
self.total_tests += 1;
|
|
self.tests.push(test);
|
|
}
|
|
|
|
pub fn set_passed(&mut self, passed: bool) {
|
|
self.passed = passed;
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_risk_metrics() {
|
|
let metrics = RiskMetrics::new();
|
|
|
|
metrics.record_risk_check(25_000); // 25µs
|
|
metrics.record_order_rejection();
|
|
metrics.record_limit_breach(50_000); // 50µs
|
|
|
|
let summary = metrics.get_summary();
|
|
assert_eq!(summary["risk_checks_performed"].as_u64().unwrap(), 1);
|
|
assert_eq!(summary["orders_rejected"].as_u64().unwrap(), 1);
|
|
assert_eq!(summary["limit_breaches_detected"].as_u64().unwrap(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_account_position_limits() {
|
|
let mut account = TestAccount::new("TEST", Decimal::new(10000, 0));
|
|
account.set_position_limit("AAPL", Decimal::new(1000, 0));
|
|
|
|
assert_eq!(account.get_position_limit("AAPL"), Some(Decimal::new(1000, 0)));
|
|
assert_eq!(account.get_position_limit("GOOGL"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_position_updates() {
|
|
let mut account = TestAccount::new("TEST", Decimal::new(10000, 0));
|
|
|
|
// Initial position
|
|
account.update_position("AAPL", Decimal::new(100, 0), Decimal::new(15000, 2));
|
|
assert_eq!(account.positions["AAPL"].quantity, Decimal::new(100, 0));
|
|
assert_eq!(account.positions["AAPL"].average_price, Decimal::new(15000, 2));
|
|
|
|
// Add to position
|
|
account.update_position("AAPL", Decimal::new(50, 0), Decimal::new(16000, 2));
|
|
assert_eq!(account.positions["AAPL"].quantity, Decimal::new(150, 0));
|
|
}
|
|
} |