Files
foxhunt/ml/tests/pnl_realism_tests.rs
jgrusewski f5947c2b22 Wave 16S-V11: Bug #8 fix + P2-A/B implementation
Bug #8 (CRITICAL): Fixed action selection frequency catastrophe
- Root cause: execute_action called during training (522,713 orders/epoch)
- Fix: Removed execute_action from experience collection loop (line 928-936)
- Impact: 522,713 → 0 orders/epoch (100% reduction)
- Transaction costs: $338K → $0 (eliminated)
- Test suite: ml/tests/action_selection_frequency_test.rs (3/3 passing)

P2-A: Configurable Initial Capital
- CLI argument: --initial-capital (default: $100K, min: $1K)
- Files modified: trainers/dqn.rs, train_dqn.rs, hyperopt adapter
- Test suite: ml/tests/configurable_capital_test.rs (8/8 passing)
- Supports: Small accounts ($10K), Standard ($100K), Institutional ($500K+)

P2-B: Cash Reserve Requirement
- CLI argument: --cash-reserve-percent (default: 0%, range: 0-100%)
- Reserve enforcement: BUY trades only (SELL always allowed)
- Dynamic reserve adjusts with portfolio value
- Files modified: portfolio_tracker.rs (70 lines), trainers/dqn.rs, train_dqn.rs
- Test suite: ml/tests/cash_reserve_requirement_test.rs (10/10 passing)

Test Status: 21/21 core tests passing (P2-C deferred due to API mismatch)

Wave 16S-V11 Agents:
- Agent #1: Bug #8 investigation (transaction cost analysis)
- Agent #2: P2-A implementation (configurable capital)
- Agent #3: P2-B implementation + test fix (cash reserve)
- Agent #4: Integration validation (certification report)
2025-11-12 23:05:51 +01:00

423 lines
13 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.
//! 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;
// ============================================================================
// 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::Long100,
1 => ExposureLevel::Flat,
2 => ExposureLevel::Short50,
_ => 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());
println!(
"✓ P&L within realistic range: {:.2} (expected <$10,000)",
final_pnl
);
// 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::Long50
} 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
);
println!(
"✓ Return percentage reasonable: {:.2}% (expected ±10%)",
return_pct
);
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::Long100, 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
);
println!(
"✓ P&L matches position changes: {:.2} (expected {:.2})",
actual_pnl, expected_pnl
);
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::Long100, 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;
println!(
"✓ Transaction costs applied: gross={:.2}, net={:.2}, cost={:.2}",
gross_pnl, net_pnl, cost
);
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::Long50,
1 => ExposureLevel::Flat,
2 => ExposureLevel::Short50,
_ => 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
);
}
}
println!(
"✓ P&L accumulation consistent across {} epochs (no sudden jumps >100%)",
epoch_pnls.len()
);
println!(" Epoch P&Ls: {:?}", epoch_pnls);
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::Long100, 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
);
println!(
"✓ Long position P&L: up={:.2}, down={:.2}",
pnl_up, pnl_down
);
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::Short100, 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
);
println!(
"✓ Short position P&L: down={:.2}, up={:.2}",
pnl_down, pnl_up
);
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::Long100, 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
);
}
println!("⚠️ Bug detector active: Testing preprocessed price leak");
println!("⚠️ If this test fails, preprocessed prices are contaminating P&L");
Ok(())
}