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)
172 lines
6.5 KiB
Rust
172 lines
6.5 KiB
Rust
//! 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::Long100, 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)");
|
||
}
|