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

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::ShortSmall, 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::LongFull, 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::ShortSmall, 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::LongFull, 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::LongFull, 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::ShortSmall, 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::ShortSmall, 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::LongFull, 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::ShortSmall, 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::ShortSmall, 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::LongFull, 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::ShortSmall, 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::LongFull, 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)");
}