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>
400 lines
15 KiB
Rust
400 lines
15 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,
|
||
)]
|
||
//! Cash Reserve Requirement Tests (P2-B Enhancement)
|
||
//!
|
||
//! Comprehensive test suite for configurable cash reserve requirement feature.
|
||
//! Tests cover:
|
||
//! - Baseline (0% reserve) - backward compatibility
|
||
//! - Conservative/Standard/Aggressive reserves (5%, 10%, 15%)
|
||
//! - Trade rejection scenarios (BUY violating reserve)
|
||
//! - Trade acceptance scenarios (BUY with sufficient cash)
|
||
//! - Sell exemption (SELL always allowed)
|
||
//! - Dynamic reserve updates (portfolio growth/shrinkage)
|
||
//! - Transaction cost integration
|
||
//! - Boundary conditions
|
||
|
||
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
||
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
|
||
|
||
/// Helper function to create PortfolioTracker with specified reserve percentage
|
||
fn create_tracker_with_reserve(reserve_pct: f64) -> PortfolioTracker {
|
||
PortfolioTracker::new(
|
||
100_000.0, // initial_capital
|
||
0.0001, // avg_spread
|
||
reserve_pct, // cash_reserve_percent
|
||
)
|
||
}
|
||
|
||
/// Test 1: Baseline (0% reserve) - backward compatibility
|
||
///
|
||
/// Verifies that 0% reserve allows trades that would drain cash to $0,
|
||
/// maintaining backward compatibility with existing behavior.
|
||
#[test]
|
||
fn test_no_reserve_baseline() {
|
||
let mut tracker = create_tracker_with_reserve(0.0);
|
||
|
||
// Initial state: $100K cash, 0 position
|
||
assert_eq!(tracker.cash_balance(), 100_000.0);
|
||
assert_eq!(tracker.current_position(), 0.0);
|
||
|
||
// Action: BUY Long100 at $5000 with max_position=10 contracts (use smaller max for simpler version)
|
||
// Target: 10 contracts (Long100 = 1.0 × 10)
|
||
// Cost: 10 contracts × $5000 = $50,000
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Expected: Trade executes (0% reserve = no constraint)
|
||
// Cash: $100K - $50K trade cost - $75 Market order fee (0.15% of $50K) = $49,925
|
||
assert_eq!(tracker.current_position(), 10.0);
|
||
assert_eq!(tracker.cash_balance(), 49_925.0); // Cash reduced by trade cost ($50K) + Market fee ($75)
|
||
}
|
||
|
||
/// Test 2: Conservative reserve (5%) - BUY accepted
|
||
///
|
||
/// Verifies that BUY trades execute successfully when cash after trade
|
||
/// exceeds the 5% reserve requirement.
|
||
#[test]
|
||
fn test_conservative_reserve_buy_accepted() {
|
||
let mut tracker = create_tracker_with_reserve(5.0);
|
||
|
||
// Initial: $100K cash, portfolio_value = $100K
|
||
// Reserve required: $100K × 5% = $5K
|
||
|
||
// Action: BUY Long50 at $5000 (0.5 contracts clamped to 0.5)
|
||
// Cost: 5.0 × $5000 = $25,000
|
||
// Cash after: $100K - $25K = $75K (> $3.75K reserve) ✅
|
||
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Expected: Trade executes
|
||
assert_eq!(tracker.current_position(), 5.0);
|
||
let cash = tracker.cash_balance();
|
||
assert!(cash > 3_750.0, "Cash {:.2} should exceed $3.75K reserve", cash);
|
||
}
|
||
|
||
/// Test 3: Standard reserve (10%) - BUY accepted
|
||
///
|
||
/// Verifies that BUY trades execute when cash after trade exceeds 10% reserve.
|
||
#[test]
|
||
fn test_standard_reserve_buy_accepted() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Initial: $100K cash, portfolio_value = $100K
|
||
// Reserve required: $100K × 10% = $10K
|
||
|
||
// Action: BUY Long50 at $5000 (0.5 × 10 = 5.0 position)
|
||
// Cost: 5.0 × $5000 = $25,000
|
||
// Cash after: $100K - $25K = $75K (> $7.5K reserve) ✅
|
||
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Expected: Trade executes
|
||
assert_eq!(tracker.current_position(), 5.0);
|
||
let cash = tracker.cash_balance();
|
||
assert!(cash > 7_500.0, "Cash {:.2} should exceed $7.5K reserve (10%)", cash);
|
||
}
|
||
|
||
/// Test 4: Standard reserve (10%) - BUY rejected
|
||
///
|
||
/// Verifies that BUY trades are rejected (or reduced) when they would
|
||
/// violate the 10% cash reserve requirement.
|
||
#[test]
|
||
fn test_standard_reserve_buy_rejected() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Initial: $100K cash, portfolio_value = $100K
|
||
// Reserve required: $100K × 10% = $10K
|
||
|
||
// Step 1: Execute first buy
|
||
// Long100 (1.0) × max_position=10 = 10.0 position
|
||
// Cost: 10.0 × $5000 = $50,000
|
||
// Cash after: $100K - $50K = $50K (portfolio value ~$50K, reserve ~$5K)
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Step 2: Attempt second buy which should violate reserve
|
||
// This would cost another $50K, leaving cash at $0 (< $5K reserve) ❌
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Expected: Trade REJECTED or REDUCED (cash should not go below reserve)
|
||
let cash_after = tracker.cash_balance();
|
||
|
||
// Verify reserve is enforced
|
||
let portfolio_value = tracker.total_value(5000.0);
|
||
let reserve_required = portfolio_value * 0.10;
|
||
|
||
// Cash should be >= reserve after trade attempt
|
||
assert!(
|
||
cash_after >= reserve_required * 0.99, // Allow 1% tolerance for rounding
|
||
"Cash {:.2} should be >= reserve {:.2}",
|
||
cash_after, reserve_required
|
||
);
|
||
}
|
||
|
||
/// Test 5: Aggressive reserve (15%) - BUY rejected
|
||
///
|
||
/// Verifies that higher reserve percentages correctly reject more trades.
|
||
#[test]
|
||
fn test_aggressive_reserve_buy_rejected() {
|
||
let mut tracker = create_tracker_with_reserve(15.0);
|
||
|
||
// Initial: $100K cash, portfolio_value = $100K
|
||
// Reserve required: $100K × 15% = $15K
|
||
|
||
// Step 1: Execute first buy
|
||
// Cost: 10.0 × $5000 = $50,000
|
||
// Cash after: $50K (portfolio value ~$50K, reserve ~$7.5K)
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
// Step 2: Attempt second buy
|
||
// This would cost another $50K, leaving cash at $0 (< $7.5K reserve) ❌
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
let cash_after = tracker.cash_balance();
|
||
let portfolio_value = tracker.total_value(5000.0);
|
||
let reserve_required = portfolio_value * 0.15;
|
||
|
||
// Verify 15% reserve is enforced
|
||
assert!(
|
||
cash_after >= reserve_required * 0.99,
|
||
"Cash {:.2} should be >= 15% reserve {:.2}",
|
||
cash_after, reserve_required
|
||
);
|
||
}
|
||
|
||
/// Test 6: Reserve sell exemption - SELL always allowed
|
||
///
|
||
/// Verifies that SELL trades are ALWAYS allowed, even when cash is below
|
||
/// the reserve requirement, because selling ADDS cash.
|
||
#[test]
|
||
fn test_reserve_sell_always_allowed() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Step 1: Build a long position
|
||
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(buy_action, 5000.0, 10.0);
|
||
|
||
let cash_before_sell = tracker.cash_balance();
|
||
let position_before_sell = tracker.current_position();
|
||
|
||
// Step 2: Execute SELL (should always work, regardless of cash level)
|
||
let sell_action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(sell_action, 5000.0, 10.0);
|
||
|
||
// Expected: SELL executes (position changes, cash increases)
|
||
let cash_after_sell = tracker.cash_balance();
|
||
let position_after_sell = tracker.current_position();
|
||
|
||
// Position should change (SELL executed)
|
||
assert_ne!(position_after_sell, position_before_sell, "SELL should change position");
|
||
|
||
// Cash should increase or stay same (SELL adds cash)
|
||
// Note: Transaction costs might cause slight decrease, but trade should execute
|
||
assert!(
|
||
(cash_after_sell - cash_before_sell).abs() > 0.01,
|
||
"SELL should execute (cash changed from {:.2} to {:.2})",
|
||
cash_before_sell, cash_after_sell
|
||
);
|
||
}
|
||
|
||
/// Test 7: Dynamic reserve - portfolio growth
|
||
///
|
||
/// Verifies that the reserve requirement adjusts dynamically as portfolio
|
||
/// value increases due to profitable positions.
|
||
#[test]
|
||
fn test_reserve_dynamic_portfolio_growth() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Initial: $100K cash, reserve = $10K
|
||
|
||
// Step 1: BUY Long50 at $5000
|
||
let action = FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
let pv_after_buy = tracker.total_value(5000.0);
|
||
let reserve_after_buy = pv_after_buy * 0.10;
|
||
|
||
// Step 2: Price rises to $7000 (portfolio grows)
|
||
let pv_at_7k = tracker.total_value(7000.0);
|
||
let reserve_at_7k = pv_at_7k * 0.10;
|
||
|
||
// Reserve should increase with portfolio value
|
||
assert!(
|
||
reserve_at_7k > reserve_after_buy,
|
||
"Reserve should increase with portfolio growth ({:.2} -> {:.2})",
|
||
reserve_after_buy, reserve_at_7k
|
||
);
|
||
|
||
// Verify reserve is applied to next trade
|
||
// (Implementation detail: reserve check happens in execute_action)
|
||
}
|
||
|
||
/// Test 8: Dynamic reserve - portfolio shrinkage
|
||
///
|
||
/// Verifies that reserve requirement decreases when portfolio value
|
||
/// declines due to losing positions.
|
||
#[test]
|
||
fn test_reserve_dynamic_portfolio_shrinkage() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Step 1: BUY Long100 at $5000
|
||
let buy_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(buy_action, 5000.0, 10.0);
|
||
|
||
let pv_at_5k = tracker.total_value(5000.0);
|
||
let reserve_at_5k = pv_at_5k * 0.10;
|
||
|
||
// Step 2: Price falls to $4000 (portfolio shrinks)
|
||
let pv_at_4k = tracker.total_value(4000.0);
|
||
let reserve_at_4k = pv_at_4k * 0.10;
|
||
|
||
// Reserve should decrease with portfolio value
|
||
assert!(
|
||
reserve_at_4k < reserve_at_5k,
|
||
"Reserve should decrease with portfolio shrinkage ({:.2} -> {:.2})",
|
||
reserve_at_5k, reserve_at_4k
|
||
);
|
||
}
|
||
|
||
/// Test 9: Reserve with transaction costs
|
||
///
|
||
/// Verifies that transaction costs are correctly included when calculating
|
||
/// whether a trade violates the reserve requirement.
|
||
#[test]
|
||
fn test_reserve_with_transaction_costs() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// Initial: $100K cash, reserve = $10K
|
||
|
||
// Action: BUY with Market order (higher fee 0.15% vs LimitMaker 0.05%)
|
||
// Cost: 10.0 × $5000 = $50,000
|
||
|
||
let market_action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(market_action, 5000.0, 10.0);
|
||
|
||
let cash_after_market = tracker.cash_balance();
|
||
|
||
// Note: This simpler version doesn't track transaction costs separately
|
||
// but the reserve enforcement still works correctly
|
||
|
||
// Verify reserve is still enforced
|
||
let portfolio_value = tracker.total_value(5000.0);
|
||
let reserve_required = portfolio_value * 0.10;
|
||
|
||
assert!(
|
||
cash_after_market >= reserve_required * 0.99,
|
||
"Cash {:.2} should be >= reserve {:.2}",
|
||
cash_after_market, reserve_required
|
||
);
|
||
}
|
||
|
||
/// Test 10: Boundary condition - exact reserve limit
|
||
///
|
||
/// Verifies behavior when a trade would leave cash exactly at the reserve
|
||
/// requirement (boundary condition).
|
||
#[test]
|
||
fn test_reserve_edge_case_exact_boundary() {
|
||
let mut tracker = create_tracker_with_reserve(10.0);
|
||
|
||
// This test verifies that the reserve check uses strict comparison (>= not >)
|
||
// When cash after trade = reserve exactly, trade should be REJECTED or REDUCED
|
||
|
||
// Initial: $100K cash, reserve = $10K
|
||
|
||
// Drain cash to just above reserve
|
||
// First buy: $50K cost, leaves $50K cash
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
let portfolio_value = tracker.total_value(5000.0);
|
||
let reserve_required = portfolio_value * 0.10;
|
||
|
||
// Attempt second trade (would drain $50K → $0, violating reserve)
|
||
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
||
tracker.execute_action(action, 5000.0, 10.0);
|
||
|
||
let cash_final = tracker.cash_balance();
|
||
|
||
// Verify: cash should be >= reserve (not exactly equal, due to implementation reducing trade size)
|
||
assert!(
|
||
cash_final >= reserve_required * 0.99,
|
||
"Cash {:.2} should be >= reserve {:.2} (boundary test)",
|
||
cash_final, reserve_required
|
||
);
|
||
}
|