Files
foxhunt/crates/ml/tests/risk_position_limit_integration_test.rs
jgrusewski 448b61d095 refactor: collapse 9-level to 7-level ExposureLevel — eliminate degenerate Flat variants
The 4-branch DQN (direction x magnitude) had 3 degenerate variants
(Short25, Flat, Long25) that all mapped to 0.0 target exposure when
direction=Flat, causing 82% Flat collapse. Collapse these into a
single Flat variant, giving 7 levels (ShortSmall/Half/Full, Flat,
LongSmall/Half/Full) and 63 total factored actions (7x3x3).

- ExposureLevel enum: 9 variants -> 7 (add direction/magnitude/from_dir_mag)
- FactoredAction: 81 -> 63 total actions, from_index/to_index updated
- DQN epsilon-greedy: use from_dir_mag() instead of dir*3+mag indexing
- DQN config: num_actions default 9 -> 7
- PPO action space: 45 -> 63 actions, action masking updated
- Signal adapter CUDA kernel: 5-bin -> 7-bin exposure aggregation
- All tests updated for new variant names and index ranges

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:54:09 +02:00

692 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(
clippy::assertions_on_constants,
clippy::assertions_on_result_states,
clippy::clone_on_copy,
clippy::decimal_literal_representation,
clippy::doc_markdown,
clippy::empty_line_after_doc_comments,
clippy::field_reassign_with_default,
clippy::get_unwrap,
clippy::identity_op,
clippy::inconsistent_digit_grouping,
clippy::indexing_slicing,
clippy::integer_division,
clippy::len_zero,
clippy::let_underscore_must_use,
clippy::manual_div_ceil,
clippy::manual_let_else,
clippy::manual_range_contains,
clippy::modulo_arithmetic,
clippy::needless_range_loop,
clippy::non_ascii_literal,
clippy::redundant_clone,
clippy::shadow_reuse,
clippy::shadow_same,
clippy::shadow_unrelated,
clippy::single_match_else,
clippy::str_to_string,
clippy::string_slice,
clippy::tests_outside_test_module,
clippy::too_many_lines,
clippy::unnecessary_wraps,
clippy::unseparated_literal_suffix,
clippy::use_debug,
clippy::useless_vec,
clippy::wildcard_enum_match_arm,
clippy::else_if_without_else,
clippy::expect_used,
clippy::missing_const_for_fn,
clippy::similar_names,
clippy::type_complexity,
clippy::collapsible_else_if,
clippy::doc_lazy_continuation,
clippy::items_after_test_module,
clippy::map_clone,
clippy::multiple_unsafe_ops_per_block,
clippy::unwrap_or_default,
clippy::assign_op_pattern,
clippy::needless_borrow,
clippy::println_empty_string,
clippy::unnecessary_cast,
clippy::used_underscore_binding,
clippy::create_dir,
clippy::implicit_saturating_sub,
clippy::exit,
clippy::expect_fun_call,
clippy::too_many_arguments,
clippy::unnecessary_map_or,
clippy::unwrap_used,
dead_code,
unused_imports,
unused_variables,
clippy::cloned_ref_to_slice_refs,
clippy::neg_multiply,
clippy::while_let_loop,
clippy::bool_assert_comparison,
clippy::excessive_precision,
clippy::trivially_copy_pass_by_ref,
clippy::op_ref,
clippy::redundant_closure,
clippy::unnecessary_lazy_evaluations,
clippy::if_then_some_else_none,
clippy::unnecessary_to_owned,
clippy::single_component_path_imports,
)]
//! TDD Tests: PositionLimiter Integration with DQN
//!
//! Comprehensive test suite for hybrid position limiting with local cache + RPC fallback.
//! Tests FIRST (TDD approach), implementation by Agent 27.
//!
//! # Architecture Overview
//! The PositionLimiter uses a hybrid approach:
//! - **Fast Path**: Local cache (DashMap) - responds in <10μs for cache hits
//! - **Slow Path**: RPC fallback - authoritative position checks via gRPC
//! - **TTL**: Cache entries expire after configured duration
//! - **Masking**: Invalid actions filtered before execution
//!
//! # Test Coverage
//! 1. Initialization and configuration
//! 2. Allowed position increases (within limits)
//! 3. Rejected positions (exceeding max_position)
//! 4. Notional value checks (market value of position)
//! 5. Concentration limits (% of portfolio)
//! 6. Position decreases (always allowed)
//! 7. Dynamic limit adjustment (volatility-based)
//! 8. Cache hit performance (<10μs)
//! 9. RPC fallback on cache miss
//! 10. Action masking for invalid positions
//! 11. Error logging on rejection
//! 12. CLI configuration via --position-limit args
//!
//! # Success Criteria
//! - All 12+ tests fail initially (TDD)
//! - ~800-900 lines of test code
//! - Covers happy path, error cases, performance, integration
//! - Ready for Agent 27 implementation
#![allow(unused_crate_dependencies)]
use std::time::{Duration, Instant};
// ============================================================================
// MOCK TYPES & HELPERS - Placeholders for integration
// ============================================================================
/// Test configuration for PositionLimiter
#[derive(Debug, Clone)]
struct TestPositionLimiterConfig {
max_position: f64,
max_notional: f64,
max_concentration: f64,
cache_ttl: Duration,
rpc_check_threshold_percent: f64,
}
impl TestPositionLimiterConfig {
fn default_test() -> Self {
Self {
max_position: 10.0, // 10 contracts
max_notional: 500_000.0, // $500k notional
max_concentration: 0.10, // 10% of portfolio
cache_ttl: Duration::from_secs(60),
rpc_check_threshold_percent: 0.80,
}
}
}
/// Cached position with timestamp for TTL management
#[derive(Debug, Clone)]
struct CachedPosition {
quantity: f64,
timestamp: Instant,
}
impl CachedPosition {
fn is_expired(&self, ttl: Duration) -> bool {
self.timestamp.elapsed() > ttl
}
}
/// PositionLimiter mock - implementing core checking logic
struct MockPositionLimiter {
config: TestPositionLimiterConfig,
cache: std::collections::HashMap<String, CachedPosition>,
rpc_call_count: std::sync::atomic::AtomicUsize,
cache_hit_count: std::sync::atomic::AtomicUsize,
}
impl MockPositionLimiter {
fn new(config: TestPositionLimiterConfig) -> Self {
Self {
config,
cache: std::collections::HashMap::new(),
rpc_call_count: std::sync::atomic::AtomicUsize::new(0),
cache_hit_count: std::sync::atomic::AtomicUsize::new(0),
}
}
/// Check if position increase is allowed
fn check_position_increase(
&mut self,
_symbol: &str,
current_position: f64,
proposed_delta: f64,
current_price: f64,
portfolio_value: f64,
) -> Result<bool, String> {
// New position after this increase
let new_position = current_position + proposed_delta;
// Check 1: Absolute position limit
if new_position.abs() > self.config.max_position {
return Ok(false); // Rejected: exceeds max_position
}
// Check 2: Notional value (market value of position)
let notional = (new_position * current_price).abs();
if notional > self.config.max_notional {
return Ok(false); // Rejected: exceeds max_notional
}
// Check 3: Concentration limit (position value as % of portfolio)
let concentration = notional / portfolio_value;
if concentration > self.config.max_concentration {
return Ok(false); // Rejected: exceeds concentration limit
}
Ok(true) // Allowed
}
/// Update cached position
fn update_position(&mut self, symbol: &str, quantity: f64, _market_value: f64) {
let cache_key = symbol.to_string();
self.cache.insert(
cache_key,
CachedPosition {
quantity,
timestamp: Instant::now(),
},
);
}
/// Get cached position if not expired
fn get_cached_position(&mut self, symbol: &str) -> Option<f64> {
let cache_key = symbol.to_string();
if let Some(cached) = self.cache.get(&cache_key) {
if !cached.is_expired(self.config.cache_ttl) {
self.cache_hit_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
return Some(cached.quantity);
} else {
// Expired entry - would be removed in real impl
self.cache.remove(&cache_key);
}
}
None // Cache miss or expired
}
/// Get position (with RPC fallback)
fn get_position(&mut self, symbol: &str, force_rpc: bool) -> Result<f64, String> {
// Try cache first (unless forced RPC)
if !force_rpc {
if let Some(qty) = self.get_cached_position(symbol) {
return Ok(qty); // Cache hit
}
}
// Cache miss or forced RPC - call RPC
self.rpc_call_count
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
// In real implementation: gRPC call to authoritative service
// For testing: simulate RPC returning position from cache or default
Ok(0.0) // Placeholder: would return authoritative position
}
/// Check if position decrease is allowed
fn check_position_decrease(&self, proposed_delta: f64) -> bool {
// Position decreases are ALWAYS allowed (reducing risk)
proposed_delta < 0.0
}
/// Mask invalid actions based on current position
fn get_action_mask(&self, current_position: f64) -> Vec<bool> {
let mut mask = vec![true; 45]; // 45-action space (5 × 3 × 3)
// Mask actions that would exceed position limits
// Action space: (exposure * 9) + (order_type * 3) + urgency
// Exposure: 0=Short100, 1=Short50, 2=Flat, 3=Long50, 4=Long100
for action_idx in 0..63 {
let exposure_idx = action_idx / 9;
let exposure_level = match exposure_idx {
0 => -1.0, // Short100
1 => -0.5, // Short50
2 => 0.0, // Flat
3 => 0.5, // Long50
4 => 1.0, // Long100
_ => 0.0,
};
// Simple masking: action invalid if would exceed 2x current position
let proposed_position = current_position + exposure_level;
if proposed_position.abs() > self.config.max_position {
mask[action_idx] = false;
}
}
mask
}
fn get_rpc_call_count(&self) -> usize {
self.rpc_call_count.load(std::sync::atomic::Ordering::SeqCst)
}
fn get_cache_hit_count(&self) -> usize {
self.cache_hit_count.load(std::sync::atomic::Ordering::SeqCst)
}
}
// ============================================================================
// TEST 1: Initialization
// ============================================================================
#[test]
fn test_position_limiter_initialization() {
let config = TestPositionLimiterConfig::default_test();
let limiter = MockPositionLimiter::new(config.clone());
assert_eq!(limiter.config.max_position, 10.0);
assert_eq!(limiter.config.max_notional, 500_000.0);
assert_eq!(limiter.config.max_concentration, 0.10);
assert_eq!(limiter.get_rpc_call_count(), 0);
assert_eq!(limiter.get_cache_hit_count(), 0);
}
// ============================================================================
// TEST 2: Allow Small Position Increase
// ============================================================================
#[test]
fn test_check_position_increase_allowed() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Current position: 0, proposed increase: +1
// Price: $100, portfolio value: $1M
let result = limiter.check_position_increase(
"AAPL",
0.0, // current
1.0, // proposed delta
100.0, // price
1_000_000.0, // portfolio
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), true); // Should be allowed
}
// ============================================================================
// TEST 3: Reject Position Exceeding Max
// ============================================================================
#[test]
fn test_reject_position_exceeding_max() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Current position: 9, proposed increase: +3
// New position: 12 > max(10) → REJECT
let result = limiter.check_position_increase(
"AAPL",
9.0, // current
3.0, // proposed delta
100.0, // price
1_000_000.0, // portfolio
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // Should be rejected
}
// ============================================================================
// TEST 4: Reject Notional Exceeding Limit
// ============================================================================
#[test]
fn test_reject_notional_exceeding_limit() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Position: 5, price: $150,000
// Notional: 5 * $150,000 = $750,000 > max($500,000) → REJECT
let result = limiter.check_position_increase(
"ES",
0.0, // current
5.0, // proposed delta
150_000.0, // price (ES futures are expensive)
2_000_000.0, // portfolio
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // Should be rejected (notional exceeded)
}
// ============================================================================
// TEST 5: Reject Concentration Exceeding Limit
// ============================================================================
#[test]
fn test_reject_concentration_exceeding_limit() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Position: 6, price: $100
// Notional: 6 * $100 = $600
// Concentration: $600 / $5,000 = 12% > max(10%) → REJECT
let result = limiter.check_position_increase(
"SMALL",
0.0,
6.0,
100.0,
5_000.0, // Small portfolio → higher concentration
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // Should be rejected (concentration exceeded)
}
// ============================================================================
// TEST 6: Allow Position Decrease
// ============================================================================
#[test]
fn test_allow_position_decrease() {
let config = TestPositionLimiterConfig::default_test();
let limiter = MockPositionLimiter::new(config);
// Position decreases are ALWAYS allowed
assert!(limiter.check_position_decrease(-1.0));
assert!(limiter.check_position_decrease(-5.0));
assert!(limiter.check_position_decrease(-10.0));
}
// ============================================================================
// TEST 7: Dynamic Limit Adjustment (Volatility-based)
// ============================================================================
#[test]
fn test_dynamic_limit_adjustment() {
let mut config = TestPositionLimiterConfig::default_test();
// Simulate high volatility environment → reduce max_position
config.max_position = 5.0; // Reduced from 10.0 due to volatility
let mut limiter = MockPositionLimiter::new(config);
// Position of 6 should now be rejected
let result = limiter.check_position_increase(
"AAPL",
0.0, // current
6.0, // proposed - exceeds new limit
100.0, // price
1_000_000.0, // portfolio
);
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // Rejected due to reduced volatility limit
}
// ============================================================================
// TEST 8: Cache Hit Performance (<10μs)
// ============================================================================
#[test]
fn test_cache_hit_performance() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Populate cache
limiter.update_position("AAPL", 5.0, 500.0);
let start = Instant::now();
let result = limiter.get_cached_position("AAPL");
let elapsed = start.elapsed();
assert_eq!(result, Some(5.0));
assert!(elapsed < Duration::from_micros(100)); // Much faster than 10μs requirement allows some slack
assert_eq!(limiter.get_cache_hit_count(), 1);
assert_eq!(limiter.get_rpc_call_count(), 0); // No RPC calls
}
// ============================================================================
// TEST 9: RPC Fallback on Cache Miss
// ============================================================================
#[test]
fn test_rpc_fallback_on_cache_miss() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// No cache entry for MSFT
let result = limiter.get_position("MSFT", false);
assert!(result.is_ok());
assert_eq!(limiter.get_cache_hit_count(), 0); // No cache hit
assert_eq!(limiter.get_rpc_call_count(), 1); // RPC was called
}
// ============================================================================
// TEST 10: Action Masked if Limit Violated
// ============================================================================
#[test]
fn test_action_masked_if_limit_violated() {
let config = TestPositionLimiterConfig::default_test();
let limiter = MockPositionLimiter::new(config);
// Current position: 9.5 (near limit of 10)
let mask = limiter.get_action_mask(9.5);
// Long100 (index 36-44) should be masked (would exceed limit)
for idx in 36..=44 {
assert_eq!(mask[idx], false, "Long100 action {} should be masked", idx);
}
// Flat (index 18-26) and Short (0-17) should mostly be valid
let flat_valid = mask[18..=26].iter().filter(|&&m| m).count();
assert!(flat_valid > 0, "Some Flat actions should be valid");
}
// ============================================================================
// TEST 11: Error Logged on Rejection
// ============================================================================
#[test]
fn test_error_logged_on_rejection() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Attempt to exceed position limit
let result = limiter.check_position_increase(
"AAPL",
9.0, // current
5.0, // proposed delta → 14 > max 10
100.0,
1_000_000.0,
);
// In real implementation, error would be logged here
assert!(result.is_ok());
assert_eq!(result.unwrap(), false); // Position rejected
}
// ============================================================================
// TEST 12: Limit Config via CLI
// ============================================================================
#[test]
fn test_limit_config_via_cli() {
// Simulate CLI args: --max-position 20 --max-notional 1000000 --max-concentration 0.15
let mut config = TestPositionLimiterConfig::default_test();
config.max_position = 20.0; // --max-position 20
config.max_notional = 1_000_000.0; // --max-notional 1000000
config.max_concentration = 0.15; // --max-concentration 0.15
let limiter = MockPositionLimiter::new(config);
assert_eq!(limiter.config.max_position, 20.0);
assert_eq!(limiter.config.max_notional, 1_000_000.0);
assert_eq!(limiter.config.max_concentration, 0.15);
}
// ============================================================================
// EXTENDED TESTS: Edge Cases & Integration
// ============================================================================
/// Test 13: Zero position handling
#[test]
fn test_zero_position_handling() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
limiter.update_position("AAPL", 0.0, 0.0);
let position = limiter.get_cached_position("AAPL");
assert_eq!(position, Some(0.0));
}
/// Test 14: Negative position (short) handling
#[test]
fn test_negative_position_handling() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Short position: -5 contracts
limiter.update_position("AAPL", -5.0, -500.0);
let position = limiter.get_cached_position("AAPL");
assert_eq!(position, Some(-5.0));
}
/// Test 15: Cache expiry handling
#[test]
fn test_cache_expiry_handling() {
let mut config = TestPositionLimiterConfig::default_test();
config.cache_ttl = Duration::from_millis(10); // Very short TTL
let mut limiter = MockPositionLimiter::new(config);
limiter.update_position("AAPL", 5.0, 500.0);
// Immediately: should be in cache
assert_eq!(limiter.get_cached_position("AAPL"), Some(5.0));
// After expiry: cache miss, triggers RPC
std::thread::sleep(Duration::from_millis(20));
let result = limiter.get_position("AAPL", false);
assert!(result.is_ok());
// RPC would be called on second access if cache expired
}
/// Test 16: Concurrent position updates
#[test]
fn test_concurrent_position_updates() {
use std::sync::Arc;
use std::sync::Mutex;
let config = TestPositionLimiterConfig::default_test();
let limiter = Arc::new(Mutex::new(MockPositionLimiter::new(config)));
// Simulate 10 concurrent position updates
let handles: Vec<_> = (0..10)
.map(|i| {
let lim = Arc::clone(&limiter);
std::thread::spawn(move || {
let mut l = lim.lock().unwrap();
let symbol = format!("SYM_{}", i);
l.update_position(&symbol, i as f64, (i as f64) * 100.0);
})
})
.collect();
for handle in handles {
handle.join().unwrap();
}
// Verify all updates were applied
let l = limiter.lock().unwrap();
assert_eq!(l.cache.len(), 10);
}
/// Test 17: Multiple symbols per account
#[test]
fn test_multiple_symbols_per_account() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
limiter.update_position("AAPL", 5.0, 500.0);
limiter.update_position("GOOGL", 3.0, 300.0);
limiter.update_position("MSFT", 2.0, 200.0);
assert_eq!(limiter.get_cached_position("AAPL"), Some(5.0));
assert_eq!(limiter.get_cached_position("GOOGL"), Some(3.0));
assert_eq!(limiter.get_cached_position("MSFT"), Some(2.0));
}
/// Test 18: RPC threshold percentage
#[test]
fn test_rpc_threshold_percentage() {
let config = TestPositionLimiterConfig::default_test();
// 80% of $500k = $400k notional threshold
assert_eq!(config.rpc_check_threshold_percent, 0.80);
}
/// Test 19: Reject both short and long extremes
#[test]
fn test_reject_both_extremes() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Reject long extreme
let long_result = limiter.check_position_increase("ES", 10.0, 5.0, 100.0, 1_000_000.0);
assert_eq!(long_result.unwrap(), false);
// Reject short extreme
let short_result = limiter.check_position_increase("ES", -10.0, -5.0, 100.0, 1_000_000.0);
assert_eq!(short_result.unwrap(), false);
}
/// Test 20: Portfolio value affects concentration limit
#[test]
fn test_portfolio_value_affects_concentration() {
let config = TestPositionLimiterConfig::default_test();
let mut limiter = MockPositionLimiter::new(config);
// Small portfolio: 3% concentration (still within 10% limit)
let small_portfolio = limiter.check_position_increase("AAPL", 0.0, 3.0, 100.0, 10_000.0);
assert_eq!(small_portfolio.unwrap(), true); // 3% of 10k = $300, which is 3% < 10% limit (allowed)
// Large portfolio: looser concentration limit
let large_portfolio = limiter.check_position_increase("AAPL", 0.0, 3.0, 100.0, 10_000_000.0);
assert_eq!(large_portfolio.unwrap(), true); // 3% of portfolio
}
// ============================================================================
// SUMMARY
// ============================================================================
// Total tests: 20 TDD tests
// All tests FAIL initially (before Agent 27 implementation)
// Coverage:
// - Initialization ✓
// - Position increase validation ✓
// - Position limits (absolute, notional, concentration) ✓
// - Position decreases ✓
// - Dynamic limits ✓
// - Cache performance ✓
// - RPC fallback ✓
// - Action masking ✓
// - Error handling ✓
// - CLI configuration ✓
// - Edge cases (zero, negative, expiry, concurrency, multi-symbol) ✓
// - Integration scenarios ✓
// ============================================================================