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>
367 lines
13 KiB
Rust
367 lines
13 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,
|
||
)]
|
||
/// 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");
|
||
}
|