Files
foxhunt/ml/tests/ppo_portfolio_tracker_tests.rs
jgrusewski c645e6222d Wave 11: Rainbow DQN integration + 23/23 tests passing
CRITICAL FINDINGS from 3-trial validation:
- 85,120 gradient clipping warnings (81.6% of logs) - REGRESSION
- Rainbow features DISABLED: use_dueling=false, use_distributional=false, use_noisy_nets=false
- Negative Q-values confirmed: HOLD -1000 to -3250
- Performance: Sharpe 0.29 (target 0.77)

Changes:
- Fixed N-Step compilation (7/7 tests passing)
- Fixed Distributional compilation (6/6 tests passing)
- Fixed Dueling CUDA errors (10/10 tests passing)
- Added TDD validation for state_dim=225
- Total: 23/23 Wave 11 tests passing (100%)

Issues requiring investigation:
1. Why are Dueling/Distributional/Noisy disabled in hyperopt?
2. Why gradient explosion despite previous fixes?
3. Test coverage gaps - unit tests pass but integration fails

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-18 13:53:59 +01:00

223 lines
9.5 KiB
Rust

//! PPO Portfolio Tracker Tests (TDD)
//!
//! These tests validate the PortfolioTracker integration for PPO.
//! Tests written FIRST (TDD Red Phase) before implementation.
use ml::ppo::portfolio_tracker::PortfolioTracker;
#[test]
fn test_portfolio_tracker_initialization() {
// Test initial state (cash, position, pnl)
let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
assert_eq!(tracker.cash_balance(), 10_000.0, "Initial cash should equal initial capital");
assert_eq!(tracker.current_position(), 0.0, "Initial position should be 0 (flat)");
assert_eq!(tracker.realized_pnl(), 0.0, "Initial realized P&L should be 0");
assert_eq!(tracker.unrealized_pnl(100.0), 0.0, "Initial unrealized P&L should be 0");
assert_eq!(tracker.total_value(100.0), 10_000.0, "Initial portfolio value should equal cash");
}
#[test]
fn test_portfolio_tracker_buy_action() {
// Test BUY action updates position and cash
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Execute PPO-style action: action index 0 = BUY
tracker.execute_ppo_action(0, 100.0, 10.0);
assert_eq!(tracker.current_position(), 10.0, "Position should be 10 contracts after BUY");
assert_eq!(tracker.cash_balance(), 9_000.0, "Cash should decrease by position cost (10 * 100)");
assert_eq!(tracker.average_entry_price(), 100.0, "Entry price should be recorded");
}
#[test]
fn test_portfolio_tracker_sell_action() {
// Test SELL action updates position and cash
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Execute PPO-style action: action index 1 = SELL
tracker.execute_ppo_action(1, 100.0, 10.0);
assert_eq!(tracker.current_position(), -10.0, "Position should be -10 contracts after SELL (short)");
assert_eq!(tracker.cash_balance(), 11_000.0, "Cash should increase by short proceeds (10 * 100)");
assert_eq!(tracker.average_entry_price(), 100.0, "Entry price should be recorded");
}
#[test]
fn test_portfolio_tracker_cash_reserve() {
// Test cash reserve enforcement
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 20.0); // 20% cash reserve
// Try to buy with insufficient cash (reserve enforcement)
// At price 100, max affordable = (10000 - 2000) / 100 = 80 contracts
// Request 100 contracts -> should be reduced to 80
tracker.execute_ppo_action(0, 100.0, 100.0);
let cash = tracker.cash_balance();
let portfolio_value = tracker.total_value(100.0);
let reserve_required = portfolio_value * 0.20;
assert!(
cash >= reserve_required,
"Cash reserve should be enforced: cash=${:.2}, reserve=${:.2}",
cash, reserve_required
);
}
#[test]
fn test_portfolio_tracker_pnl_calculation() {
// Test P&L calculation accuracy
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Open long position: Buy 10 contracts at $100
tracker.execute_ppo_action(0, 100.0, 10.0);
assert_eq!(tracker.cash_balance(), 9_000.0, "Cash after buy");
// Price rises to $110 -> +$100 unrealized profit
let unrealized = tracker.unrealized_pnl(110.0);
assert_eq!(unrealized, 100.0, "Unrealized P&L should be +$100 (10 contracts * $10 gain)");
// Total portfolio value = cash + position_value
let total_value = tracker.total_value(110.0);
assert_eq!(total_value, 10_100.0, "Portfolio value = 9000 (cash) + 1100 (position value)");
// Close position: Sell 10 contracts at $110
tracker.execute_ppo_action(1, 110.0, 10.0);
// After closing, realized P&L should be +$100
assert_eq!(tracker.current_position(), 0.0, "Position should be closed");
assert_eq!(tracker.realized_pnl(), 100.0, "Realized P&L should be +$100");
}
#[test]
fn test_portfolio_tracker_hold_action() {
// Test HOLD action (no change)
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
let initial_cash = tracker.cash_balance();
let initial_position = tracker.current_position();
// Execute PPO-style action: action index 2 = HOLD
tracker.execute_ppo_action(2, 100.0, 10.0);
assert_eq!(tracker.cash_balance(), initial_cash, "Cash should not change after HOLD");
assert_eq!(tracker.current_position(), initial_position, "Position should not change after HOLD");
}
#[test]
fn test_portfolio_tracker_reset() {
// Test reset functionality
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Execute trades
tracker.execute_ppo_action(0, 100.0, 10.0);
// Reset
tracker.reset();
assert_eq!(tracker.cash_balance(), 10_000.0, "Cash should reset to initial capital");
assert_eq!(tracker.current_position(), 0.0, "Position should reset to 0");
assert_eq!(tracker.average_entry_price(), 0.0, "Entry price should reset to 0");
}
#[test]
fn test_portfolio_tracker_short_position_pnl() {
// Test short position P&L calculation
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Open short position: Sell 10 contracts at $100
tracker.execute_ppo_action(1, 100.0, 10.0);
assert_eq!(tracker.current_position(), -10.0, "Position should be -10 (short)");
assert_eq!(tracker.cash_balance(), 11_000.0, "Cash after short sale");
// Price falls to $90 -> +$100 unrealized profit (profitable for short)
let unrealized = tracker.unrealized_pnl(90.0);
assert_eq!(unrealized, 100.0, "Unrealized P&L should be +$100 (10 contracts * $10 gain from price drop)");
// Total portfolio value = cash + position_value (position_value is negative for shorts)
let total_value = tracker.total_value(90.0);
assert_eq!(total_value, 10_100.0, "Portfolio value = 11000 (cash) - 900 (short liability)");
}
#[test]
fn test_portfolio_tracker_get_portfolio_features() {
// Test portfolio features retrieval
let tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
let features = tracker.get_portfolio_features(100.0);
// Features: [normalized_value, normalized_position, spread]
assert_eq!(features[0], 1.0, "Normalized portfolio value should be 1.0 (initial capital)");
assert_eq!(features[1], 0.0, "Normalized position should be 0.0 (no position)");
assert_eq!(features[2], 0.0001, "Spread should be 0.0001");
}
// ========== WAVE 2 AGENT 4: POSITION REVERSAL TESTS (TDD RED PHASE) ==========
#[test]
fn test_position_reversal_long_to_short() {
// Test position reversal from long to short
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Step 1: Open long position: Buy 20 contracts at $100
tracker.execute_ppo_action(0, 100.0, 20.0);
assert_eq!(tracker.current_position(), 20.0, "Should have long position of 20 contracts");
assert_eq!(tracker.cash_balance(), 8_000.0, "Cash = 10000 - (20 * 100)");
// Step 2: Reversal - Sell 40 contracts at $110 (close 20 long + open 20 short)
// Phase 1: Close 20 long at $110 -> cash += 20 * 110 = +2200
// Phase 2: Open 20 short at $110 -> cash += 20 * 110 = +2200
tracker.execute_ppo_action(1, 110.0, 40.0);
// Expected: Position = -20 (short), Cash = 8000 + 2200 + 2200 = 12400
assert_eq!(tracker.current_position(), -20.0, "Should have short position of -20 contracts after reversal");
assert_eq!(tracker.cash_balance(), 12_400.0, "Cash = 8000 + 2200 (close long) + 2200 (open short)");
}
#[test]
fn test_position_reversal_short_to_long() {
// Test position reversal from short to long
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 0.0);
// Step 1: Open short position: Sell 20 contracts at $100
tracker.execute_ppo_action(1, 100.0, 20.0);
assert_eq!(tracker.current_position(), -20.0, "Should have short position of -20 contracts");
assert_eq!(tracker.cash_balance(), 12_000.0, "Cash = 10000 + (20 * 100)");
// Step 2: Reversal - Buy 40 contracts at $90 (close 20 short + open 20 long)
// Phase 1: Close 20 short at $90 -> cash -= 20 * 90 = -1800
// Phase 2: Open 20 long at $90 -> cash -= 20 * 90 = -1800
tracker.execute_ppo_action(0, 90.0, 40.0);
// Expected: Position = 20 (long), Cash = 12000 - 1800 - 1800 = 8400
assert_eq!(tracker.current_position(), 20.0, "Should have long position of 20 contracts after reversal");
assert_eq!(tracker.cash_balance(), 8_400.0, "Cash = 12000 - 1800 (close short) - 1800 (open long)");
}
#[test]
fn test_partial_reversal_insufficient_cash() {
// Test partial reversal when insufficient cash for full flip
let mut tracker = PortfolioTracker::new(10_000.0, 0.0001, 20.0); // 20% cash reserve
// Step 1: Open long position: Buy 50 contracts at $100
// Max affordable = (10000 - 2000 reserve) / 100 = 80 contracts
tracker.execute_ppo_action(0, 100.0, 50.0);
assert_eq!(tracker.current_position(), 50.0, "Should have long position of 50 contracts");
// Step 2: Attempt reversal - Sell 100 contracts at $110 (close 50 long + open 50 short)
// Phase 1: Close 50 long at $110 -> cash += 50 * 110 = +5500
// Phase 2: Try to open 50 short at $110 -> may be partial due to cash reserve
tracker.execute_ppo_action(1, 110.0, 100.0);
// After reversal:
// - Position should be negative (short) but may be less than -50 due to cash constraint
// - Cash reserve should still be enforced
let final_position = tracker.current_position();
let final_cash = tracker.cash_balance();
let portfolio_value = tracker.total_value(110.0);
let reserve_required = portfolio_value * 0.20;
assert!(final_position < 0.0, "Should have short position after reversal (negative)");
assert!(final_cash >= reserve_required, "Cash reserve should be enforced: cash=${:.2}, reserve=${:.2}", final_cash, reserve_required);
}