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

383 lines
14 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,
)]
// Test-Driven Development: P&L Calculation Bug Fix
//
// **Problem**: P&L values are 10,000× - 100,000× too large
// - Epoch 10: -$578,040,448 loss (-5,780,405% return)
// - Expected: ±$5,000 range for validation data
//
// **Root Cause**: max_position = 1.0 contract instead of fractional position
// - ES futures price: ~$5,800
// - ES contract multiplier: 50×
// - 1.0 contract = $5,800 × 50 = $290,000 notional
// - For $10K capital, this is 29× leverage (CATASTROPHIC)
//
// **Solution**: Scale max_position to match capital
// - Desired exposure: 1.0× capital ($10,000 notional)
// - Fractional contracts: $10,000 / ($5,800 × 50) = 0.0345 contracts
// - General formula: max_position = initial_capital / (price × contract_multiplier)
use ml::dqn::action_space::{ExposureLevel, FactoredAction, OrderType, Urgency};
use ml::dqn::portfolio_tracker::PortfolioTracker;
/// Test 1: Verify P&L calculation with realistic position sizing
#[test]
fn test_pnl_calculation_realistic_sizing() {
// GIVEN: $10,000 initial capital, ES futures at $5,800
// NOTE: PortfolioTracker treats position_size as notional units (not contracts)
// So we scale max_position to represent desired notional exposure
let initial_capital = 10_000.0;
let price = 5_800.0;
// For 1× capital exposure:
// max_position × price = initial_capital
// max_position = initial_capital / price = 10000 / 5800 = 1.724 units
let max_position = initial_capital / price;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Long100 (full long position)
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be ~1.724 units (NOT 1.0 or 0.0345)
let actual_position = tracker.current_position();
assert!(
(actual_position - max_position).abs() < 0.0001,
"Position size should be ~1.724 units, got {}",
actual_position
);
// Portfolio value should still be ~$10,000 (minus fees)
let portfolio_value = tracker.total_value(price);
assert!(
(portfolio_value - initial_capital).abs() < 100.0,
"Portfolio value should be ~$10,000, got ${}",
portfolio_value
);
}
/// Test 2: Verify P&L stays within ±$5K range for 10% price move
#[test]
fn test_pnl_range_realistic() {
// GIVEN: $10,000 initial capital, ES futures at $5,800
// NOTE: PortfolioTracker doesn't model contract multipliers - it treats position_size
// as notional value directly. So we need to scale max_position accordingly.
let initial_capital = 10_000.0;
let price_entry = 5_800.0;
// max_position should represent the number of "notional units" we want to hold
// For 1× capital exposure, we want max_position such that:
// position_size × price = initial_capital
// So max_position = initial_capital / price
let max_position = initial_capital / price_entry;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Go long at $5,800
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price_entry, max_position);
// AND: Price increases 10% to $6,380
let price_exit = price_entry * 1.10;
let pnl = tracker.unrealized_pnl(price_exit);
// THEN: P&L should be ~$1,000 (10% of $10K), NOT $290,000
assert!(
pnl.abs() < 5_000.0,
"P&L should be within ±$5K for 10% move, got ${:.2}",
pnl
);
// Expected P&L: max_position × (price_exit - price_entry)
// = (10000/5800) × (6380 - 5800) = 1.724 × 580 = ~$1,000
let expected_pnl = max_position * (price_exit - price_entry);
assert!(
(pnl - expected_pnl).abs() < 100.0,
"P&L should be ~${:.2}, got ${:.2}",
expected_pnl,
pnl
);
}
/// Test 3: Verify old bug (max_position = 1.0) produces WRONG P&L
#[test]
fn test_old_bug_wrong_pnl() {
// GIVEN: $10,000 initial capital, ES futures at $5,800
let initial_capital = 10_000.0;
let price_entry = 5_800.0;
// OLD BUG: max_position = 1.0 (WRONG - too small)
// This gives 1.0 units × $5,800 = $5,800 notional (58% capital exposure)
let max_position_wrong = 1.0;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Go long at $5,800 with 1.0 unit
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price_entry, max_position_wrong);
// AND: Price increases 10% to $6,380
let price_exit = price_entry * 1.10;
let pnl = tracker.unrealized_pnl(price_exit);
// THEN: P&L is WRONG ($580, not $1,000)
// 1.0 units × (6,380 - 5,800) = $580 (should be $1,000 for 100% capital exposure)
let wrong_pnl = max_position_wrong * (price_exit - price_entry);
assert!(
(pnl - wrong_pnl).abs() < 100.0,
"Old bug should produce ~${:.2}, got ${:.2}",
wrong_pnl,
pnl
);
// Expected with correct sizing: (10000/5800) × 580 = ~$1,000
let correct_max_position = initial_capital / price_entry;
let correct_pnl = correct_max_position * (price_exit - price_entry);
assert!(
pnl < correct_pnl * 0.9,
"Old bug P&L (${:.2}) should be <90% of correct P&L (${:.2})",
pnl,
correct_pnl
);
}
/// Test 4: Verify return percentage calculation
#[test]
fn test_return_percentage_scaling() {
// GIVEN: $10,000 initial capital, 10% return
let initial_capital = 10_000.0;
let final_value = 11_000.0;
// WHEN: Calculate return percentage
let pnl = final_value - initial_capital;
let return_pct = (pnl / initial_capital) * 100.0f32;
// THEN: Should be 10.0%
assert!(
(return_pct - 10.0f32).abs() < 0.01,
"Return should be 10%, got {:.2}%",
return_pct
);
}
/// Test 5: Verify Short100 position sizing (symmetric to Long100)
#[test]
fn test_short_position_sizing() {
// GIVEN: $10,000 initial capital, ES futures at $5,800
let initial_capital = 10_000.0;
let price = 5_800.0;
let max_position = initial_capital / price;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Short100 (full short position)
let action = FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be -1.724 units (negative for short)
let actual_position = tracker.current_position();
assert!(
(actual_position + max_position).abs() < 0.0001,
"Short position should be ~-1.724 units, got {}",
actual_position
);
// Portfolio value should still be ~$10,000 (cash increased, but position liability offsets)
let portfolio_value = tracker.total_value(price);
assert!(
(portfolio_value - initial_capital).abs() < 100.0,
"Portfolio value should be ~$10,000 after short, got ${}",
portfolio_value
);
}
/// Test 6: Verify $100K initial capital (matches DQN trainer)
#[test]
fn test_trainer_initial_capital() {
// GIVEN: $100K initial capital (same as DQN trainer line 535)
let initial_capital = 100_000.0;
let price = 5_800.0;
let max_position = initial_capital / price; // Should be ~17.24 units
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// WHEN: Execute Long100 at $5,800
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, price, max_position);
// THEN: Position should be ~17.24 units
let actual_position = tracker.current_position();
assert!(
(actual_position - max_position).abs() < 0.01,
"Position size should be ~17.24 units, got {}",
actual_position
);
// Portfolio value should still be ~$100K
let portfolio_value = tracker.total_value(price);
assert!(
(portfolio_value - initial_capital).abs() < 1000.0,
"Portfolio value should be ~$100K, got ${}",
portfolio_value
);
// 10% price move should produce ~$10K P&L (10% of capital)
let price_exit = price * 1.10;
let pnl = tracker.unrealized_pnl(price_exit);
let expected_pnl = 10_000.0; // 10% of $100K
assert!(
(pnl - expected_pnl).abs() < 1000.0,
"P&L should be ~$10K for 10% move, got ${:.2}",
pnl
);
}
/// Test 7: Verify last_price is updated after execute_action
#[test]
fn test_last_price_updated() {
// GIVEN: Fresh portfolio tracker
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// WHEN: Execute action at $5,800
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
let price = 5_800.0;
let max_position = 100_000.0 / price;
tracker.execute_action(action, price, max_position);
// THEN: unrealized_pnl_cached() should work (not NaN)
let pnl = tracker.unrealized_pnl_cached();
assert!(
!pnl.is_nan(),
"P&L should not be NaN after execute_action, got {}",
pnl
);
// P&L should be 0.0 (no price change yet)
assert!(
pnl.abs() < 0.01,
"P&L should be ~0.0 (no price change), got ${:.2}",
pnl
);
}
/// Test 8: Integration test - Full training epoch P&L range
#[test]
fn test_epoch_pnl_range() {
// GIVEN: Realistic ES futures price range ($5,700 - $5,900)
let initial_capital = 10_000.0;
let price_min = 5_700.0;
let price_max = 5_900.0;
let price_avg = (price_min + price_max) / 2.0;
// Calculate max_position at average price (dynamic sizing)
// This will be recalculated per-trade in real implementation
let max_position = initial_capital / price_avg;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// Simulate 10 trades across price range
let actions = vec![
FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::LongSmall, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::ShortFull, OrderType::Market, Urgency::Normal),
FactoredAction::new(ExposureLevel::Flat, OrderType::Market, Urgency::Normal),
];
let prices = vec![
5700.0, 5720.0, 5750.0, 5780.0, 5800.0,
5820.0, 5850.0, 5870.0, 5890.0, 5900.0,
];
// Execute all trades (in real implementation, max_position would be recalculated per trade)
for (action, &price) in actions.iter().zip(prices.iter()) {
tracker.execute_action(*action, price, max_position);
}
// THEN: Final P&L should be within ±$10K (realistic for validation data)
let final_pnl = tracker.unrealized_pnl(prices[prices.len() - 1]);
assert!(
final_pnl.abs() < 10_000.0,
"Epoch P&L should be within ±$10K, got ${:.2}",
final_pnl
);
}