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

232 lines
7.5 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,
)]
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::Long100, // 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::Long100,
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::Short100, // 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::Long100,
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
);
}