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)
423 lines
13 KiB
Rust
423 lines
13 KiB
Rust
//! 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(())
|
||
}
|