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>
397 lines
14 KiB
Rust
397 lines
14 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,
|
|
)]
|
|
//! Wave 16N Integration Tests: Price Validity
|
|
//!
|
|
//! Property tests ensuring all prices remain positive and realistic throughout
|
|
//! the data pipeline, from DBN loading to P&L calculation.
|
|
//!
|
|
//! # Test Coverage
|
|
//! - All prices in DBN files are positive
|
|
//! - Prices remain within realistic ranges for each instrument
|
|
//! - Preprocessed prices NOT used in P&L calculations
|
|
//! - Training and validation use consistent price extraction
|
|
//! - PortfolioTracker receives valid price updates
|
|
//!
|
|
//! # Regression Prevention
|
|
//! - Detects negative prices (Wave 16M bug: preprocessed z-scores in P&L)
|
|
//! - Validates realistic price ranges (catches $640T P&L explosions)
|
|
//! - Ensures raw price preservation through preprocessing
|
|
|
|
#![allow(unused_crate_dependencies)]
|
|
|
|
use anyhow::Result;
|
|
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
|
|
use ml::dqn::portfolio_tracker::PortfolioTracker;
|
|
use tracing::{info, warn};
|
|
|
|
// ============================================================================
|
|
// Test 1: All Prices Positive
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_all_prices_positive() -> Result<()> {
|
|
// Validate that PortfolioTracker enforces positive prices
|
|
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
// Test prices at realistic levels for ES.FUT
|
|
let test_prices = vec![4500.0, 4550.25, 4525.50, 4600.75, 4575.00];
|
|
|
|
for (i, &price) in test_prices.iter().enumerate() {
|
|
assert!(price > 0.0, "Price at index {} is not positive: {}", i, price);
|
|
|
|
// Execute action with positive price
|
|
let action = FactoredAction::new(
|
|
ExposureLevel::LongSmall,
|
|
OrderType::Market,
|
|
Urgency::Normal,
|
|
);
|
|
tracker.execute_action(action, price, 100.0);
|
|
|
|
// Verify portfolio value is positive
|
|
let portfolio_value = tracker.total_value(price);
|
|
assert!(
|
|
portfolio_value > 0.0,
|
|
"Portfolio value negative at step {}: {:.2}",
|
|
i,
|
|
portfolio_value
|
|
);
|
|
}
|
|
|
|
info!(count = test_prices.len(), "All prices positive, portfolio value valid");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 2: Prices in Realistic Range
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_prices_in_realistic_range() -> Result<()> {
|
|
// ES.FUT: $4,000 - $6,000
|
|
let es_prices = vec![4500.0, 4800.25, 5200.50, 4100.75, 5800.00];
|
|
for (i, &price) in es_prices.iter().enumerate() {
|
|
assert!(
|
|
price >= 4000.0 && price <= 6000.0,
|
|
"ES.FUT price {} out of range at index {}: {:.2}",
|
|
price,
|
|
i,
|
|
price
|
|
);
|
|
}
|
|
info!(count = es_prices.len(), "ES.FUT prices in $4,000-$6,000 range");
|
|
|
|
// ZN.FUT: $100 - $130 (10-year T-Note)
|
|
let zn_prices = vec![110.0, 115.25, 120.50, 108.75, 125.00];
|
|
for (i, &price) in zn_prices.iter().enumerate() {
|
|
assert!(
|
|
price >= 100.0 && price <= 130.0,
|
|
"ZN.FUT price {} out of range at index {}: {:.2}",
|
|
price,
|
|
i,
|
|
price
|
|
);
|
|
}
|
|
info!(count = zn_prices.len(), "ZN.FUT prices in $100-$130 range");
|
|
|
|
// 6E.FUT: $1.00 - $1.30 (EUR/USD)
|
|
let fx_prices = vec![1.05, 1.10, 1.15, 1.08, 1.20];
|
|
for (i, &price) in fx_prices.iter().enumerate() {
|
|
assert!(
|
|
price >= 1.00 && price <= 1.30,
|
|
"6E.FUT price {} out of range at index {}: {:.2}",
|
|
price,
|
|
i,
|
|
price
|
|
);
|
|
}
|
|
info!(count = fx_prices.len(), "6E.FUT prices in $1.00-$1.30 range");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 3: No Preprocessed Prices in P&L
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_no_preprocessed_prices_in_pnl() -> Result<()> {
|
|
// This test verifies that P&L calculations use RAW prices, not preprocessed z-scores
|
|
// Z-scores range from approximately -3 to +3 (standard deviations)
|
|
// Raw prices for ES.FUT are $4,000-$6,000
|
|
|
|
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
// Execute trades at realistic price levels
|
|
let realistic_prices = vec![
|
|
4500.0, // Initial entry
|
|
4550.25, // +$50 move
|
|
4525.50, // -$25 move
|
|
4600.75, // +$75 move
|
|
];
|
|
|
|
for (i, &price) in realistic_prices.iter().enumerate() {
|
|
// Verify price is NOT a z-score (outside -3 to +3 range)
|
|
let price: f32 = price;
|
|
assert!(
|
|
price.abs() > 10.0,
|
|
"Price looks like z-score at step {}: {:.4} (expected >$10)",
|
|
i,
|
|
price
|
|
);
|
|
|
|
// Execute action
|
|
let action = if i % 2 == 0 {
|
|
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal)
|
|
} else {
|
|
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal)
|
|
};
|
|
|
|
tracker.execute_action(action, price, 100.0);
|
|
|
|
// Verify P&L is realistic (NOT using preprocessed prices)
|
|
let pnl = tracker.unrealized_pnl(price);
|
|
assert!(
|
|
pnl.abs() < 10_000.0,
|
|
"P&L too large at step {} (possible preprocessed price leak): {:.2}",
|
|
i,
|
|
pnl
|
|
);
|
|
}
|
|
|
|
info!("No preprocessed prices detected in P&L calculations");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 4: Training vs Validation Price Consistency
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_training_vs_validation_price_consistency() -> Result<()> {
|
|
// This test verifies that both training and validation use the same price extraction logic
|
|
// Key assertion: Prices come from the same tensor index in both modes
|
|
|
|
// Simulate training phase
|
|
let training_prices = vec![4500.0, 4550.25, 4525.50];
|
|
let mut training_tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
for &price in &training_prices {
|
|
training_tracker.execute_action(
|
|
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
|
|
price,
|
|
100.0,
|
|
);
|
|
}
|
|
let training_pnl = training_tracker.unrealized_pnl(*training_prices.last().unwrap());
|
|
|
|
// Simulate validation phase (should use identical price extraction)
|
|
let validation_prices = vec![4500.0, 4550.25, 4525.50];
|
|
let mut validation_tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
for &price in &validation_prices {
|
|
validation_tracker.execute_action(
|
|
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
|
|
price,
|
|
100.0,
|
|
);
|
|
}
|
|
let validation_pnl = validation_tracker.unrealized_pnl(*validation_prices.last().unwrap());
|
|
|
|
// P&L should be identical if prices are extracted consistently
|
|
assert!(
|
|
(training_pnl - validation_pnl).abs() < 0.01,
|
|
"Training P&L ({:.2}) != Validation P&L ({:.2})",
|
|
training_pnl,
|
|
validation_pnl
|
|
);
|
|
|
|
info!(pnl = training_pnl, "Training and validation price extraction consistent");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 5: Portfolio Tracker Price Updates
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_portfolio_tracker_price_updates() -> Result<()> {
|
|
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
// Execute 100 actions with varying prices
|
|
let mut last_valid_price = 4500.0;
|
|
|
|
for i in 0..100 {
|
|
// Simulate realistic price movements (±1% per step)
|
|
let price_change = ((i as f32 % 10.0) - 5.0) * 9.0; // ±$45 per step
|
|
let current_price = (last_valid_price + price_change).max(4000.0).min(6000.0);
|
|
|
|
// Execute action
|
|
let exposure = match i % 5 {
|
|
0 => ExposureLevel::LongFull,
|
|
1 => ExposureLevel::LongSmall,
|
|
2 => ExposureLevel::Flat,
|
|
3 => ExposureLevel::ShortFull,
|
|
4 => ExposureLevel::ShortSmall,
|
|
_ => ExposureLevel::Flat,
|
|
};
|
|
|
|
tracker.execute_action(
|
|
FactoredAction::new(exposure, OrderType::Market, Urgency::Normal),
|
|
current_price,
|
|
100.0,
|
|
);
|
|
|
|
// Verify last_price is updated
|
|
let portfolio_value = tracker.total_value(current_price);
|
|
|
|
// Assert: Portfolio value is positive and realistic
|
|
assert!(
|
|
portfolio_value > 0.0,
|
|
"Portfolio value non-positive at step {}: {:.2}",
|
|
i,
|
|
portfolio_value
|
|
);
|
|
assert!(
|
|
portfolio_value < 1_000_000.0,
|
|
"Portfolio value unrealistic at step {}: {:.2}",
|
|
i,
|
|
portfolio_value
|
|
);
|
|
|
|
last_valid_price = current_price;
|
|
}
|
|
|
|
info!(final_value = tracker.total_value(last_valid_price), "100 portfolio tracker updates: all prices positive and realistic");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 6: Zero Price Protection
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_zero_price_protection() -> Result<()> {
|
|
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
// Attempt to execute action at price = 0 (should be protected)
|
|
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
|
|
|
|
// Note: PortfolioTracker allows price=0 but normalized_position calculation has fallback
|
|
tracker.execute_action(action, 0.0, 100.0);
|
|
|
|
let features = tracker.get_portfolio_features(0.0);
|
|
// features[1] is normalized_position, should have fallback if price=0
|
|
assert!(
|
|
features[1].is_finite(),
|
|
"Normalized position should have finite fallback for price=0"
|
|
);
|
|
|
|
info!("Zero price protection: normalized position fallback operational");
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Test 7: Negative Price Rejection
|
|
// ============================================================================
|
|
|
|
#[test]
|
|
fn test_negative_price_rejection() -> Result<()> {
|
|
// This test ensures that negative prices (e.g., preprocessed z-scores) are caught
|
|
// The test documents expected behavior - PortfolioTracker accepts f32 prices
|
|
// but negative prices should never reach this point in production
|
|
|
|
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
|
|
|
// Test with known negative value (z-score example)
|
|
let preprocessed_z_score = -2.5; // This should NEVER be used as a price
|
|
|
|
// Execute action (system accepts negative prices, but P&L will be wrong)
|
|
tracker.execute_action(
|
|
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
|
|
preprocessed_z_score,
|
|
100.0,
|
|
);
|
|
|
|
let pnl = tracker.unrealized_pnl(preprocessed_z_score);
|
|
|
|
// Document the behavior: Negative prices produce nonsensical P&L
|
|
// This test will PASS to document current behavior, but highlights the bug
|
|
warn!(price = preprocessed_z_score, pnl, "Negative price accepted (z-score leak)");
|
|
warn!("THIS IS A BUG DETECTOR - Production MUST validate prices before PortfolioTracker");
|
|
|
|
// The fix is upstream: Ensure feature extraction uses raw prices, NOT preprocessed
|
|
Ok(())
|
|
}
|