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

403 lines
12 KiB
Rust

//! PPO Risk Management Integration Tests (TDD Red Phase)
//!
//! Tests for integrating PortfolioTracker, CircuitBreaker, and RewardNormalizer
//! into the PPO training pipeline for Wave 1 of the DQN-PPO alignment project.
//!
//! **Expected Status**: These tests should FAIL until implementation is complete.
use anyhow::Result;
use candle_core::Device;
use ml::dqn::circuit_breaker::{CircuitBreaker, CircuitBreakerConfig};
use ml::dqn::portfolio_tracker::PortfolioTracker;
use ml::dqn::reward::RewardNormalizer;
use ml::dqn::TradingAction;
use ml::ppo::ppo::{PPOConfig, WorkingPPO};
use ml::ppo::trajectories::{Trajectory, TrajectoryBatch, TrajectoryStep};
use std::time::Duration;
/// Test 1: PPO PortfolioTracker Integration
///
/// **Goal**: Verify PortfolioTracker is properly integrated into PPO training loop
/// **Expected Behavior**:
/// - PPO struct contains PortfolioTracker field
/// - PortfolioTracker updates after each action
/// - Portfolio state (cash, position, P&L) is tracked correctly
#[test]
fn test_ppo_portfolio_tracker_integration() -> Result<()> {
// Create PPO with PortfolioTracker
let config = PPOConfig {
state_dim: 64,
num_actions: 3,
policy_hidden_dims: vec![32, 16],
value_hidden_dims: vec![32, 16],
policy_learning_rate: 1e-4,
value_learning_rate: 1e-3,
..Default::default()
};
let mut ppo = WorkingPPO::new(config)?;
// Access PortfolioTracker (this will fail until implementation)
let initial_cash = ppo.portfolio_tracker.cash_balance();
assert_eq!(
initial_cash, 10_000.0,
"Initial cash should be 10,000"
);
// Simulate action execution
let state = vec![0.5; 64];
let (action, _value) = ppo.act(&state)?;
// Verify PortfolioTracker updated
let current_position = ppo.portfolio_tracker.current_position();
match action {
TradingAction::Buy => {
assert!(
current_position > 0.0,
"Buy action should create long position"
);
},
TradingAction::Sell => {
assert!(
current_position < 0.0,
"Sell action should create short position"
);
},
TradingAction::Hold => {
assert_eq!(
current_position, 0.0,
"Hold action should keep neutral position"
);
},
}
Ok(())
}
/// Test 2: PPO CircuitBreaker Integration
///
/// **Goal**: Verify CircuitBreaker stops training on consecutive failures
/// **Expected Behavior**:
/// - CircuitBreaker opens after N consecutive failures (default: 5)
/// - Training loop stops when circuit is open
/// - CircuitBreaker state is logged
#[test]
fn test_ppo_circuit_breaker_integration() -> Result<()> {
// Create CircuitBreaker with aggressive threshold for testing
let circuit_config = CircuitBreakerConfig {
failure_threshold: 3, // Open after 3 failures
success_threshold: 2,
timeout_duration: Duration::from_secs(10),
half_open_max_calls: 1,
};
let circuit_breaker = CircuitBreaker::new(circuit_config);
// Simulate 3 consecutive failures
circuit_breaker.record_failure();
circuit_breaker.record_failure();
circuit_breaker.record_failure();
// Circuit should be open
let stats = circuit_breaker.stats();
assert_eq!(
stats.state,
ml::dqn::circuit_breaker::CircuitState::Open,
"Circuit should be open after 3 failures"
);
assert!(!circuit_breaker.allow_request(), "Circuit should block requests");
// Create PPO config with circuit breaker
let config = PPOConfig {
state_dim: 64,
num_actions: 3,
..Default::default()
};
let mut ppo = WorkingPPO::new(config)?;
// Verify PPO has circuit breaker field (will fail until implementation)
assert!(
ppo.circuit_breaker.is_some(),
"PPO should have CircuitBreaker field"
);
// Simulate training with open circuit
let state = vec![0.5; 64];
let mut trajectory = Trajectory::new();
for _ in 0..10 {
let (action, value) = ppo.act(&state)?;
let step = TrajectoryStep::new(state.clone(), action, -1.0, value, -0.1, false);
trajectory.add_step(step);
}
let mut batch = TrajectoryBatch::from_trajectories(
vec![trajectory],
vec![0.1; 10],
vec![0.2; 10],
);
// Training update should respect circuit breaker state
let result = ppo.update(&mut batch);
// If circuit is open, update should either:
// 1. Return an error indicating circuit is open
// 2. Skip the update and return zero loss
match result {
Err(e) => {
assert!(
e.to_string().contains("circuit") || e.to_string().contains("breaker"),
"Error should mention circuit breaker: {}",
e
);
},
Ok((policy_loss, value_loss)) => {
// If update proceeds, losses should be minimal (no actual update)
assert!(
policy_loss < 0.01 && value_loss < 0.01,
"Losses should be minimal when circuit is open"
);
},
}
Ok(())
}
/// Test 3: PPO Reward Normalization Integration
///
/// **Goal**: Verify RewardNormalizer is applied to rewards during training
/// **Expected Behavior**:
/// - Rewards are normalized to ~N(0,1) distribution
/// - Normalization statistics (mean, std) are tracked
/// - Normalized rewards are used in advantage calculation
#[test]
fn test_ppo_reward_normalization_integration() -> Result<()> {
// Create RewardNormalizer
let mut normalizer = RewardNormalizer::new();
// Feed rewards with non-zero mean and variance
let raw_rewards = vec![10.0, 20.0, 30.0, 40.0, 50.0];
for &reward in &raw_rewards {
normalizer.update(reward);
}
// Verify normalization statistics
let (mean, std) = normalizer.get_stats();
assert!(
(mean - 30.0).abs() < 0.1,
"Mean should be ~30.0, got {}",
mean
);
assert!(std > 0.0, "Std should be positive, got {}", std);
// Verify normalized rewards have ~N(0,1) distribution
let normalized_rewards: Vec<f64> = raw_rewards
.iter()
.map(|&r| normalizer.normalize(r))
.collect();
let norm_mean: f64 = normalized_rewards.iter().sum::<f64>() / normalized_rewards.len() as f64;
let norm_var: f64 = normalized_rewards
.iter()
.map(|r| (r - norm_mean).powi(2))
.sum::<f64>()
/ normalized_rewards.len() as f64;
let norm_std = norm_var.sqrt();
assert!(
norm_mean.abs() < 0.5,
"Normalized mean should be ~0, got {}",
norm_mean
);
assert!(
(norm_std - 1.0).abs() < 0.5,
"Normalized std should be ~1.0, got {}",
norm_std
);
// Create PPO config with reward normalization enabled
let config = PPOConfig {
state_dim: 64,
num_actions: 3,
..Default::default()
};
let mut ppo = WorkingPPO::new(config)?;
// Verify PPO has RewardNormalizer field (will fail until implementation)
assert!(
ppo.reward_normalizer.is_some(),
"PPO should have RewardNormalizer field"
);
// Simulate training with reward normalization
let state = vec![0.5; 64];
let mut trajectory = Trajectory::new();
for i in 0..10 {
let (action, value) = ppo.act(&state)?;
let reward = 10.0 + i as f32; // Non-normalized rewards (10.0 to 19.0)
let step = TrajectoryStep::new(state.clone(), action, -1.0, value, reward, false);
trajectory.add_step(step);
}
let mut batch = TrajectoryBatch::from_trajectories(
vec![trajectory],
vec![0.1; 10],
vec![0.2; 10],
);
// Update should normalize rewards internally
let (_policy_loss, _value_loss) = ppo.update(&mut batch)?;
// Verify normalizer statistics were updated
let (reward_mean, reward_std) = ppo.reward_normalizer.as_ref().unwrap().get_stats();
assert!(
reward_mean > 0.0,
"Reward mean should be positive after training"
);
assert!(
reward_std > 0.0,
"Reward std should be positive after training"
);
Ok(())
}
/// Test 4: PPO Transaction Cost Integration
///
/// **Goal**: Verify transaction costs are deducted from rewards
/// **Expected Behavior**:
/// - Buy/Sell actions incur transaction costs (0.05-0.15%)
/// - Hold actions incur no transaction costs
/// - Transaction costs reduce net rewards
#[test]
fn test_ppo_transaction_cost_integration() -> Result<()> {
// Create PPO config with transaction costs enabled
let config = PPOConfig {
state_dim: 64,
num_actions: 3,
policy_learning_rate: 1e-4,
value_learning_rate: 1e-3,
..Default::default()
};
let mut ppo = WorkingPPO::new(config)?;
// Access transaction cost configuration (will fail until implementation)
assert!(
ppo.transaction_cost_bps.is_some(),
"PPO should have transaction_cost_bps field"
);
let tx_cost_bps = ppo.transaction_cost_bps.unwrap();
assert!(
tx_cost_bps >= 0.05 && tx_cost_bps <= 0.15,
"Transaction cost should be 0.05-0.15%, got {}",
tx_cost_bps
);
// Simulate Buy action
let state = vec![0.5; 64];
let mut trajectory = Trajectory::new();
// Force Buy action and measure reward
let (_, value) = ppo.act(&state)?;
let buy_reward_before_cost = 1.0; // Hypothetical reward before costs
let buy_step = TrajectoryStep::new(
state.clone(),
TradingAction::Buy,
-1.0,
value,
buy_reward_before_cost,
false,
);
trajectory.add_step(buy_step);
// Force Hold action and measure reward
let hold_reward_before_cost = 0.0; // Hold gets no P&L
let hold_step = TrajectoryStep::new(
state.clone(),
TradingAction::Hold,
-1.0,
value,
hold_reward_before_cost,
false,
);
trajectory.add_step(hold_step);
let mut batch = TrajectoryBatch::from_trajectories(
vec![trajectory],
vec![0.1, 0.0],
vec![0.2, 0.0],
);
// Update applies transaction costs internally
let (_policy_loss, _value_loss) = ppo.update(&mut batch)?;
// Verify transaction costs were applied
// Buy action should have reward reduced by transaction cost
// Hold action should have no transaction cost
let buy_reward_after_cost = batch.rewards[0];
let hold_reward_after_cost = batch.rewards[1];
assert!(
buy_reward_after_cost < buy_reward_before_cost,
"Buy action reward should be reduced by transaction costs"
);
assert_eq!(
hold_reward_after_cost, hold_reward_before_cost,
"Hold action reward should not have transaction costs"
);
Ok(())
}
/// Test 5: PPO Position Limit Integration
///
/// **Goal**: Verify position limits are enforced during action execution
/// **Expected Behavior**:
/// - Position size is clamped to [-max_position, +max_position]
/// - Actions that would exceed limits are rejected or reduced
/// - Position limit violations are logged
#[test]
fn test_ppo_position_limit_integration() -> Result<()> {
// Create PPO config with strict position limits
let config = PPOConfig {
state_dim: 64,
num_actions: 3,
policy_learning_rate: 1e-4,
value_learning_rate: 1e-3,
..Default::default()
};
let mut ppo = WorkingPPO::new(config)?;
// Access position limit configuration (will fail until implementation)
assert!(
ppo.max_position_absolute.is_some(),
"PPO should have max_position_absolute field"
);
let max_position = ppo.max_position_absolute.unwrap();
assert!(
max_position > 0.0,
"Max position should be positive, got {}",
max_position
);
// Simulate multiple Buy actions to test position limit
let state = vec![0.5; 64];
for _ in 0..10 {
let (_action, _value) = ppo.act(&state)?;
// Force Buy action
let current_position = ppo.portfolio_tracker.current_position();
// Verify position never exceeds max_position
assert!(
current_position.abs() <= max_position as f32,
"Position {} should not exceed max_position {}",
current_position,
max_position
);
}
Ok(())
}