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

246 lines
8.7 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,
)]
//! Unit tests for PortfolioTracker epoch reset functionality
//!
//! These tests validate that portfolio state is correctly reset between epochs
//! to prevent P&L contamination across training epochs.
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::action_space::{FactoredAction, ExposureLevel, OrderType, Urgency};
use ml::dqn::agent::TradingAction;
#[test]
fn test_portfolio_tracker_resets_between_epochs() {
// Given: PortfolioTracker with modified state after some trading activity
let initial_capital = 100_000.0;
let avg_spread = 0.0001;
let mut tracker = PortfolioTracker::new(initial_capital, avg_spread, 1.0);
// Simulate epoch 1 trading activity
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
// Verify state changed
assert_eq!(tracker.current_position(), 10.0);
assert_eq!(tracker.cash_balance(), 99_000.0); // 100_000 - (10 * 100)
assert_eq!(tracker.average_entry_price(), 100.0);
// When: Reset is called (simulating epoch boundary)
tracker.reset();
// Then: State should be back to initial values
assert_eq!(tracker.cash_balance(), initial_capital, "Cash should reset to initial capital");
assert_eq!(tracker.current_position(), 0.0, "Position should reset to 0 (flat)");
assert_eq!(tracker.average_entry_price(), 0.0, "Entry price should reset to 0");
assert_eq!(tracker.realized_pnl(), 0.0, "Realized P&L should reset to 0");
assert_eq!(tracker.unrealized_pnl(100.0), 0.0, "Unrealized P&L should reset to 0");
}
#[test]
fn test_epoch_reset_prevents_pnl_contamination() {
// Given: PortfolioTracker with trading activity in epoch 1
let initial_capital = 100_000.0;
let mut tracker = PortfolioTracker::new(initial_capital, 0.0001, 1.0);
// Epoch 1: Buy at 100, sell at 110 (profit = $100)
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
tracker.execute_legacy_action(TradingAction::Sell, 110.0, 10.0);
let epoch1_pnl = tracker.realized_pnl();
assert_eq!(epoch1_pnl, 100.0, "Epoch 1 should have $100 profit");
assert_eq!(tracker.cash_balance(), 100_100.0, "Cash should be $100,100 after profit");
// When: Reset for epoch 2
tracker.reset();
// Then: Epoch 2 should start from clean slate
let epoch2_initial_pnl = tracker.realized_pnl();
assert_eq!(epoch2_initial_pnl, 0.0, "Epoch 2 P&L should start at $0, not contaminated from epoch 1");
// Epoch 2: Independent trading activity
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
let epoch2_unrealized = tracker.unrealized_pnl(95.0); // Price drops to 95
// Unrealized P&L should be -$50 (10 contracts × -$5), not affected by epoch 1
assert_eq!(epoch2_unrealized, -50.0, "Epoch 2 unrealized P&L should be independent");
}
#[test]
fn test_reset_clears_all_state_fields() {
// Given: PortfolioTracker with complex state
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Create complex state
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
tracker.execute_legacy_action(TradingAction::Sell, 110.0, 10.0);
tracker.execute_legacy_action(TradingAction::Sell, 105.0, 5.0);
// Verify state is non-trivial
assert_ne!(tracker.cash_balance(), 100_000.0);
assert_ne!(tracker.current_position(), 0.0);
// When: Reset
tracker.reset();
// Then: All fields should be reset
assert_eq!(tracker.cash_balance(), 100_000.0);
assert_eq!(tracker.current_position(), 0.0);
assert_eq!(tracker.average_entry_price(), 0.0);
// Verify parameter-less methods also return 0
assert_eq!(tracker.total_value_cached(), 100_000.0);
assert_eq!(tracker.unrealized_pnl_cached(), 0.0);
}
#[test]
fn test_reset_with_factored_actions() {
// Given: PortfolioTracker using new factored action space
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Execute factored action (Long100 = full long exposure)
let action = FactoredAction::new(ExposureLevel::LongFull, OrderType::Market, Urgency::Normal);
tracker.execute_action(action, 100.0, 100.0); // max_position = 100 contracts
// Verify position opened
assert_eq!(tracker.current_position(), 100.0);
assert_ne!(tracker.cash_balance(), 100_000.0);
// When: Reset
tracker.reset();
// Then: State should be clean for next epoch
assert_eq!(tracker.cash_balance(), 100_000.0);
assert_eq!(tracker.current_position(), 0.0);
assert_eq!(tracker.average_entry_price(), 0.0);
}
#[test]
fn test_reset_preserves_initial_capital_and_spread() {
// Given: PortfolioTracker with specific initial values
let initial_capital = 250_000.0;
let avg_spread = 0.0005;
let mut tracker = PortfolioTracker::new(initial_capital, avg_spread, 1.0);
// Modify state
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 50.0);
// When: Reset
tracker.reset();
// Then: Initial capital and spread should be preserved
assert_eq!(tracker.cash_balance(), initial_capital, "Initial capital should be preserved");
// Verify spread is still correct via features
let features = tracker.get_portfolio_features(100.0);
assert_eq!(features[2], avg_spread, "Spread should be preserved");
}
#[test]
fn test_multiple_resets_idempotent() {
// Given: PortfolioTracker
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Modify state
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
// When: Multiple resets
tracker.reset();
tracker.reset();
tracker.reset();
// Then: State should be same after each reset (idempotent)
assert_eq!(tracker.cash_balance(), 100_000.0);
assert_eq!(tracker.current_position(), 0.0);
assert_eq!(tracker.average_entry_price(), 0.0);
}
#[test]
fn test_reset_allows_fresh_trading_in_next_epoch() {
// Given: PortfolioTracker after epoch 1
let mut tracker = PortfolioTracker::new(100_000.0, 0.0001, 1.0);
// Epoch 1: End with long position
tracker.execute_legacy_action(TradingAction::Buy, 100.0, 10.0);
// When: Reset for epoch 2
tracker.reset();
// Then: Can open new position without interference from epoch 1
tracker.execute_legacy_action(TradingAction::Sell, 100.0, 10.0); // Short position
assert_eq!(tracker.current_position(), -10.0, "Should be able to open short position");
assert_eq!(tracker.average_entry_price(), 100.0, "Entry price should be for new position");
assert_eq!(tracker.cash_balance(), 101_000.0, "Cash should reflect short opening (100k + 10*100)");
}