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

477 lines
16 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,
)]
//! 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(())
}