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

232 lines
7.5 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,
)]
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
use tracing::info;
#[test]
fn test_position_size_respects_max_limit() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Try to build massive 10,000 contract position (Wave 16R bug)
for i in 0..100 {
let long_action = FactoredAction::new(
ExposureLevel::LongFull, // Maximum long exposure
OrderType::Market,
Urgency::Aggressive
);
tracker.execute_action(
long_action,
5000.0, // ES futures price
200.0 // Max position parameter (should be enforced)
);
// Debug: Check position after each iteration
if i % 20 == 0 {
let features = tracker.get_raw_portfolio_features(5000.0);
info!(iteration = i, position = features[1], portfolio = features[0], "Position check");
}
}
// CRITICAL: Position must be clamped to max_position limit
let features = tracker.get_raw_portfolio_features(5000.0);
let final_position = features[1]; // Position size from features
info!(position = final_position, portfolio = features[0], "Final position");
assert!(
final_position.abs() <= 200.0,
"Position size {} exceeds max_position limit 200! \
Bug #15 (unbounded position sizing) still exists!",
final_position
);
}
#[test]
fn test_position_prevents_portfolio_explosion() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
let initial_capital = 100_000.0;
// Build position to limit
for _ in 0..50 {
let action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive
);
tracker.execute_action(action, 5000.0, 100.0);
}
let features = tracker.get_raw_portfolio_features(5000.0);
let portfolio_value = features[0];
let normalized_value = portfolio_value / initial_capital;
// Portfolio should stay in realistic range (not $50M)
assert!(
portfolio_value < 500_000.0,
"Portfolio value ${} is catastrophic! Expected <$500K. \
Position explosion indicates Bug #15 persists.",
portfolio_value
);
assert!(
normalized_value < 5.0,
"Normalized value {} is catastrophic! Expected <5.0. \
This will cause reward explosion and gradient collapse.",
normalized_value
);
}
#[test]
fn test_negative_position_also_clamped() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Try to build massive short position
for _ in 0..100 {
let short_action = FactoredAction::new(
ExposureLevel::ShortSmall, // Maximum short exposure
OrderType::Market,
Urgency::Aggressive
);
tracker.execute_action(
short_action,
5000.0, // ES futures price
150.0 // Max position parameter
);
}
let features = tracker.get_raw_portfolio_features(5000.0);
let final_position = features[1];
assert!(
final_position >= -150.0,
"Short position {} exceeds max_position limit -150! \
Position clipping should work both ways.",
final_position
);
}
#[test]
fn test_low_price_creates_catastrophic_positions() {
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// CRITICAL: Simulate low price scenario (e.g., penny stock or data bug)
// At price=$10, max_position = $100K / $10 = 10,000 contracts!
let low_price = 10.0;
let max_position_uncapped = 100_000.0 / low_price; // = 10,000 contracts
info!(price = low_price, max_position = max_position_uncapped, "Testing low price scenario");
// Execute Long100 action with uncapped max_position
let long_action = FactoredAction::new(
ExposureLevel::LongFull,
OrderType::Market,
Urgency::Aggressive
);
tracker.execute_action(long_action, low_price, max_position_uncapped);
let features = tracker.get_raw_portfolio_features(low_price);
let final_position = features[1];
let portfolio_value = features[0];
info!(position = final_position, portfolio = portfolio_value, "After Long100");
// THIS IS THE BUG: Position can be 10,000 contracts!
// At $10/contract, that's $100K exposure (OK)
// But if price moves to $5000 (ES futures), portfolio = 10,000 × $5000 = $50M (CATASTROPHIC)
// Demonstrate the explosion when price changes
let es_price = 5000.0;
let catastrophic_portfolio = tracker.get_raw_portfolio_features(es_price)[0];
info!(price = es_price, portfolio = catastrophic_portfolio, "Price move - portfolio value");
assert!(
final_position.abs() <= 200.0,
"Position size {} is catastrophic at low price! \
Should be clamped to reasonable limit (200 contracts), not based on price. \
Bug #15: max_position = capital/price creates unbounded positions at low prices!",
final_position
);
// Verify portfolio stays under $2M even at high prices (200 contracts × $5K = $1M position)
assert!(
catastrophic_portfolio < 2_000_000.0,
"Portfolio value ${:.0} is still catastrophic! Expected <$2M after position clamping.",
catastrophic_portfolio
);
}