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>
383 lines
14 KiB
Rust
383 lines
14 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,
|
||
)]
|
||
// 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
|
||
);
|
||
}
|