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>
427 lines
12 KiB
Rust
427 lines
12 KiB
Rust
//! Test Helpers for Continuous PPO
|
|
//!
|
|
//! Provides utilities for testing continuous PPO implementation:
|
|
//! - Simple trading environment for testing
|
|
//! - Data loading utilities
|
|
//! - Training metric collection
|
|
//! - Trajectory generation helpers
|
|
|
|
use candle_core::Device;
|
|
use ml::ppo::continuous_policy::{ContinuousAction, ContinuousPolicyConfig};
|
|
use ml::ppo::continuous_ppo::{
|
|
ContinuousPPO, ContinuousPPOConfig, ContinuousTrajectory, ContinuousTrajectoryBatch,
|
|
ContinuousTrajectoryStep,
|
|
};
|
|
use ml::ppo::gae::GAEConfig;
|
|
use std::fs::File;
|
|
use std::path::PathBuf;
|
|
|
|
/// Training metrics collected during continuous PPO training
|
|
#[derive(Debug, Clone)]
|
|
pub struct TrainingMetrics {
|
|
/// Policy loss per epoch
|
|
pub policy_losses: Vec<f32>,
|
|
/// Value loss per epoch
|
|
pub value_losses: Vec<f32>,
|
|
/// Average reward per epoch
|
|
pub avg_rewards: Vec<f32>,
|
|
/// Log standard deviation per epoch (exploration parameter)
|
|
pub log_stds: Vec<f32>,
|
|
}
|
|
|
|
impl TrainingMetrics {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
policy_losses: Vec::new(),
|
|
value_losses: Vec::new(),
|
|
avg_rewards: Vec::new(),
|
|
log_stds: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Add epoch metrics
|
|
pub fn add_epoch(&mut self, policy_loss: f32, value_loss: f32, avg_reward: f32, log_std: f32) {
|
|
self.policy_losses.push(policy_loss);
|
|
self.value_losses.push(value_loss);
|
|
self.avg_rewards.push(avg_reward);
|
|
self.log_stds.push(log_std);
|
|
}
|
|
|
|
/// Check if policy loss is converging (decreasing trend)
|
|
pub fn is_policy_loss_converging(&self, min_reduction_pct: f32) -> bool {
|
|
if self.policy_losses.len() < 2 {
|
|
return false;
|
|
}
|
|
let first = self.policy_losses[0];
|
|
let last = *self.policy_losses.last().unwrap();
|
|
let reduction = (first - last) / first.abs().max(1e-8);
|
|
reduction >= min_reduction_pct
|
|
}
|
|
|
|
/// Check if value loss is stable
|
|
pub fn is_value_loss_stable(&self, window: usize, max_std: f32) -> bool {
|
|
if self.value_losses.len() < window {
|
|
return false;
|
|
}
|
|
let recent: Vec<f32> = self
|
|
.value_losses
|
|
.iter()
|
|
.rev()
|
|
.take(window)
|
|
.copied()
|
|
.collect();
|
|
let mean = recent.iter().sum::<f32>() / recent.len() as f32;
|
|
let variance = recent.iter().map(|&x| (x - mean).powi(2)).sum::<f32>() / recent.len() as f32;
|
|
let std = variance.sqrt();
|
|
std < max_std
|
|
}
|
|
|
|
/// Check if rewards are improving
|
|
pub fn are_rewards_improving(&self, min_improvement_factor: f32) -> bool {
|
|
if self.avg_rewards.len() < 2 {
|
|
return false;
|
|
}
|
|
let first = self.avg_rewards[0];
|
|
let last = *self.avg_rewards.last().unwrap();
|
|
last >= first * min_improvement_factor
|
|
}
|
|
|
|
/// Check if exploration is decaying (log_std decreasing)
|
|
pub fn is_exploration_decaying(&self) -> bool {
|
|
if self.log_stds.len() < 2 {
|
|
return false;
|
|
}
|
|
let first = self.log_stds[0];
|
|
let last = *self.log_stds.last().unwrap();
|
|
last < first
|
|
}
|
|
}
|
|
|
|
/// Simple trading environment for testing continuous PPO
|
|
#[derive(Debug, Clone)]
|
|
pub struct SimpleTradingEnv {
|
|
/// Price history
|
|
prices: Vec<f32>,
|
|
/// Current position (continuous value in [0, 1])
|
|
position: f32,
|
|
/// Current step
|
|
step: usize,
|
|
/// State dimension
|
|
state_dim: usize,
|
|
/// Transaction cost (basis points)
|
|
transaction_cost_bps: f32,
|
|
}
|
|
|
|
impl SimpleTradingEnv {
|
|
/// Create new environment with synthetic price data
|
|
pub fn new(num_steps: usize, state_dim: usize) -> Self {
|
|
// Generate simple sine wave prices for testing
|
|
let prices: Vec<f32> = (0..num_steps)
|
|
.map(|i| 100.0 + 10.0 * ((i as f32) * 0.1).sin())
|
|
.collect();
|
|
|
|
Self {
|
|
prices,
|
|
position: 0.0,
|
|
step: 0,
|
|
state_dim,
|
|
transaction_cost_bps: 10.0, // 0.10%
|
|
}
|
|
}
|
|
|
|
/// Create from real price data
|
|
pub fn from_prices(prices: Vec<f32>, state_dim: usize) -> Self {
|
|
Self {
|
|
prices,
|
|
position: 0.0,
|
|
step: 0,
|
|
state_dim,
|
|
transaction_cost_bps: 10.0,
|
|
}
|
|
}
|
|
|
|
/// Reset environment to initial state
|
|
pub fn reset(&mut self) -> Vec<f32> {
|
|
self.step = 0;
|
|
self.position = 0.0;
|
|
self.get_state()
|
|
}
|
|
|
|
/// Get current state (simplified features)
|
|
fn get_state(&self) -> Vec<f32> {
|
|
let mut state = vec![0.0; self.state_dim];
|
|
|
|
if self.step < self.prices.len() {
|
|
let current_price = self.prices[self.step];
|
|
|
|
// Feature 0: Normalized current price
|
|
state[0] = (current_price - 100.0) / 10.0;
|
|
|
|
// Feature 1: Price change (if not first step)
|
|
if self.step > 0 {
|
|
let prev_price = self.prices[self.step - 1];
|
|
state[1] = (current_price - prev_price) / prev_price;
|
|
}
|
|
|
|
// Feature 2: Current position
|
|
state[2] = self.position;
|
|
|
|
// Feature 3: Simple momentum (last 5 steps)
|
|
if self.step >= 5 {
|
|
let momentum = (self.prices[self.step] - self.prices[self.step - 5])
|
|
/ self.prices[self.step - 5];
|
|
state[3] = momentum;
|
|
}
|
|
|
|
// Fill remaining features with noise for dimensionality
|
|
for i in 4..self.state_dim {
|
|
state[i] = ((i + self.step) as f32).sin() * 0.1;
|
|
}
|
|
}
|
|
|
|
state
|
|
}
|
|
|
|
/// Execute action and get reward
|
|
pub fn step(&mut self, action: &ContinuousAction) -> (Vec<f32>, f32, bool) {
|
|
if self.step >= self.prices.len() - 1 {
|
|
return (self.get_state(), 0.0, true);
|
|
}
|
|
|
|
let current_price = self.prices[self.step];
|
|
let next_price = self.prices[self.step + 1];
|
|
|
|
// Calculate position change
|
|
let new_position = action.position_size();
|
|
let position_change = (new_position - self.position).abs();
|
|
|
|
// Calculate transaction costs
|
|
let transaction_cost = position_change * current_price * (self.transaction_cost_bps / 10000.0);
|
|
|
|
// Calculate P&L from price movement
|
|
let price_return = (next_price - current_price) / current_price;
|
|
let pnl = self.position * price_return * 100.0; // Scale for easier learning
|
|
|
|
// Reward = P&L - transaction costs
|
|
let reward = pnl - transaction_cost;
|
|
|
|
// Update state
|
|
self.position = new_position;
|
|
self.step += 1;
|
|
|
|
let next_state = self.get_state();
|
|
let done = self.step >= self.prices.len() - 1;
|
|
|
|
(next_state, reward, done)
|
|
}
|
|
|
|
/// Get current step
|
|
pub fn current_step(&self) -> usize {
|
|
self.step
|
|
}
|
|
|
|
/// Check if episode is done
|
|
pub fn is_done(&self) -> bool {
|
|
self.step >= self.prices.len() - 1
|
|
}
|
|
}
|
|
|
|
/// Load subset of ES_FUT parquet data for testing
|
|
pub fn load_test_data(num_samples: usize) -> anyhow::Result<Vec<f32>> {
|
|
use parquet::file::reader::{FileReader, SerializedFileReader};
|
|
|
|
let parquet_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.join("test_data")
|
|
.join("ES_FUT_180d.parquet");
|
|
|
|
let file = File::open(&parquet_path)?;
|
|
let reader = SerializedFileReader::new(file)?;
|
|
|
|
let mut prices = Vec::new();
|
|
|
|
for row_result in reader.get_row_iter(None)? {
|
|
if prices.len() >= num_samples {
|
|
break;
|
|
}
|
|
|
|
let row = row_result?;
|
|
|
|
// Extract close price (column 4)
|
|
if let Some(close_field) = row.get_double(4) {
|
|
prices.push(close_field as f32);
|
|
}
|
|
}
|
|
|
|
Ok(prices)
|
|
}
|
|
|
|
/// Train continuous PPO for N epochs and return metrics
|
|
pub fn train_continuous_ppo(
|
|
epochs: usize,
|
|
config: ContinuousPPOConfig,
|
|
num_trajectories: usize,
|
|
trajectory_length: usize,
|
|
) -> anyhow::Result<(ContinuousPPO, TrainingMetrics)> {
|
|
let mut ppo = ContinuousPPO::new(config.clone())?;
|
|
let mut metrics = TrainingMetrics::new();
|
|
|
|
// Create environment
|
|
let mut env = SimpleTradingEnv::new(trajectory_length * 10, config.state_dim);
|
|
|
|
for epoch in 0..epochs {
|
|
// Collect trajectories
|
|
let mut trajectories = Vec::new();
|
|
let mut epoch_rewards = 0.0;
|
|
|
|
for _ in 0..num_trajectories {
|
|
let trajectory = collect_trajectory(&ppo, &mut env, trajectory_length)?;
|
|
epoch_rewards += trajectory
|
|
.steps()
|
|
.iter()
|
|
.map(|s| s.reward)
|
|
.sum::<f32>();
|
|
trajectories.push(trajectory);
|
|
}
|
|
|
|
// Compute advantages using GAE
|
|
let (advantages, returns) = compute_batch_gae(&trajectories, &config.gae_config)?;
|
|
|
|
// Create batch
|
|
let mut batch = ContinuousTrajectoryBatch::from_trajectories(trajectories, advantages, returns);
|
|
|
|
// Update PPO
|
|
let (policy_loss, value_loss) = ppo.update(&mut batch)?;
|
|
|
|
// Get current exploration parameter (log_std)
|
|
let test_state = vec![0.0; config.state_dim];
|
|
let log_std = ppo.get_exploration_param(&test_state)?;
|
|
|
|
// Record metrics
|
|
let avg_reward = epoch_rewards / num_trajectories as f32;
|
|
metrics.add_epoch(policy_loss, value_loss, avg_reward, log_std);
|
|
|
|
if (epoch + 1) % 5 == 0 {
|
|
println!(
|
|
"Epoch {}/{}: Policy Loss={:.4}, Value Loss={:.4}, Avg Reward={:.4}, Log Std={:.4}",
|
|
epoch + 1,
|
|
epochs,
|
|
policy_loss,
|
|
value_loss,
|
|
avg_reward,
|
|
log_std
|
|
);
|
|
}
|
|
}
|
|
|
|
Ok((ppo, metrics))
|
|
}
|
|
|
|
/// Collect a single trajectory
|
|
pub fn collect_trajectory(
|
|
ppo: &ContinuousPPO,
|
|
env: &mut SimpleTradingEnv,
|
|
max_steps: usize,
|
|
) -> anyhow::Result<ContinuousTrajectory> {
|
|
let mut trajectory = ContinuousTrajectory::new();
|
|
let mut state = env.reset();
|
|
|
|
for _ in 0..max_steps {
|
|
if env.is_done() {
|
|
break;
|
|
}
|
|
|
|
// Get action with log prob
|
|
let (action, log_prob, value) = ppo.act_with_log_prob(&state)?;
|
|
|
|
// Execute action
|
|
let (next_state, reward, done) = env.step(&action);
|
|
|
|
// Add step to trajectory
|
|
trajectory.add_step(ContinuousTrajectoryStep::new(
|
|
state.clone(),
|
|
action,
|
|
log_prob,
|
|
reward,
|
|
value,
|
|
done,
|
|
));
|
|
|
|
state = next_state;
|
|
|
|
if done {
|
|
break;
|
|
}
|
|
}
|
|
|
|
Ok(trajectory)
|
|
}
|
|
|
|
/// Compute GAE advantages for batch of trajectories
|
|
pub fn compute_batch_gae(
|
|
trajectories: &[ContinuousTrajectory],
|
|
gae_config: &GAEConfig,
|
|
) -> anyhow::Result<(Vec<f32>, Vec<f32>)> {
|
|
let mut all_advantages = Vec::new();
|
|
let mut all_returns = Vec::new();
|
|
|
|
for trajectory in trajectories {
|
|
let steps = trajectory.steps();
|
|
if steps.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
let rewards: Vec<f32> = steps.iter().map(|s| s.reward).collect();
|
|
let values: Vec<f32> = steps.iter().map(|s| s.value).collect();
|
|
let dones: Vec<bool> = steps.iter().map(|s| s.done).collect();
|
|
|
|
// Last value is 0 if done, otherwise use last step's value
|
|
let next_value = if steps.last().unwrap().done {
|
|
0.0
|
|
} else {
|
|
steps.last().unwrap().value
|
|
};
|
|
|
|
// Compute GAE
|
|
let (advantages, returns) =
|
|
ml::ppo::gae::compute_gae_single_trajectory(&rewards, &values, &dones, next_value, gae_config)?;
|
|
|
|
all_advantages.extend(advantages);
|
|
all_returns.extend(returns);
|
|
}
|
|
|
|
Ok((all_advantages, all_returns))
|
|
}
|
|
|
|
/// Create default continuous PPO config for testing
|
|
pub fn create_test_config(state_dim: usize) -> ContinuousPPOConfig {
|
|
ContinuousPPOConfig {
|
|
state_dim,
|
|
policy_config: ContinuousPolicyConfig {
|
|
state_dim,
|
|
hidden_dims: vec![64, 32],
|
|
min_log_std: -5.0,
|
|
max_log_std: 2.0,
|
|
init_log_std: -1.0,
|
|
learnable_std: true,
|
|
action_bounds: (0.0, 1.0),
|
|
},
|
|
value_hidden_dims: vec![64, 32],
|
|
policy_learning_rate: 3e-4,
|
|
value_learning_rate: 1e-3,
|
|
clip_epsilon: 0.2,
|
|
value_loss_coeff: 0.5,
|
|
entropy_coeff: 0.01,
|
|
gae_config: GAEConfig {
|
|
gamma: 0.99,
|
|
lambda: 0.95,
|
|
normalize_advantages: true,
|
|
},
|
|
batch_size: 128,
|
|
mini_batch_size: 32,
|
|
num_epochs: 4,
|
|
max_grad_norm: 0.5,
|
|
}
|
|
}
|