Files
foxhunt/crates/ml/tests/partial_reversal_validation.rs
jgrusewski ca4c38d921 fix(tests): CI GPU test stability, walltime reduction, BF16 tolerance
- Reduce CI GPU test datasets 16x for walltime reduction
- Reduce early-stop epochs 50→10, add --test-threads=1
- Serialize all GPU lib tests to prevent cuBLAS init race
- Align state_dim to 16 for BF16 tensor core HMMA dispatch
- BF16 precision tolerance in ml-dqn tests
- Enable branching DQN + tracing subscriber in smoke tests
- Prevent min_replay_size > buffer_size deadlock in early-stop tests
- Prevent AutoReplaySizer from breaking gradient collapse warmup
- Replace racy tokio::spawn checkpoint counter with AtomicUsize
- Set warmup_steps=0 and max_training_steps_per_epoch=300 in early-stop tests
- RealDataLoader respects TEST_DATA_DIR for CI PVC layout
- Add collapse_warmup_capacity to gpu_smoketest DQNConfig
- Drain CUDA context between test binaries
- Detached HEAD checkout prevents local branch corruption
- GPU pipeline tests: fix BF16 dtype and rank-1 squeeze assertions
- OOD input handling tests use use_gpu: true

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-15 12:00:13 +01:00

276 lines
11 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,
)]
//! Simple validation test for partial reversal support (Wave 16 P2-C)
//!
//! This test suite uses the CORRECT PortfolioTracker API to validate
//! the two-phase reversal logic implementation.
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
use tracing::info;
#[test]
fn test_full_reversal_sufficient_cash() {
// Test 1: Full reversal with ample cash (baseline)
let mut tracker = PortfolioTracker::new(20_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Step 1: Open short position (-1.0)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
assert_eq!(tracker.current_position(), -1.0, "Should have -1.0 short position");
// Step 2: Reverse to long (+1.0) with sufficient cash
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
assert_eq!(tracker.current_position(), 1.0, "Should complete full reversal to +1.0 long");
assert!(tracker.cash_balance() > 0.0, "Should have positive cash remaining");
}
#[test]
fn test_partial_reversal_limited_cash() {
// Test 2: Partial reversal when cash is limited
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Step 1: Open short position (-1.0)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_before_reversal = tracker.cash_balance();
info!(cash_before_reversal, "Cash before reversal");
// Step 2: Attempt full reversal to long (+1.0)
// With limited cash, should achieve partial reversal
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
let final_cash = tracker.cash_balance();
info!(final_position, final_cash, "Final position and cash");
// Assertions:
// 1. Position should be >= 0 (closed short, opened some long)
assert!(final_position >= 0.0, "Position should be non-negative after reversal");
// 2. Position should be < 1.0 (partial fill due to cash constraint)
assert!(final_position < 1.0, "Position should be partial (< 1.0) due to limited cash");
// 3. Cash should be low but >= 0
assert!(final_cash >= 0.0, "Cash should never go negative");
}
#[test]
fn test_reversal_to_flat_only() {
// Test 3: Reversal with only enough cash for Phase 1 (close position)
// Result: Should end at flat (0.0) position
let mut tracker = PortfolioTracker::new(7_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Step 1: Open long position (+1.0)
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let cash_after_long = tracker.cash_balance();
info!(cash_after_long, "Cash after opening long");
// Step 2: Attempt reversal to short (-1.0)
// With very limited cash, should only close long (Phase 1), not open short (Phase 2)
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let final_position = tracker.current_position();
let final_cash = tracker.cash_balance();
info!(final_position, final_cash, "Final position and cash");
// Assertion: Should end at flat (0.0) or very small short position
// Depending on cash, might have 0.0 or small partial short
assert!(final_position >= -0.1, "Position should be near flat or small short");
assert!(final_cash >= 0.0, "Cash should never go negative");
}
#[test]
fn test_transaction_costs_tracked() {
// Test 4: Verify transaction costs are tracked during reversal
let mut tracker = PortfolioTracker::new(20_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Execute short → long reversal
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let tx_cost_after_short = tracker.transaction_costs();
assert!(tx_cost_after_short > 0.0, "Should have transaction costs from short");
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let tx_cost_after_reversal = tracker.transaction_costs();
info!(tx_cost_after_short, tx_cost_after_reversal, "Transaction costs: short vs reversal");
// Assertions:
// 1. Costs increased after reversal (Phase 1 + Phase 2 costs)
assert!(tx_cost_after_reversal > tx_cost_after_short,
"Transaction costs should increase after reversal");
// 2. Market order rate is 0.15% (0.0015)
// For 2 trades of 1 contract at $5,600: 2 * 5600 * 0.0015 = $16.80 minimum
assert!(tx_cost_after_reversal >= 16.0,
"Transaction costs should be at least $16 for two market orders");
}
#[test]
fn test_negative_cash_guard() {
// Test 5: Verify negative cash is guarded against
// This is a safety test - should never happen in practice
let mut tracker = PortfolioTracker::new(1_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Try to open short with minimal capital
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
// Cash should never go negative
assert!(tracker.cash_balance() >= 0.0, "Cash should never be negative");
}
#[test]
fn test_multiple_reversals() {
// Test 6: Multiple reversals in sequence
let mut tracker = PortfolioTracker::new(30_000.0, 0.0001, 0.0);
let price = 5_600.0;
// Reversal 1: Flat → Short
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "After Reversal 1 (Short)");
// Reversal 2: Short → Long
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "After Reversal 2 (Long)");
// Reversal 3: Long → Short
tracker.execute_action(short_action, price, 1.0);
info!(position = tracker.current_position(), cash = tracker.cash_balance(), "After Reversal 3 (Short)");
// Assertions:
assert!(tracker.cash_balance() >= 0.0, "Cash should never go negative");
let tx_costs = tracker.transaction_costs();
info!(tx_costs, "Total transaction costs");
// Should have costs from all reversals (6 trades total: 3 reversals × 2 phases each)
// Minimum: 6 * 5600 * 0.0015 = $50.40
assert!(tx_costs >= 50.0, "Should have accumulated transaction costs");
}
#[test]
fn test_cash_reserve_enforcement() {
// Test 7: Verify cash reserve is enforced during Phase 2
let mut tracker = PortfolioTracker::new(15_000.0, 0.0001, 10.0); // 10% reserve
let price = 5_600.0;
// Open short position
let short_action = FactoredAction::new(ExposureLevel::Short100, OrderType::Market, Urgency::Normal);
tracker.execute_action(short_action, price, 1.0);
let cash_before = tracker.cash_balance();
info!(cash_before, "Cash before reversal");
// Attempt reversal with reserve requirement
let long_action = FactoredAction::new(ExposureLevel::Long100, OrderType::Market, Urgency::Normal);
tracker.execute_action(long_action, price, 1.0);
let final_position = tracker.current_position();
let final_cash = tracker.cash_balance();
let portfolio_value = tracker.total_value(price);
info!(final_position, final_cash, portfolio_value, "Final position, cash, and portfolio value");
// Assertion: Cash reserve should be enforced
let reserve_required = portfolio_value * 0.10;
info!(reserve_required, "Reserve required (10%)");
// Cash should be close to or above reserve (may be slightly below due to rounding)
assert!(final_cash >= reserve_required * 0.95,
"Cash should respect reserve requirement (within 5% tolerance)");
}