Files
foxhunt/crates/ml/tests/cash_reserve_requirement_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

400 lines
15 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,
)]
//! 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
);
}