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>
477 lines
16 KiB
Rust
477 lines
16 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: P&L Realism
|
||
//!
|
||
//! Property tests ensuring P&L values remain realistic throughout training,
|
||
//! preventing catastrophic explosions like the Wave 16M $640 trillion bug.
|
||
//!
|
||
//! # Test Coverage
|
||
//! - P&L values within realistic bounds (<$10,000 for validation)
|
||
//! - Return percentages reasonable (±10% max for short validation runs)
|
||
//! - P&L matches position changes (accounting validation)
|
||
//! - Transaction costs correctly reduce net P&L
|
||
//! - P&L accumulation is consistent across epochs
|
||
//!
|
||
//! # Regression Prevention
|
||
//! - Detects P&L explosions (e.g., $640T from preprocessed prices)
|
||
//! - Validates accounting consistency (position changes → P&L)
|
||
//! - Ensures transaction costs are applied correctly
|
||
|
||
#![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: P&L in Realistic Range
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_pnl_in_realistic_range() -> Result<()> {
|
||
// For a 1-epoch validation run with $10,000 initial capital:
|
||
// Expected P&L: <$10,000 (100% return is extreme but theoretically possible)
|
||
// BUG DETECTOR: $640 trillion P&L indicates preprocessed price leak
|
||
|
||
let initial_capital = 10_000.0;
|
||
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
|
||
|
||
// Simulate realistic trading sequence (ES.FUT)
|
||
let price_sequence = vec![
|
||
4500.0, // Initial price
|
||
4550.0, // +$50 (+1.1%)
|
||
4525.0, // -$25 (-0.5%)
|
||
4575.0, // +$50 (+1.1%)
|
||
4550.0, // -$25 (-0.5%)
|
||
];
|
||
|
||
for (i, &price) in price_sequence.iter().enumerate() {
|
||
let exposure = match i % 3 {
|
||
0 => ExposureLevel::LongFull,
|
||
1 => ExposureLevel::Flat,
|
||
2 => ExposureLevel::ShortFull,
|
||
_ => ExposureLevel::Flat,
|
||
};
|
||
|
||
tracker.execute_action(
|
||
FactoredAction::new(exposure, OrderType::Market, Urgency::Normal),
|
||
price,
|
||
100.0,
|
||
);
|
||
|
||
// Check P&L at each step
|
||
let pnl = tracker.unrealized_pnl(price);
|
||
assert!(
|
||
pnl.abs() < 10_000.0,
|
||
"P&L out of realistic range at step {}: {:.2} (expected <$10K)",
|
||
i,
|
||
pnl
|
||
);
|
||
}
|
||
|
||
let final_pnl = tracker.unrealized_pnl(*price_sequence.last().unwrap());
|
||
info!(pnl = %format!("{:.2}", final_pnl), "P&L within realistic range (expected <$10,000)");
|
||
|
||
// CRITICAL: Detect $640T explosion
|
||
assert!(
|
||
final_pnl.abs() < 1_000_000_000_000.0, // $1 trillion threshold
|
||
"CATASTROPHIC: P&L = ${:.2e} (likely preprocessed price leak)",
|
||
final_pnl
|
||
);
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 2: P&L Return Percentage Reasonable
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_pnl_return_percentage_reasonable() -> Result<()> {
|
||
let initial_capital = 10_000.0;
|
||
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
|
||
|
||
// Simulate moderate trading (ES.FUT, 10 steps)
|
||
let price_sequence = vec![
|
||
4500.0, 4520.0, 4510.0, 4530.0, 4515.0, 4540.0, 4525.0, 4550.0, 4535.0, 4560.0,
|
||
];
|
||
|
||
for (i, &price) in price_sequence.iter().enumerate() {
|
||
let exposure = if i % 2 == 0 {
|
||
ExposureLevel::LongSmall
|
||
} else {
|
||
ExposureLevel::Flat
|
||
};
|
||
|
||
tracker.execute_action(
|
||
FactoredAction::new(exposure, OrderType::Market, Urgency::Normal),
|
||
price,
|
||
100.0,
|
||
);
|
||
}
|
||
|
||
let final_pnl = tracker.unrealized_pnl(*price_sequence.last().unwrap());
|
||
let return_pct = (final_pnl / initial_capital) * 100.0;
|
||
|
||
// For validation data (limited time horizon), expect ±10% max
|
||
assert!(
|
||
return_pct.abs() < 10.0,
|
||
"Return percentage too high: {:.2}% (expected ±10% for short validation)",
|
||
return_pct
|
||
);
|
||
|
||
info!(return_pct = %format!("{:.2}", return_pct), "Return percentage reasonable (expected +-10%)");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 3: P&L Matches Position Changes
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_pnl_matches_position_changes() -> Result<()> {
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
||
|
||
// Test case: Long 100 contracts, price moves from $4500 → $4550 (+$50)
|
||
// Expected P&L: 100 contracts × $50 = $5,000
|
||
|
||
let entry_price = 4500.0;
|
||
let exit_price = 4550.0;
|
||
let position_size = 100.0;
|
||
|
||
// Open long position
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
|
||
entry_price,
|
||
position_size,
|
||
);
|
||
|
||
// Close position
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
|
||
exit_price,
|
||
position_size,
|
||
);
|
||
|
||
let actual_pnl = tracker.unrealized_pnl(exit_price);
|
||
let expected_pnl = position_size * (exit_price - entry_price);
|
||
|
||
// Allow 1% tolerance for rounding
|
||
let tolerance = expected_pnl.abs() * 0.01;
|
||
assert!(
|
||
(actual_pnl - expected_pnl).abs() < tolerance,
|
||
"P&L mismatch: actual={:.2}, expected={:.2} (position × price_change)",
|
||
actual_pnl,
|
||
expected_pnl
|
||
);
|
||
|
||
info!(actual_pnl = %format!("{:.2}", actual_pnl), expected_pnl = %format!("{:.2}", expected_pnl), "P&L matches position changes");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 4: Transaction Costs Reduce P&L
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_transaction_costs_reduce_pnl() -> Result<()> {
|
||
// This test verifies that transaction costs are applied correctly
|
||
// Expected: Net P&L < Gross P&L (after costs)
|
||
|
||
let initial_capital = 10_000.0;
|
||
let spread = 0.0001; // 1 basis point
|
||
|
||
let mut tracker = PortfolioTracker::new(initial_capital, spread, 1.0);
|
||
|
||
// Execute profitable trade
|
||
let entry_price = 4500.0;
|
||
let exit_price = 4550.0; // +$50 per contract
|
||
let position_size = 100.0;
|
||
|
||
// Open position
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
|
||
entry_price,
|
||
position_size,
|
||
);
|
||
|
||
// Close position
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
|
||
exit_price,
|
||
position_size,
|
||
);
|
||
|
||
// Calculate gross P&L (before costs)
|
||
let gross_pnl = position_size * (exit_price - entry_price);
|
||
|
||
// Calculate net P&L (after costs, from realized_pnl)
|
||
let net_pnl = tracker.realized_pnl();
|
||
|
||
// Transaction costs reduce P&L
|
||
assert!(
|
||
net_pnl < gross_pnl,
|
||
"Net P&L ({:.2}) should be less than gross P&L ({:.2}) due to transaction costs",
|
||
net_pnl,
|
||
gross_pnl
|
||
);
|
||
|
||
let cost = gross_pnl - net_pnl;
|
||
info!(gross_pnl = %format!("{:.2}", gross_pnl), net_pnl = %format!("{:.2}", net_pnl), cost = %format!("{:.2}", cost), "Transaction costs applied");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 5: P&L Accumulation Correct
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_pnl_accumulation_correct() -> Result<()> {
|
||
// Simulate 5 epochs of trading, verify cumulative P&L consistency
|
||
let initial_capital = 10_000.0;
|
||
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
|
||
|
||
let mut epoch_pnls = Vec::new();
|
||
|
||
// 5 epochs, 10 steps each
|
||
for epoch in 0..5 {
|
||
let base_price = 4500.0 + (epoch as f32 * 10.0); // Slight upward trend
|
||
|
||
for step in 0..10 {
|
||
let price = base_price + (step as f32 * 2.0);
|
||
let exposure = match step % 3 {
|
||
0 => ExposureLevel::LongSmall,
|
||
1 => ExposureLevel::Flat,
|
||
2 => ExposureLevel::ShortFull,
|
||
_ => ExposureLevel::Flat,
|
||
};
|
||
|
||
tracker.execute_action(
|
||
FactoredAction::new(exposure, OrderType::Market, Urgency::Normal),
|
||
price,
|
||
100.0,
|
||
);
|
||
}
|
||
|
||
// Record epoch P&L
|
||
let epoch_pnl = tracker.unrealized_pnl(base_price + 18.0);
|
||
epoch_pnls.push(epoch_pnl);
|
||
|
||
// Check for sudden jumps (>100% change)
|
||
if epoch > 0 {
|
||
let pnl_change = (epoch_pnl - epoch_pnls[epoch - 1]).abs();
|
||
let pnl_change_pct = if epoch_pnls[epoch - 1].abs() > 0.01 {
|
||
(pnl_change / epoch_pnls[epoch - 1].abs()) * 100.0
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
assert!(
|
||
pnl_change_pct < 100.0,
|
||
"Sudden P&L jump at epoch {}: {:.2}% (expected <100%)",
|
||
epoch,
|
||
pnl_change_pct
|
||
);
|
||
}
|
||
}
|
||
|
||
info!(epoch_count = epoch_pnls.len(), "P&L accumulation consistent across epochs (no sudden jumps >100%)");
|
||
info!(?epoch_pnls, "Epoch P&Ls");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 6: Long Position P&L Calculation
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_long_position_pnl_calculation() -> Result<()> {
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
||
|
||
// Open long position at $4500
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
|
||
4500.0,
|
||
100.0,
|
||
);
|
||
|
||
// Test 1: Price rises to $4600 (+$100 per contract)
|
||
let pnl_up = tracker.unrealized_pnl(4600.0);
|
||
let expected_up = 100.0 * (4600.0 - 4500.0); // +$10,000
|
||
assert!(
|
||
(pnl_up - expected_up).abs() < 1.0,
|
||
"Long position P&L (up): {:.2}, expected {:.2}",
|
||
pnl_up,
|
||
expected_up
|
||
);
|
||
|
||
// Test 2: Price falls to $4400 (-$100 per contract)
|
||
let pnl_down = tracker.unrealized_pnl(4400.0);
|
||
let expected_down = 100.0 * (4400.0 - 4500.0); // -$10,000
|
||
assert!(
|
||
(pnl_down - expected_down).abs() < 1.0,
|
||
"Long position P&L (down): {:.2}, expected {:.2}",
|
||
pnl_down,
|
||
expected_down
|
||
);
|
||
|
||
info!(pnl_up = %format!("{:.2}", pnl_up), pnl_down = %format!("{:.2}", pnl_down), "Long position P&L");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 7: Short Position P&L Calculation
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_short_position_pnl_calculation() -> Result<()> {
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
||
|
||
// Open short position at $4500
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
|
||
4500.0,
|
||
100.0,
|
||
);
|
||
|
||
// Test 1: Price falls to $4400 (+$100 profit per contract for short)
|
||
let pnl_down = tracker.unrealized_pnl(4400.0);
|
||
let expected_down = -100.0 * (4400.0 - 4500.0); // +$10,000
|
||
assert!(
|
||
(pnl_down - expected_down).abs() < 1.0,
|
||
"Short position P&L (down): {:.2}, expected {:.2}",
|
||
pnl_down,
|
||
expected_down
|
||
);
|
||
|
||
// Test 2: Price rises to $4600 (-$100 loss per contract for short)
|
||
let pnl_up = tracker.unrealized_pnl(4600.0);
|
||
let expected_up = -100.0 * (4600.0 - 4500.0); // -$10,000
|
||
assert!(
|
||
(pnl_up - expected_up).abs() < 1.0,
|
||
"Short position P&L (up): {:.2}, expected {:.2}",
|
||
pnl_up,
|
||
expected_up
|
||
);
|
||
|
||
info!(pnl_down = %format!("{:.2}", pnl_down), pnl_up = %format!("{:.2}", pnl_up), "Short position P&L");
|
||
|
||
Ok(())
|
||
}
|
||
|
||
// ============================================================================
|
||
// Test 8: P&L Explosion Detector (Regression Prevention)
|
||
// ============================================================================
|
||
|
||
#[test]
|
||
fn test_pnl_explosion_detector() -> Result<()> {
|
||
// This test specifically targets the Wave 16M $640 trillion bug
|
||
// Root cause: Preprocessed z-scores (-3 to +3) used as prices
|
||
|
||
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 1.0);
|
||
|
||
// Simulate bug scenario: z-score used as price
|
||
let preprocessed_z_score = -2.5; // This should NEVER be a price
|
||
|
||
tracker.execute_action(
|
||
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
|
||
preprocessed_z_score,
|
||
100.0,
|
||
);
|
||
|
||
let pnl = tracker.unrealized_pnl(preprocessed_z_score);
|
||
|
||
// BUG DETECTOR: If P&L > $1 billion, preprocessed prices are leaking
|
||
if pnl.abs() > 1_000_000_000.0 {
|
||
panic!(
|
||
"❌ CATASTROPHIC P&L EXPLOSION DETECTED: ${:.2e}\n\
|
||
This indicates preprocessed prices (z-scores) are being used in P&L calculations.\n\
|
||
Root cause: Feature extraction using normalized prices instead of raw prices.",
|
||
pnl
|
||
);
|
||
}
|
||
|
||
warn!("Bug detector active: Testing preprocessed price leak");
|
||
warn!("If this test fails, preprocessed prices are contaminating P&L");
|
||
|
||
Ok(())
|
||
}
|