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>
692 lines
24 KiB
Rust
692 lines
24 KiB
Rust
#![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 ✓
|
||
// ============================================================================
|