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

367 lines
13 KiB
Rust
Raw Permalink 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,
)]
/// Test: Comprehensive Action-Position Sign Validation
///
/// Purpose: Validate that all 45 FactoredAction combinations produce correct position signs
/// and magnitudes after execution.
///
/// Coverage:
/// - All 45 action combinations (5 exposures × 3 order types × 3 urgencies)
/// - Sign convention validation (Long=positive, Short=negative, Flat=zero)
/// - Magnitude validation against target exposure
///
/// Expected Results:
/// - Long100 (exposure=0): +10.00 contracts
/// - Long50 (exposure=1): +5.00 contracts
/// - Flat (exposure=2): 0.00 contracts
/// - Short50 (exposure=3): -5.00 contracts
/// - Short100 (exposure=4): -10.00 contracts
use ml::dqn::{FactoredAction, ExposureLevel, OrderType, Urgency, PortfolioTracker};
use tracing::info;
#[test]
fn test_all_45_actions_sign_convention() {
let price = 100.0;
let max_position = 10.0;
// Expected exposure values for each ExposureLevel
let expected_exposures = [
(ExposureLevel::LongFull, 1.0, "LongFull"),
(ExposureLevel::LongHalf, 0.5, "LongHalf"),
(ExposureLevel::LongSmall, 0.25, "LongSmall"),
(ExposureLevel::Flat, 0.0, "Flat"),
(ExposureLevel::ShortSmall, -0.25, "ShortSmall"),
(ExposureLevel::ShortHalf, -0.5, "ShortHalf"),
(ExposureLevel::ShortFull, -1.0, "ShortFull"),
];
let order_types = [
(OrderType::Market, "Market"),
(OrderType::LimitMaker, "LimitMaker"),
(OrderType::IoC, "IoC"),
];
let urgencies = [
(Urgency::Patient, "Patient"),
(Urgency::Normal, "Normal"),
(Urgency::Aggressive, "Aggressive"),
];
info!("Testing All 45 Action-Position Sign Conventions");
let mut test_count = 0;
let mut pass_count = 0;
// Test all combinations
for (exposure_level, expected_target, exposure_name) in &expected_exposures {
for (order_type, order_name) in &order_types {
for (urgency, urgency_name) in &urgencies {
test_count += 1;
// Create action
let action = FactoredAction::new(*exposure_level, *order_type, *urgency);
let action_index = action.to_index();
// Get target exposure from action
let target_exposure = action.target_exposure();
// Verify target exposure matches expected
assert!(
(target_exposure - expected_target).abs() < 1e-6,
"Action {} ({}, {}, {}): target_exposure mismatch. Expected: {}, Got: {}",
action_index,
exposure_name,
order_name,
urgency_name,
expected_target,
target_exposure
);
// Create fresh portfolio tracker
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
// Execute action
portfolio.execute_action(action, price as f32, max_position as f32);
// Get actual position
let actual_position = portfolio.current_position() as f64;
// Calculate expected position
let expected_position = target_exposure * max_position;
// Validate position matches target
let position_error = (actual_position - expected_position).abs();
let is_pass = position_error < 1e-6;
if is_pass {
pass_count += 1;
}
// Log result
info!(
passed = is_pass,
action_index,
exposure_name,
order_name,
urgency_name,
target_exposure,
actual_position,
expected_position,
position_error,
"Action sign convention check"
);
// Assert with detailed failure message
assert!(
is_pass,
"\n❌ Action {} FAILED: Sign/magnitude mismatch\n\
Action: {} (exposure={}, order={}, urgency={})\n\
Target Exposure: {:+.2}\n\
Expected Position: {:+.2} contracts\n\
Actual Position: {:+.2} contracts\n\
Error: {:.6}\n",
action_index,
action_index,
exposure_name,
order_name,
urgency_name,
target_exposure,
expected_position,
actual_position,
position_error
);
}
}
}
info!(
test_count,
pass_count,
failed = test_count - pass_count,
pass_rate_pct = (pass_count as f64 / test_count as f64) * 100.0,
"Action sign convention test summary"
);
assert_eq!(
pass_count,
test_count,
"\n❌ Not all actions passed sign convention validation"
);
info!("All 45 actions passed sign convention validation");
}
#[test]
fn test_specific_exposure_levels_detailed() {
let price = 100.0;
let max_position = 10.0;
info!("Detailed Exposure Level Validation");
// Test Long100
{
let action = FactoredAction::from_index(36).unwrap(); // Long100 (exposure=4), Market (order=0), Patient (urgency=0)
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
portfolio.execute_action(action, price as f32, max_position as f32);
let target = action.target_exposure();
let position = portfolio.current_position() as f64;
let expected = target * max_position;
info!(target, expected, position, "Long100 exposure validation");
assert_eq!(target, 1.0, "Long100 target exposure should be +1.0");
assert_eq!(position, 10.0, "Long100 should result in +10.00 contracts");
assert!(position > 0.0, "Long100 position should be POSITIVE");
}
// Test Long50
{
let action = FactoredAction::from_index(27).unwrap(); // Long50 (exposure=3), Market (order=0), Patient (urgency=0)
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
portfolio.execute_action(action, price as f32, max_position as f32);
let target = action.target_exposure();
let position = portfolio.current_position() as f64;
let expected = target * max_position;
info!(target, expected, position, "Long50 exposure validation");
assert_eq!(target, 0.5, "Long50 target exposure should be +0.5");
assert_eq!(position, 5.0, "Long50 should result in +5.00 contracts");
assert!(position > 0.0, "Long50 position should be POSITIVE");
}
// Test Flat
{
let action = FactoredAction::from_index(18).unwrap(); // Flat, Market, Patient
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
portfolio.execute_action(action, price as f32, max_position as f32);
let target = action.target_exposure();
let position = portfolio.current_position() as f64;
let expected = target * max_position;
info!(target, expected, position, "Flat exposure validation");
assert_eq!(target, 0.0, "Flat target exposure should be 0.0");
assert_eq!(position, 0.0, "Flat should result in 0.00 contracts");
}
// Test Short50
{
let action = FactoredAction::from_index(9).unwrap(); // Short50 (exposure=1), Market (order=0), Patient (urgency=0)
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
portfolio.execute_action(action, price as f32, max_position as f32);
let target = action.target_exposure();
let position = portfolio.current_position() as f64;
let expected = target * max_position;
info!(target, expected, position, "Short50 exposure validation");
assert_eq!(target, -0.5, "Short50 target exposure should be -0.5");
assert_eq!(position, -5.0, "Short50 should result in -5.00 contracts");
assert!(position < 0.0, "Short50 position should be NEGATIVE");
}
// Test Short100
{
let action = FactoredAction::from_index(0).unwrap(); // Short100 (exposure=0), Market (order=0), Patient (urgency=0)
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
portfolio.execute_action(action, price as f32, max_position as f32);
let target = action.target_exposure();
let position = portfolio.current_position() as f64;
let expected = target * max_position;
info!(target, expected, position, "Short100 exposure validation");
assert_eq!(target, -1.0, "Short100 target exposure should be -1.0");
assert_eq!(position, -10.0, "Short100 should result in -10.00 contracts");
assert!(position < 0.0, "Short100 position should be NEGATIVE");
}
info!("All exposure levels validated successfully");
}
#[test]
fn test_position_transitions() {
let price = 100.0;
let max_position = 10.0;
info!("Position Transition Validation");
let mut portfolio = PortfolioTracker::new(10000.0, 0.01, 0.0);
// Transition 1: Flat → Long100
let action1 = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action1, price as f32, max_position as f32);
let pos1 = portfolio.current_position() as f64;
info!(pos1, "Flat to Long100 position");
assert_eq!(pos1, 10.0, "Should be at +10.0 after Long100");
// Transition 2: LongFull → ShortFull
let action2 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action2, price as f32, max_position as f32);
let pos2 = portfolio.current_position() as f64;
info!(pos2, "LongFull to ShortFull position");
assert_eq!(pos2, -10.0, "Should be at -10.0 after ShortFull");
// Transition 3: Short100 → Flat
let action3 = FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action3, price as f32, max_position as f32);
let pos3 = portfolio.current_position() as f64;
info!(pos3, "Short100 to Flat position");
assert_eq!(pos3, 0.0, "Should be at 0.0 after Flat");
// Transition 4: Flat → ShortFull
let action4 = FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action4, price as f32, max_position as f32);
let pos4 = portfolio.current_position() as f64;
info!(pos4, "Flat to ShortFull position");
assert_eq!(pos4, -10.0, "Should be at -10.0 after ShortFull");
// Transition 5: ShortFull → LongHalf
let action5 = FactoredAction::new(ExposureLevel::LongHalf, OrderType::Market, Urgency::Normal);
portfolio.execute_action(action5, price as f32, max_position as f32);
let pos5 = portfolio.current_position() as f64;
info!(pos5, "ShortFull to LongHalf position");
assert_eq!(pos5, 5.0, "Should be at +5.0 after LongHalf");
info!("All position transitions validated successfully");
}