Files
foxhunt/crates/ml/src/hyperopt/adapters/ppo.rs
jgrusewski 81771d7920 feat(tick): OFI_DIM 8→20 — atomic update across 15 files
fxcache: OFI_DIM=20 (pub const), RECORD_F64_COUNT=66, 272 bytes/bar.
constructor: state_dim 74→86 (aligned 88) with OFI.
experience collector: ofi_dim detection 8→20.
All [f64; 8] → [f64; 20]. Existing 8 features preserved at 0-7.
New 12 features zero-padded until precompute is extended.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 00:21:15 +02:00

2066 lines
85 KiB
Rust
Raw 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.
//! PPO Hyperparameter Optimization Adapter
//!
//! This module provides a production-ready adapter for optimizing PPO
//! hyperparameters using the generic optimization framework. It implements:
//!
//! - Parameter space with log-scale handling for learning rates
//! - Training wrapper that integrates with existing PPO pipeline
//! - Metrics extraction for episode reward optimization (maximization)
//!
//! ## Usage Example
//!
//! ```rust,no_run
//! use ml::hyperopt::ArgminOptimizer;
//! use ml::hyperopt::adapters::ppo::{PPOTrainer, PPOParams};
//!
//! # async fn example() -> anyhow::Result<()> {
//! // Create trainer
//! let trainer = PPOTrainer::new(
//! 1000, // episodes per trial
//! )?;
//!
//! // Run optimization
//! let optimizer = ArgminOptimizer::with_trials(30, 5);
//! let result = optimizer.optimize(trainer)?;
//!
//! println!("Best policy LR: {}", result.best_params.policy_learning_rate);
//! println!("Best value LR: {}", result.best_params.value_learning_rate);
//! println!("Best episode reward: {:.6}", -result.best_objective); // Negated (optimizer minimizes)
//! # Ok(())
//! # }
//! ```
use ml_core::device::MlDevice;
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write as IoWrite;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use tracing::{info, warn};
use crate::common::action::FactoredAction;
use crate::cuda_pipeline::signal_adapter::backtest_fitness;
use crate::dqn::curiosity::CuriosityModule;
use crate::hyperopt::paths::TrainingPaths;
use crate::hyperopt::traits::{HardwareBudget, HyperparameterOptimizable, ParameterSpace};
use crate::ppo::composite_reward::CompositeReward;
use crate::ppo::gae::GAEConfig;
use crate::ppo::ppo::{PPOConfig, PPO};
use crate::ppo::reward_shaping::PPORewardShaper;
use crate::ppo::trajectories::TrajectoryBatch;
use crate::ppo::trajectory_replay::TrajectoryReplayBuffer;
use crate::MLError;
use crate::cuda_pipeline::gpu_backtest_evaluator::{GpuBacktestConfig, GpuBacktestEvaluator};
use cudarc::driver::CudaSlice;
/// Pure model VRAM in MB (actor + critic + optimizers + gradients).
const MODEL_OVERHEAD_MB: f64 = 300.0;
/// Total per-trial VRAM in MB during concurrent hyperopt.
/// PPO uses less VRAM than DQN (no replay buffer, smaller trajectory batches)
/// but CUDA overhead and fragmentation still dominate.
/// Conservative estimate: ~3.5 GB/trial on L40S.
const TRIAL_VRAM_MB: f64 = 3500.0;
/// PPO per-sample memory in MB (45 features x 4B + activations)
const MB_PER_SAMPLE: f64 = 0.001;
/// PPO hyperparameter space
///
/// Defines the hyperparameters to optimize for PPO training:
/// - Policy learning rate (log-scale: 1e-6 to 1e-3)
/// - Value learning rate (log-scale: 1e-5 to 1e-3)
/// - Clip epsilon (linear scale: 0.1 to 0.3)
/// - Value loss coefficient (linear scale: 0.5 to 2.0)
/// - Entropy coefficient (log-scale: 0.001 to 0.1)
///
/// ## Parameter Scaling
///
/// - **Log-scale**: Learning rates, entropy_coeff (span multiple orders)
/// - **Linear scale**: Clip epsilon, value loss coeff (span single order)
///
/// This scaling ensures efficient exploration by argmin's optimization.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PPOParams {
/// Policy network learning rate (log-scale)
pub policy_learning_rate: f64,
/// Value network learning rate (log-scale)
pub value_learning_rate: f64,
/// PPO clip parameter epsilon (linear scale)
pub clip_epsilon: f64,
/// Value function loss coefficient (linear scale)
pub value_loss_coeff: f64,
/// Entropy coefficient for exploration (log-scale)
pub entropy_coeff: f64,
/// Batch size for PPO rollout collection (linear scale)
pub batch_size: usize,
/// Hidden dimension base for dynamic network sizing.
/// Policy network: [base, base/2]. Value network: [base*2, base, base/2].
/// Range: [64, 2048], step=64. Bounded by VRAM via HardwareBudget.
pub hidden_dim_base: usize,
// --- Ensemble diversity parameters (6D) ---
/// GAE discount factor — controls time horizon.
/// Low (0.95) = short-term scalper, high (0.999) = trend follower.
pub gae_gamma: f64,
/// GAE lambda — bias-variance tradeoff in advantage estimation.
/// Low (0.8) = more biased but stable, high (1.0) = Monte Carlo-like.
pub gae_lambda: f64,
/// Mini-batch size for PPO updates within each rollout batch.
/// Smaller = noisier gradients (more diverse), larger = more stable.
pub mini_batch_size: usize,
/// Maximum gradient norm for clipping. Controls learning stability.
pub max_grad_norm: f64,
/// Maximum absolute position size. Controls risk appetite per trial.
pub max_position_absolute: f64,
/// Asymmetric clip upper bound. 0.0 = symmetric (None), >0 = clip_epsilon_high.
/// Prevents entropy collapse during long training.
pub clip_epsilon_high: f64,
/// Curiosity-driven exploration weight. 0.0 = disabled, >0 = intrinsic reward scaling.
pub curiosity_weight: f64,
}
impl Default for PPOParams {
fn default() -> Self {
Self {
policy_learning_rate: 3e-5,
value_learning_rate: 1e-4,
clip_epsilon: 0.2,
value_loss_coeff: 1.0,
entropy_coeff: 0.05,
batch_size: 2048,
hidden_dim_base: 128, // Conservative default: policy [128, 64], value [256, 128, 64]
gae_gamma: 0.99,
gae_lambda: 0.95,
mini_batch_size: 512,
max_grad_norm: 0.5,
max_position_absolute: 2.0,
clip_epsilon_high: 0.0, // symmetric by default
curiosity_weight: 0.0, // disabled by default
}
}
}
impl ParameterSpace for PPOParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
vec![
(1e-6_f64.ln(), 3e-4_f64.ln()), // 0: policy_learning_rate (log scale, capped at 3e-4)
(1e-5_f64.ln(), 1e-4_f64.ln()), // 1: value_learning_rate (log scale, capped at 1e-4 — 1e-3 causes gradient explosion)
(0.1, 0.3), // 2: clip_epsilon (linear)
(0.5, 2.0), // 3: value_loss_coeff (linear)
(0.001_f64.ln(), 0.1_f64.ln()), // 4: entropy_coeff (log scale)
(512.0, 8192.0), // 5: batch_size (large for PPO variance reduction)
(64.0, 4096.0), // 6: hidden_dim_base (linear, step=64)
(0.95, 0.999), // 7: gae_gamma (linear)
(0.8, 1.0), // 8: gae_lambda (linear)
(128.0, 2048.0), // 9: mini_batch_size (linear)
(0.1, 1.0), // 10: max_grad_norm (linear)
(0.5, 3.0), // 11: max_position_absolute (linear)
(0.0, 0.5), // 12: clip_epsilon_high (linear, 0=symmetric)
(0.0, 0.5), // 13: curiosity_weight (linear, 0=off)
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() != 14 {
return Err(MLError::ConfigError(format!("Expected 14 parameters, got {}", x.len())));
}
Ok(Self {
policy_learning_rate: x.get(0).copied().ok_or(MLError::ConfigError("Missing policy_learning_rate".to_owned()))?.exp(),
value_learning_rate: x.get(1).copied().ok_or(MLError::ConfigError("Missing value_learning_rate".to_owned()))?.exp(),
clip_epsilon: x.get(2).copied().ok_or(MLError::ConfigError("Missing clip_epsilon".to_owned()))?.clamp(0.1, 0.3),
value_loss_coeff: x.get(3).copied().ok_or(MLError::ConfigError("Missing value_loss_coeff".to_owned()))?.clamp(0.5, 2.0),
entropy_coeff: x.get(4).copied().ok_or(MLError::ConfigError("Missing entropy_coeff".to_owned()))?.exp(),
batch_size: x.get(5).copied().ok_or(MLError::ConfigError("Missing batch_size".to_owned()))?.round() as usize,
hidden_dim_base: ((x.get(6).copied().ok_or(MLError::ConfigError("Missing hidden_dim_base".to_owned()))?.round() / 64.0).round() * 64.0).clamp(64.0, 4096.0) as usize,
gae_gamma: x.get(7).copied().ok_or(MLError::ConfigError("Missing gae_gamma".to_owned()))?.clamp(0.95, 0.999),
gae_lambda: x.get(8).copied().ok_or(MLError::ConfigError("Missing gae_lambda".to_owned()))?.clamp(0.8, 1.0),
mini_batch_size: x.get(9).copied().ok_or(MLError::ConfigError("Missing mini_batch_size".to_owned()))?.round() as usize,
max_grad_norm: x.get(10).copied().ok_or(MLError::ConfigError("Missing max_grad_norm".to_owned()))?.clamp(0.1, 1.0),
max_position_absolute: x.get(11).copied().ok_or(MLError::ConfigError("Missing max_position_absolute".to_owned()))?.clamp(0.5, 3.0),
clip_epsilon_high: x.get(12).copied().ok_or(MLError::ConfigError("Missing clip_epsilon_high".to_owned()))?.clamp(0.0, 0.5),
curiosity_weight: x.get(13).copied().ok_or(MLError::ConfigError("Missing curiosity_weight".to_owned()))?.clamp(0.0, 0.5),
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.policy_learning_rate.ln(),
self.value_learning_rate.ln(),
self.clip_epsilon,
self.value_loss_coeff,
self.entropy_coeff.ln(),
self.batch_size as f64,
self.hidden_dim_base as f64,
self.gae_gamma,
self.gae_lambda,
self.mini_batch_size as f64,
self.max_grad_norm,
self.max_position_absolute,
self.clip_epsilon_high,
self.curiosity_weight,
]
}
fn param_names() -> Vec<&'static str> {
vec![
"policy_learning_rate",
"value_learning_rate",
"clip_epsilon",
"value_loss_coeff",
"entropy_coeff",
"batch_size",
"hidden_dim_base",
"gae_gamma",
"gae_lambda",
"mini_batch_size",
"max_grad_norm",
"max_position_absolute",
"clip_epsilon_high",
"curiosity_weight",
]
}
fn continuous_bounds_for(budget: &HardwareBudget) -> Vec<(f64, f64)> {
let mut bounds = Self::continuous_bounds();
// Cap batch_size by VRAM (index 5)
if let Some(max_batch) = budget.max_batch_size(MODEL_OVERHEAD_MB, MB_PER_SAMPLE, 512.0, 8192.0) {
if let Some(batch_bound) = bounds.get_mut(5) {
batch_bound.1 = max_batch;
}
}
// Cap hidden_dim_base by VRAM (index 6)
// PPO needs more VRAM than DQN (actor + critic networks)
let max_base = budget.max_hidden_dim_base(4, 512, 53, 45); // 4 concurrent, 512 batch, 45 features (42 market + 3 portfolio), 45 factored actions
if let Some(dim_bound) = bounds.get_mut(6) {
dim_bound.1 = max_base as f64;
}
// Cap mini_batch_size by VRAM (index 9) — same logic as batch_size
if let Some(max_mini) = budget.max_batch_size(MODEL_OVERHEAD_MB, MB_PER_SAMPLE, 128.0, 2048.0) {
if let Some(mini_bound) = bounds.get_mut(9) {
mini_bound.1 = max_mini;
}
}
bounds
}
fn model_overhead_mb() -> f64 {
TRIAL_VRAM_MB
}
fn per_sample_mb() -> f64 {
MB_PER_SAMPLE
}
}
/// PPO training metrics
///
/// Contains all relevant metrics from a PPO training run.
/// The primary optimization target is episode reward (avg_episode_reward).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PPOMetrics {
/// Training policy loss
pub policy_loss: f64,
/// Training value loss
pub value_loss: f64,
/// Validation policy loss (optimization target)
pub val_policy_loss: f64,
/// Validation value loss (optimization target)
pub val_value_loss: f64,
/// Combined loss (weighted sum)
pub combined_loss: f64,
/// Average episode reward
pub avg_episode_reward: f64,
/// Number of episodes completed
pub episodes_completed: usize,
/// GPU walk-forward backtest Sharpe ratio (None when CUDA unavailable or backtest fails)
pub backtest_sharpe: Option<f32>,
/// GPU walk-forward backtest total trade count (None when CUDA unavailable or backtest fails)
pub backtest_trades: Option<u32>,
}
/// PPO trainer for hyperparameter optimization
///
/// This struct wraps the PPO training pipeline and implements
/// `HyperparameterOptimizable` for use with optimization backends.
///
/// ## Configuration
///
/// - **DBN data dir**: Market data source (OHLCV bars from Databento)
/// - **Episodes**: Number of training episodes per trial
/// - **Device**: CUDA GPU (falls back to CPU if unavailable)
/// - **Features**: 42 market features from extraction pipeline
///
/// ## Fixed Architecture
///
/// The following parameters are fixed for consistency:
/// - `state_dim`: 45 without OFI (42 market + 3 portfolio), or 53 with MBP-10 data (+8 OFI)
/// - `num_actions`: 45 (5 exposure x 3 order x 3 urgency)
/// - `policy_hidden_dims`: [base, base/2]
/// - `value_hidden_dims`: [base*4, base*3, base*2, base, base/2]
///
/// ## Optimized Hyperparameters
///
/// The following are optimized by `PPOParams`:
/// - Policy learning rate
/// - Value learning rate
/// - Clip epsilon
/// - Value loss coefficient
/// - Entropy coefficient
pub struct PPOTrainer {
dbn_data_dir: std::path::PathBuf,
episodes: usize,
device: MlDevice,
/// Pool of GPU devices for multi-GPU trial parallelism.
device_pool: Vec<MlDevice>,
/// Training paths configuration (replaces hardcoded checkpoint directories)
training_paths: TrainingPaths,
/// Early stopping patience (epochs without improvement before stopping)
early_stopping_patience: usize,
/// Early stopping minimum epochs (minimum epochs before early stopping can trigger)
early_stopping_min_epochs: usize,
/// Trial counter for hyperopt — shared across clones for parallel execution
trial_counter: Arc<AtomicUsize>,
/// Transaction cost in basis points (commission per trade, default 1.0)
tx_cost_bps: f64,
/// Tick size for spread calculation (default 0.25 for ES futures)
tick_size: f64,
/// Spread width in ticks (default 1.0)
spread_ticks: f64,
/// GPU market features [num_bars * 42] — uploaded once, reused across trials
features_cuda: Option<CudaSlice<f32>>,
/// GPU market targets [num_bars * 4] — uploaded once, reused across trials
targets_cuda: Option<CudaSlice<f32>>,
/// GPU PPO experience collector — initialized on first trial, weights synced per trial
gpu_collector: Option<crate::cuda_pipeline::gpu_ppo_collector::GpuPpoExperienceCollector>,
/// Number of bars in the uploaded market data
gpu_num_bars: usize,
/// Initial capital for portfolio simulation and backtest evaluation.
/// Must match the value used in training reward computation.
initial_capital: f64,
/// Preloaded training data cached across hyperopt trials.
/// Loaded once via `preload_data()`, then shared (via Arc) across all trials
/// to avoid re-reading `.dbn.zst` files and re-extracting features per trial.
preloaded_data: Option<Arc<Vec<([f32; 42], f64)>>>,
}
impl std::fmt::Debug for PPOTrainer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PPOTrainer")
.field("dbn_data_dir", &self.dbn_data_dir)
.field("episodes", &self.episodes)
.field("device", &self.device)
.field("early_stopping_patience", &self.early_stopping_patience)
.finish_non_exhaustive()
}
}
impl Clone for PPOTrainer {
fn clone(&self) -> Self {
Self {
dbn_data_dir: self.dbn_data_dir.clone(),
episodes: self.episodes,
device: self.device.clone(),
device_pool: self.device_pool.clone(),
training_paths: self.training_paths.clone(),
early_stopping_patience: self.early_stopping_patience,
early_stopping_min_epochs: self.early_stopping_min_epochs,
trial_counter: self.trial_counter.clone(),
tx_cost_bps: self.tx_cost_bps,
tick_size: self.tick_size,
spread_ticks: self.spread_ticks,
initial_capital: self.initial_capital,
// GPU resources are not cloned — each clone re-initialises on first use
features_cuda: None,
targets_cuda: None,
gpu_collector: None,
gpu_num_bars: 0,
// Preloaded data is shared via Arc — cheap to clone
preloaded_data: self.preloaded_data.clone(),
}
}
}
impl PPOTrainer {
/// Create a new PPO trainer
///
/// # Arguments
///
/// * `dbn_data_dir` - Path to directory with DBN market data files
/// * `episodes` - Number of training episodes per trial
///
/// # Returns
///
/// Configured trainer ready for optimization
///
/// # Errors
///
/// Returns error if:
/// - DBN data directory doesn't exist
/// - No DBN files found in directory
/// - Device initialization fails
pub fn new<P: Into<std::path::PathBuf>>(
dbn_data_dir: P,
episodes: usize,
) -> anyhow::Result<Self> {
let dbn_data_dir = dbn_data_dir.into();
if !dbn_data_dir.exists() {
return Err(MLError::ConfigError(format!("DBN data directory not found: {}", dbn_data_dir.display()))
.into());
}
// Initialize CUDA device — GPU required for RL hyperopt
let device = MlDevice::cuda(0)
.map_err(|e| anyhow::anyhow!("CUDA GPU required for PPO hyperopt: {}", e))?;
info!("PPO Trainer initialized:");
info!(" Data directory: {}", dbn_data_dir.display());
info!(" Device: CUDA GPU");
info!(" Episodes per trial: {}", episodes);
// Use temporary default paths - should be replaced with with_training_paths()
let training_paths = TrainingPaths::new("/tmp/ml_training", "ppo", "default");
Ok(Self {
dbn_data_dir,
episodes,
device_pool: vec![device.clone()],
device,
training_paths,
early_stopping_patience: 5, // Default: 5 epochs (hyperopt optimized)
early_stopping_min_epochs: 5, // Default: 5 epochs (hyperopt optimized)
trial_counter: Arc::new(AtomicUsize::new(0)), // Shared across clones for parallel trials
tx_cost_bps: 1.0, // 1 bps commission (ES futures typical)
tick_size: 0.25, // ES futures tick size
spread_ticks: 1.0, // 1 tick spread (ES typical)
initial_capital: 35_000.0, // Default: $35K (realistic retail futures)
features_cuda: None,
targets_cuda: None,
gpu_collector: None,
gpu_num_bars: 0,
preloaded_data: None, // Loaded on first call to preload_data()
})
}
/// Configure early stopping parameters (overrides defaults)
pub fn with_early_stopping(mut self, patience: usize, min_epochs: usize) -> Self {
self.early_stopping_patience = patience;
self.early_stopping_min_epochs = min_epochs;
self
}
/// Configure transaction costs and spread for realistic reward computation
///
/// # Arguments
///
/// * `tx_cost_bps` - Commission cost per trade in basis points (e.g. 1.0 for ES)
/// * `tick_size` - Minimum price increment (e.g. 0.25 for ES)
/// * `spread_ticks` - Typical bid-ask spread in ticks (e.g. 1.0 for ES)
pub fn with_costs(mut self, tx_cost_bps: f64, tick_size: f64, spread_ticks: f64) -> Self {
self.tx_cost_bps = tx_cost_bps;
self.tick_size = tick_size;
self.spread_ticks = spread_ticks;
info!("PPO costs configured: tx={} bps, spread={} ticks (tick_size={})",
tx_cost_bps, spread_ticks, tick_size);
self
}
/// Set initial capital for portfolio simulation and backtest evaluation.
pub fn with_initial_capital(mut self, capital: f64) -> Self {
self.initial_capital = capital;
self
}
/// Override the compute device (CPU or CUDA).
pub fn with_device(mut self, device: MlDevice) -> Self {
info!(
"Device overridden to: {}",
"CUDA"
);
self.device_pool = vec![device.clone()];
self.device = device;
self
}
/// Set a pool of GPU devices for multi-GPU trial parallelism.
pub fn with_devices(mut self, devices: Vec<MlDevice>) -> Self {
if devices.is_empty() {
return self;
}
info!("Multi-GPU device pool: {} devices", devices.len());
self.device = devices[0].clone();
self.device_pool = devices;
self
}
/// Set training paths configuration
///
/// # Arguments
///
/// * `paths` - Training paths configuration
///
/// # Returns
///
/// Self for method chaining
pub fn with_training_paths(mut self, paths: TrainingPaths) -> Self {
info!("PPO training paths configured:");
info!(" Run directory: {:?}", paths.run_dir());
info!(" Checkpoints: {:?}", paths.checkpoints_dir());
info!(" Logs: {:?}", paths.logs_dir());
info!(" Hyperopt: {:?}", paths.hyperopt_dir());
self.training_paths = paths;
self
}
/// Preload training data from DBN files once, caching it for reuse across
/// all hyperopt trials. This eliminates the per-trial cost of reading
/// `.dbn.zst` files, decompressing them, and extracting 42 features.
///
/// Call this once before starting the optimization loop. The data is stored
/// in `Arc` so cloning the trainer (for parallel trials) is cheap.
///
/// # Errors
///
/// Returns error if data loading or feature extraction fails
pub fn preload_data(&mut self) -> Result<(), MLError> {
info!("Preloading PPO training data from: {} ...", self.dbn_data_dir.display());
let preload_start = std::time::Instant::now();
let data = self
.load_training_data()
.map_err(|e| MLError::TrainingError(format!("Failed to preload data: {}", e)))?;
let elapsed = preload_start.elapsed();
info!(
"PPO data preloaded: {} samples in {:.1}s (cached for all trials)",
data.len(),
elapsed.as_secs_f64()
);
self.preloaded_data = Some(Arc::new(data));
Ok(())
}
/// Returns true if training data has been preloaded.
pub fn has_preloaded_data(&self) -> bool {
self.preloaded_data.is_some()
}
/// Upload training data to GPU and initialize the experience collector.
///
/// Called once at the start of the first trial. Subsequent trials reuse the
/// uploaded data and call `sync_weights()` on the collector.
fn ensure_gpu_data(
&mut self,
training_data: &[([f32; 42], f64)],
_ppo_agent: &PPO,
) -> Result<(), MLError> {
use tracing::debug;
// Data already uploaded — collector weight sync is a no-op until
// GpuVarStore integration is wired. The PPO update() path handles
// weight synchronization internally via its own CUDA kernels.
if self.features_cuda.is_some() && self.targets_cuda.is_some() {
debug!("PPO GPU data already uploaded — reusing");
return Ok(());
}
let stream = self.device.cuda_stream()
.map_err(|_| MLError::ConfigError("CUDA required for PPO training".to_owned()))?;
let num_bars = training_data.len();
// Upload features [num_bars * 42]
let mut flat_features = Vec::with_capacity(num_bars * 42);
for (features, _) in training_data {
flat_features.extend_from_slice(features);
}
match crate::cuda_pipeline::clone_htod_f32(&stream, &flat_features) {
Ok(buf) => {
info!("PPO CUDA features uploaded: {} bars × 42 ({:.1} MB)",
num_bars, (num_bars * 42 * 4) as f64 / 1_048_576.0);
self.features_cuda = Some(buf);
}
Err(e) => {
return Err(MLError::TrainingError(format!("PPO CUDA features upload FAILED (no CPU fallback): {e}")));
}
}
// Upload targets [num_bars * 4]: kernel expects [close, next_close, close_raw, next_close_raw]
let mut flat_targets = Vec::with_capacity(num_bars * 4);
for (i, (_, price)) in training_data.iter().enumerate() {
let close = *price as f32;
let next_close = training_data
.get(i + 1)
.map(|(_, p)| *p as f32)
.unwrap_or(close);
flat_targets.push(close);
flat_targets.push(next_close);
flat_targets.push(close);
flat_targets.push(next_close);
}
match crate::cuda_pipeline::clone_htod_f32(&stream, &flat_targets) {
Ok(buf) => {
info!("PPO CUDA targets uploaded: {} bars × 4 ({:.1} MB)",
num_bars, (num_bars * 4 * 4) as f64 / 1_048_576.0);
self.targets_cuda = Some(buf);
}
Err(e) => {
self.features_cuda = None;
return Err(MLError::TrainingError(format!("PPO CUDA targets upload FAILED (no CPU fallback): {e}")));
}
}
self.gpu_num_bars = num_bars;
// Initialize GPU collector with placeholder VarStores.
// PPO actor/critic weights are managed internally by their CUDA networks;
// the collector uses these for kernel-side forward passes during experience
// collection. Empty VarStores signal the kernel to skip weight-dependent ops.
if self.gpu_collector.is_none() {
let actor_vars = ml_core::cuda_autograd::GpuVarStore::new(stream.clone());
let critic_vars = ml_core::cuda_autograd::GpuVarStore::new(stream.clone());
let curiosity_vars = ml_core::cuda_autograd::GpuVarStore::new(stream.clone());
match crate::cuda_pipeline::gpu_ppo_collector::GpuPpoExperienceCollector::new(
stream.clone(),
&actor_vars,
&critic_vars,
&curiosity_vars,
self.initial_capital as f32,
self.tick_size as f32 * self.spread_ticks as f32,
0.20,
) {
Ok(collector) => {
info!("PPO GPU experience collector initialized (zero-roundtrip CUDA kernel)");
self.gpu_collector = Some(collector);
}
Err(e) => {
return Err(MLError::TrainingError(format!("PPO GPU collector init FAILED (no CPU fallback): {e}")));
}
}
}
Ok(())
}
/// Collect experiences using GPU kernel, returning a TrajectoryBatch.
///
/// Returns Err if GPU collection fails — no CPU fallback.
fn gpu_collect_trajectories(
&mut self,
num_episodes: usize,
) -> Result<TrajectoryBatch, MLError> {
use crate::cuda_pipeline::gpu_ppo_collector::PpoCollectorConfig;
let collector = self.gpu_collector.as_mut()
.ok_or_else(|| MLError::TrainingError("PPO GPU collector not initialized — CPU fallback FORBIDDEN".to_owned()))?;
let features_buf = self.features_cuda.as_ref()
.ok_or_else(|| MLError::TrainingError("PPO CUDA features not uploaded — CPU fallback FORBIDDEN".to_owned()))?;
let targets_buf = self.targets_cuda.as_ref()
.ok_or_else(|| MLError::TrainingError("PPO CUDA targets not uploaded — CPU fallback FORBIDDEN".to_owned()))?;
let total_bars = self.gpu_num_bars;
let n_episodes = (num_episodes as i32).min(128);
let timesteps = 100_i32; // Match CPU episode length
let usable_bars = (total_bars as i32 - timesteps).max(1);
let stride = (usable_bars / n_episodes).max(1);
let episode_starts: Vec<i32> = (0..n_episodes)
.map(|i| (i * stride).rem_euclid(usable_bars))
.collect();
let config = PpoCollectorConfig {
n_episodes,
timesteps_per_episode: timesteps,
total_bars: total_bars as i32,
gamma: 0.99,
gae_lambda: 0.95,
..Default::default()
};
collector.reset_episodes(self.initial_capital as f32, self.tick_size as f32 * self.spread_ticks as f32, 0.20)
.map_err(|e| MLError::TrainingError(format!("PPO GPU reset_episodes FAILED (no CPU fallback): {e}")))?;
let batch = collector.collect_experiences(features_buf, targets_buf, &episode_starts, &config)
.map_err(|e| MLError::TrainingError(format!("PPO GPU experience collection FAILED (no CPU fallback): {e}")))?;
info!("GPU PPO collected {} experiences ({} episodes × {} timesteps)",
batch.n_episodes * batch.timesteps, batch.n_episodes, batch.timesteps);
gpu_ppo_batch_to_trajectory_batch(&batch)
}
}
/// Convert a GPU-resident PPO experience batch to a TrajectoryBatch.
///
/// Downloads data from GPU for the hyperopt replay buffer and validation flow.
fn gpu_ppo_batch_to_trajectory_batch(
batch: &crate::cuda_pipeline::gpu_ppo_collector::PpoExperienceBatch,
) -> Result<TrajectoryBatch, MLError> {
let kernel_state_dim = batch.state_dim;
let model_state_dim = 45;
let total = batch.total();
let states_flat = batch.download_states()?;
let actions_raw = batch.download_actions()?;
let log_probs_raw = batch.download_log_probs()?;
let advantages_raw = batch.download_advantages()?;
let returns_raw = batch.download_returns()?;
let done_flags_raw = batch.download_done_flags()?;
let states: Vec<Vec<f32>> = states_flat
.chunks(kernel_state_dim)
.take(total)
.map(|c| c.get(..model_state_dim).unwrap_or(c).to_vec()) // cpu-side state truncate
.collect();
let hold_action = FactoredAction::new(
crate::common::action::ExposureLevel::Flat,
crate::common::action::OrderType::Market,
crate::common::action::Urgency::Normal,
);
let actions: Vec<FactoredAction> = actions_raw.iter().take(total)
.map(|&a| {
let idx = (a.clamp(0, 44)) as usize;
FactoredAction::from_index(idx).unwrap_or(hold_action)
})
.collect();
let log_probs: Vec<f32> = log_probs_raw.into_iter().take(total).collect();
let values: Vec<f32> = returns_raw.iter().zip(&advantages_raw).take(total)
.map(|(r, a)| r - a).collect();
let dones: Vec<bool> = done_flags_raw.iter().take(total).map(|&d| d != 0).collect();
let rewards = vec![0.0_f32; states.len()];
Ok(TrajectoryBatch {
trajectories: vec![],
states,
states_flat: vec![],
actions,
log_probs,
values,
rewards,
dones,
advantages: advantages_raw.into_iter().take(total).collect(),
returns: returns_raw.into_iter().take(total).collect(),
})
}
/// Write a log entry to the training log file
fn write_training_log_ppo(logs_dir: &std::path::Path, message: &str) -> Result<(), std::io::Error> {
let log_file = logs_dir.join("training.log");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(log_file)?;
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S");
writeln!(file, "[{}] {}", timestamp, message)?;
Ok(())
}
/// Write trial results to JSON file
fn write_trial_result_ppo(
hyperopt_dir: &std::path::Path,
trial_result: &crate::hyperopt::traits::TrialResult<PPOParams>,
) -> Result<(), std::io::Error> {
let trials_file = hyperopt_dir.join("trials.json");
// Read existing trials (if any)
let mut all_trials = if trials_file.exists() {
let content = std::fs::read_to_string(&trials_file)?;
serde_json::from_str::<Vec<serde_json::Value>>(&content).unwrap_or_default()
} else {
Vec::new()
};
// Append new trial
let trial_json = serde_json::to_value(trial_result)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
all_trials.push(trial_json);
// Write back to file (pretty printed)
let content = serde_json::to_string_pretty(&all_trials)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(&trials_file, content)?;
Ok(())
}
impl HyperparameterOptimizable for PPOTrainer {
type Params = PPOParams;
type Metrics = PPOMetrics;
fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
// START: Add trial timing
let trial_start = std::time::Instant::now();
// Get current trial number and increment for next trial (atomic for parallel safety)
let current_trial = self.trial_counter.fetch_add(1, Ordering::Relaxed);
// Multi-GPU: select device from pool based on trial number (round-robin)
let trial_device = self.device_pool.get(current_trial % self.device_pool.len())
.cloned()
.unwrap_or_else(|| self.device.clone());
if self.device_pool.len() > 1 {
info!("Trial {} → GPU {} of {}", current_trial,
current_trial % self.device_pool.len(), self.device_pool.len());
}
info!("Training PPO with parameters:");
info!(" Policy LR: {:.6}", params.policy_learning_rate);
info!(" Value LR: {:.6}", params.value_learning_rate);
info!(" Clip epsilon: {:.3}", params.clip_epsilon);
info!(" Value loss coeff: {:.3}", params.value_loss_coeff);
info!(" Entropy coeff: {:.6}", params.entropy_coeff);
info!(" GAE gamma: {:.4}", params.gae_gamma);
info!(" GAE lambda: {:.3}", params.gae_lambda);
info!(" Mini-batch size: {}", params.mini_batch_size);
info!(" Max grad norm: {:.3}", params.max_grad_norm);
info!(" Max position: {:.2}", params.max_position_absolute);
info!(" Clip epsilon high: {:.3} ({})", params.clip_epsilon_high,
if params.clip_epsilon_high > 0.01 { "asymmetric" } else { "symmetric" });
info!(" Curiosity weight: {:.3} ({})", params.curiosity_weight,
if params.curiosity_weight > 0.001 { "enabled" } else { "disabled" });
// Log trial start (ensure directory exists first)
std::fs::create_dir_all(self.training_paths.logs_dir()).ok();
write_training_log_ppo(
&self.training_paths.logs_dir(),
&format!("=== Starting PPO Trial ===\nParams: {:#?}", params),
)
.ok();
// Create directories
self.training_paths.create_all().map_err(|e| {
MLError::ModelError(format!("Failed to create training directories: {}", e))
})?;
info!("Training directories created:");
info!(" Logs: {:?}", self.training_paths.logs_dir());
info!(" Hyperopt: {:?}", self.training_paths.hyperopt_dir());
// Create PPO config with trial hyperparameters
let ppo_config = PPOConfig {
state_dim: 48, // 42 market + 3 portfolio = 45, aligned to 48 for tensor cores
num_actions: 63, // 5 exposure × 3 order × 3 urgency (FactoredAction)
policy_hidden_dims: {
let base = params.hidden_dim_base;
vec![base, base / 2]
},
value_hidden_dims: {
let base = params.hidden_dim_base;
vec![base * 4, base * 3, base * 2, base, base / 2]
},
policy_learning_rate: params.policy_learning_rate,
value_learning_rate: params.value_learning_rate,
clip_epsilon: params.clip_epsilon as f32,
value_loss_coeff: params.value_loss_coeff as f32,
entropy_coeff: params.entropy_coeff as f32,
gae_config: GAEConfig {
gamma: params.gae_gamma as f32,
lambda: params.gae_lambda as f32,
normalize_advantages: true,
},
batch_size: params.batch_size,
mini_batch_size: params.mini_batch_size,
num_epochs: 20,
max_grad_norm: params.max_grad_norm as f32,
early_stopping_enabled: true,
early_stopping_patience: self.early_stopping_patience,
early_stopping_min_delta: 1e-4,
early_stopping_min_epochs: self.early_stopping_min_epochs,
max_position_absolute: params.max_position_absolute,
transaction_cost_bps: self.tx_cost_bps, // Use configured cost
cash_reserve_pct: 0.20, // 20% minimum cash reserve
circuit_breaker_threshold: 5, // 5 consecutive failures
use_lstm: false, // Standard MLP networks for hyperopt (LSTM not yet integrated)
lstm_hidden_dim: 128,
lstm_num_layers: 1,
lstm_sequence_length: 32,
accumulation_steps: 1,
clip_epsilon_high: (params.clip_epsilon_high > 0.01)
.then_some(params.clip_epsilon_high as f32),
use_symlog: true,
use_adaptive_entropy: true,
use_percentile_scaling: true,
};
// Save state_dim before ppo_config is moved into PPO::with_device
let state_dim = ppo_config.state_dim;
// Create PPO agent
let mut ppo_agent = PPO::new(ppo_config)
.map_err(|e| MLError::TrainingError(format!("Failed to create PPO agent: {}", e)))?;
// Training loop (simplified for hyperopt)
let mut total_policy_loss = 0.0;
let mut total_value_loss = 0.0;
let mut total_reward = 0.0;
// Generate all trajectories upfront for train/val split
let total_trajectories = self.episodes;
// Validate minimum trajectory count for 80/20 split
if total_trajectories < 10 {
return Err(MLError::ConfigError(format!(
"Insufficient trajectories for train/val split: {} < 10 minimum",
total_trajectories
)));
}
// Split trajectories 80/20 for train/val
let split_idx = (total_trajectories as f64 * 0.8) as usize;
let num_train = split_idx;
let num_val = total_trajectories - split_idx;
// Validate non-empty validation set
if num_val < 1 {
return Err(MLError::ConfigError(format!(
"Validation set is empty after 80/20 split (train={}, val={})",
num_train, num_val
)));
}
// Warn if validation set is too small
if num_val < 5 {
warn!(
"Validation set is small ({}), may not be representative",
num_val
);
}
// Use preloaded data if available, otherwise load from disk
let training_data = if let Some(cached) = &self.preloaded_data {
info!(
"Using preloaded training data ({} samples, skipping disk I/O)",
cached.len()
);
cached.as_ref().clone()
} else {
info!(
"Loading training data from: {}",
self.dbn_data_dir.display()
);
self.load_training_data()
.map_err(|e| MLError::TrainingError(format!("Failed to load training data: {}", e)))?
};
info!("Loaded {} training samples", training_data.len());
// Split DATA 80/20 (not trajectory count -- that's different)
let data_split = (training_data.len() as f64 * 0.8) as usize;
let train_data = &training_data[..data_split];
let val_data = &training_data[data_split..];
info!(
"Train data: {} samples, Val data: {} samples (train episodes: {}, val episodes: {})",
train_data.len(),
val_data.len(),
num_train,
num_val
);
// Upload raw market data to GPU and init/sync collector
// Uses full dataset (kernel indexes by bar offset). First trial compiles
// the CUDA kernel + uploads data; subsequent trials just sync weights.
self.ensure_gpu_data(&training_data, &ppo_agent)?;
// Create ExO-PPO trajectory replay buffer (M=4 rollouts, 4x sample efficiency)
let mut replay_buffer = TrajectoryReplayBuffer::new(4, params.clip_epsilon as f32);
info!("ExO-PPO trajectory replay enabled (M=4 rollouts)");
let gae_gamma = params.gae_gamma as f32;
let gae_lambda = params.gae_lambda as f32;
// Batch episodes: min(64, num_train) to avoid zero batches with small episode counts
let batch_episodes = 64.min(num_train);
let num_batches = (num_train + batch_episodes - 1) / batch_episodes; // ceil division
for _batch_idx in 0..num_batches {
let episodes_this_batch = batch_episodes.min(num_train - _batch_idx * batch_episodes);
// GPU experience collection
let mut trajectory_batch = self.gpu_collect_trajectories(episodes_this_batch)?;
// Update PPO with on-policy trajectory batch
let (policy_loss, value_loss) = ppo_agent
.update(&mut trajectory_batch)
.map_err(|e| MLError::TrainingError(format!("PPO update failed: {}", e)))?;
total_policy_loss += policy_loss as f64;
total_value_loss += value_loss as f64;
// Store rollout in ExO-PPO replay buffer for future IS-weighted updates
let generation = replay_buffer.generation();
let flattened_states: Vec<f32> = trajectory_batch.states.iter().flatten().copied().collect();
let rollout = TrajectoryReplayBuffer::create_rollout(
flattened_states,
trajectory_batch.actions.iter().map(|a| a.to_index() as u32).collect(),
trajectory_batch.log_probs.clone(),
trajectory_batch.advantages.clone(),
trajectory_batch.returns.clone(),
state_dim,
generation,
);
replay_buffer.store_rollout(rollout);
// ExO-PPO: replay past rollouts with IS-weighted advantages
if !replay_buffer.is_empty() {
let mut replay_loss = 0.0_f64;
let replay_count = replay_buffer.len();
for stored_rollout in replay_buffer.rollouts() {
// Re-evaluate stored states under current policy to get new log-probs
let mut new_log_probs = Vec::with_capacity(stored_rollout.num_steps);
for t in 0..stored_rollout.num_steps {
if let Some(state_slice) = stored_rollout.get_state(t) {
match ppo_agent.act_with_log_prob(state_slice) {
Ok((_, lp, _)) => new_log_probs.push(lp),
Err(_) => new_log_probs.push(0.0),
}
}
}
// Compute IS-weighted surrogate loss for this stored rollout
let rollout_loss = replay_buffer.compute_rollout_loss(stored_rollout, &new_log_probs);
replay_loss += rollout_loss as f64;
}
// Weight replay loss at 50% of on-policy (off-policy data is less reliable)
if replay_count > 0 {
let avg_replay = replay_loss / replay_count as f64;
total_reward += avg_replay * 0.5; // Boost reward signal with replay
}
}
// Calculate average reward for this batch
let batch_reward: f32 = trajectory_batch.rewards.iter().sum();
total_reward += batch_reward as f64 / episodes_this_batch as f64;
}
// Compute validation losses on held-out trajectories
info!(
"Computing validation losses on {} held-out episodes...",
num_val
);
let mut val_policy_losses = Vec::new();
let mut val_value_losses = Vec::new();
// Generate validation trajectories from held-out data using current policy
// Validation uses no curiosity/shaping (evaluate pure extrinsic performance)
let mut no_curiosity = None;
let mut no_shaper = None;
let mut no_composite = None;
let mut val_trajectory_batch = self
.generate_trajectories_from_data(
&ppo_agent, val_data, num_val,
&mut no_curiosity, 0.0,
&mut no_shaper, &mut no_composite,
gae_gamma, gae_lambda, &trial_device,
)
.map_err(|e| {
MLError::TrainingError(format!("Failed to generate validation trajectories: {}", e))
})?;
// Compute validation loss WITHOUT updating policy
let (val_policy_loss, val_value_loss) = ppo_agent
.compute_losses(&mut val_trajectory_batch)
.map_err(|e| {
MLError::TrainingError(format!("Failed to compute validation losses: {}", e))
})?;
val_policy_losses.push(val_policy_loss as f64);
val_value_losses.push(val_value_loss as f64);
let avg_val_policy_loss =
val_policy_losses.iter().sum::<f64>() / val_policy_losses.len() as f64;
let avg_val_value_loss =
val_value_losses.iter().sum::<f64>() / val_value_losses.len() as f64;
let avg_policy_loss = total_policy_loss / num_batches as f64;
let avg_value_loss = total_value_loss / num_batches as f64;
let avg_reward = total_reward / num_batches as f64;
// GPU walk-forward backtest (CUDA mandatory)
let (bt_sharpe, bt_trades) = match self.run_gpu_backtest(&ppo_agent, &trial_device, &params) {
Ok((s, t)) => {
info!("PPO GPU backtest: Sharpe={:.4} trades={}", s, t);
(Some(s), Some(t))
}
Err(e) => {
return Err(MLError::TrainingError(format!("PPO GPU backtest FAILED (no CPU fallback): {e}")));
}
};
let metrics = PPOMetrics {
policy_loss: avg_policy_loss,
value_loss: avg_value_loss,
val_policy_loss: avg_val_policy_loss,
val_value_loss: avg_val_value_loss,
combined_loss: avg_policy_loss + params.value_loss_coeff * avg_value_loss,
avg_episode_reward: avg_reward,
episodes_completed: self.episodes,
backtest_sharpe: bt_sharpe,
backtest_trades: bt_trades,
};
info!("Training completed:");
info!(" Policy loss: {:.6}", metrics.policy_loss);
info!(" Value loss: {:.6}", metrics.value_loss);
info!(" Val policy loss: {:.6}", metrics.val_policy_loss);
info!(" Val value loss: {:.6}", metrics.val_value_loss);
info!(" Avg reward: {:.4}", metrics.avg_episode_reward);
// END: Add trial completion logging
let duration_secs = trial_start.elapsed().as_secs_f64();
write_training_log_ppo(
&self.training_paths.logs_dir(),
&format!(
"Training completed in {:.2}s: val_policy_loss={:.6}, val_value_loss={:.6}",
duration_secs, metrics.val_policy_loss, metrics.val_value_loss
),
)
.ok();
// CRITICAL FIX: Explicit memory cleanup to prevent OOM between trials
// Drop model and data loaders, and sync CUDA to free GPU/RAM
info!("Cleaning up resources...");
drop(ppo_agent);
drop(val_trajectory_batch);
// GPU synchronization via stream sync (forces cudaDeviceSynchronize)
if let Ok(stream) = trial_device.cuda_stream() {
stream.synchronize().map_err(|e| {
MLError::TrainingError(format!("CUDA sync failed: {}", e))
})?;
std::thread::sleep(std::time::Duration::from_millis(50));
}
info!("Resource cleanup complete");
// Write trial result to JSON (ensure directory exists first)
let trial_result = crate::hyperopt::traits::TrialResult {
trial_num: current_trial,
params,
objective: Self::extract_objective(&metrics),
duration_secs,
metrics: None,
};
std::fs::create_dir_all(self.training_paths.hyperopt_dir()).ok();
write_trial_result_ppo(&self.training_paths.hyperopt_dir(), &trial_result).ok();
Ok(metrics)
}
fn extract_objective(metrics: &Self::Metrics) -> f64 {
// Prefer GPU backtest fitness when available (walk-forward Sharpe is more
// reliable than episode reward for ranking hyperparameter trials).
if let (Some(sharpe), Some(trades)) = (metrics.backtest_sharpe, metrics.backtest_trades) {
return backtest_fitness(sharpe, trades, 30, 0.0);
}
// Fallback: maximize episode rewards (negative because optimizer MINIMIZES)
//
// We optimize for avg_episode_reward, NOT validation loss, because:
// 1. Loss minimization rewards frozen policies (policy_loss=0, KL_div=0)
// 2. Low learning rates (e.g., 1e-6) prevent learning but minimize loss
// 3. Episode rewards measure actual trading performance
//
// The optimizer minimizes this objective, so we negate rewards to maximize them.
-metrics.avg_episode_reward
}
}
/// Decode OHLCV bars from an already-opened DBN decoder.
///
/// Deduplicates by timestamp (keeping highest-volume bar) to handle overlapping
/// contracts from `.FUT` parent symbols at roll dates.
fn decode_ohlcv_bars<R: std::io::Read>(
decoder: &mut dbn::decode::dbn::Decoder<R>,
) -> anyhow::Result<Vec<crate::features::extraction::OHLCVBar>> {
use chrono::DateTime;
use dbn::decode::DecodeRecordRef;
use dbn::OhlcvMsg;
use std::collections::BTreeMap;
let mut by_ts: BTreeMap<u64, crate::features::extraction::OHLCVBar> = BTreeMap::new();
let mut total_records = 0_usize;
while let Some(record_ref) = decoder
.decode_record_ref()
.map_err(|e| anyhow::anyhow!("Failed to decode record: {}", e))?
{
if let Some(ohlcv) = record_ref.get::<OhlcvMsg>() {
total_records += 1;
let ts_nanos = ohlcv.hd.ts_event;
let volume = ohlcv.volume as f64;
let existing_vol = by_ts.get(&ts_nanos).map(|b| b.volume).unwrap_or(0.0);
if volume >= existing_vol {
by_ts.insert(
ts_nanos,
crate::features::extraction::OHLCVBar {
timestamp: DateTime::from_timestamp_nanos(ts_nanos as i64),
open: ohlcv.open as f64 / 1e9,
high: ohlcv.high as f64 / 1e9,
low: ohlcv.low as f64 / 1e9,
close: ohlcv.close as f64 / 1e9,
volume,
},
);
}
}
}
let bars: Vec<_> = by_ts.into_values().collect();
let deduped = total_records.saturating_sub(bars.len());
if deduped > 0 {
info!(" Deduplicated {}/{} bars by timestamp", deduped, total_records);
}
Ok(bars)
}
/// Recursively collect all .dbn and .dbn.zst files from a directory and its subdirectories.
fn collect_dbn_files_recursive(dir: &std::path::Path) -> Vec<std::path::PathBuf> {
let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
files.extend(collect_dbn_files_recursive(&path));
} else if path.extension().and_then(|s| s.to_str()) == Some("dbn")
|| path.to_string_lossy().ends_with(".dbn.zst")
{
files.push(path);
} else {
// Skip non-DBN files
}
}
}
files.sort();
files
}
impl PPOTrainer {
/// Run GPU walk-forward backtest on validation data using the trained PPO actor.
///
/// Uses the last 20% of preloaded bars as validation data. Builds a single
/// window, creates a `GpuBacktestEvaluator`, and calls `evaluate()` with
/// a forward function that converts PPO 45-action softmax probabilities into
/// 5-action exposure scores via `ppo_to_exposure_scores`.
///
/// Returns `(sharpe, total_trades)` from the first (and only) window.
fn run_gpu_backtest(
&self,
ppo: &PPO,
device: &MlDevice,
params: &PPOParams,
) -> Result<(f32, u32), MLError> {
let data = self.preloaded_data.as_ref().ok_or_else(|| {
MLError::ConfigError("run_gpu_backtest: no preloaded data".to_owned())
})?;
// Use last 20% of data for validation
let val_start = (data.len() as f64 * 0.8) as usize;
let val_data = data.get(val_start..).ok_or_else(|| {
MLError::ConfigError("run_gpu_backtest: val slice out of bounds".to_owned())
})?;
if val_data.len() < 50 {
return Err(MLError::ConfigError(format!(
"run_gpu_backtest: insufficient validation data ({} < 50)",
val_data.len()
)));
}
// Build price and feature vectors for a single window
let mut prices = Vec::with_capacity(val_data.len());
let mut features = Vec::with_capacity(val_data.len());
for (fv, close_price) in val_data {
let close = *close_price as f32;
prices.push([close, close, close, close]); // OHLC approximation (same as DQN)
features.push(fv.to_vec()); // cpu-side feature copy
}
let window_prices = vec![prices];
let window_features = vec![features];
// Market features only -- portfolio features (3) added by evaluator's gather_states.
let feature_dim = 42;
let config = GpuBacktestConfig {
max_position: params.max_position_absolute as f32,
tx_cost_bps: self.tx_cost_bps as f32,
spread_cost: (self.tick_size * self.spread_ticks) as f32,
initial_capital: self.initial_capital as f32,
..Default::default()
};
let stream = device.cuda_stream()
.map_err(|_| MLError::ConfigError("CUDA required for PPO backtest".to_owned()))?;
let mut evaluator = GpuBacktestEvaluator::new(
&window_prices,
&window_features,
feature_dim,
config,
stream,
)?;
// Forward function: PPO actor -> softmax -> ppo_to_exposure_scores -> [B, 5]
// Uses host-side action_probabilities API, then maps to exposure scores on GPU.
let metrics = evaluator.evaluate(
&|states_gpu: &CudaSlice<f32>, batch_size: usize, state_dim: usize| -> Result<CudaSlice<i32>, MLError> {
// Download f32 states to host for PPO forward pass
let states_host: Vec<f32> = stream.clone_dtoh(states_gpu).map_err(|e| { // gpu-exit: PPO actor forward
MLError::ModelError(format!("PPO backtest state download: {e}"))
})?;
// Run PPO actor forward to get action probabilities per sample
let mut action_indices = Vec::with_capacity(batch_size);
for b in 0..batch_size {
let start = b * state_dim;
let end = start + state_dim;
let state_slice = states_host.get(start..end).unwrap_or(&[]);
match ppo.greedy_action(state_slice) {
Ok(action) => action_indices.push(action.to_index() as i32),
Err(_) => action_indices.push(0_i32), // Flat fallback
}
}
// Upload action indices to GPU
stream.clone_htod(&action_indices).map_err(|e| {
MLError::ModelError(format!("PPO backtest action upload: {e}"))
})
},
24, // portfolio_dim: 8 portfolio + 16 multi-timeframe (matches training state layout)
)?;
let first = metrics.first().ok_or_else(|| {
MLError::ModelError("run_gpu_backtest: evaluator returned no windows".to_owned())
})?;
Ok((first.sharpe, first.total_trades as u32))
}
/// Load training data from Parquet/DBN files
///
/// Returns feature vectors (42 dims) with target close prices for trajectory generation
fn load_training_data(&self) -> anyhow::Result<Vec<([f32; 42], f64)>> {
use std::path::Path;
let dir_path = Path::new(&self.dbn_data_dir);
// Check if directory contains Parquet or DBN files
let has_parquet = std::fs::read_dir(dir_path)?
.filter_map(|entry| entry.ok())
.any(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("parquet"));
if has_parquet {
info!("Found Parquet files, loading from Parquet...");
self.load_from_parquet()
} else {
info!("No Parquet files found, loading from DBN...");
self.load_from_dbn()
}
}
/// Load training data from Parquet file (optimized path)
fn load_from_parquet(&self) -> anyhow::Result<Vec<([f32; 42], f64)>> {
use crate::features::extraction::OHLCVBar;
use arrow::array::{Array, Float64Array, PrimitiveArray, UInt64Array};
use arrow::datatypes::TimestampNanosecondType;
use arrow::record_batch::RecordBatch;
use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
use std::fs::File;
// Find first Parquet file in directory
let parquet_file = std::fs::read_dir(&self.dbn_data_dir)?
.filter_map(|entry| entry.ok())
.find(|entry| entry.path().extension().and_then(|s| s.to_str()) == Some("parquet"))
.ok_or_else(|| anyhow::anyhow!("No Parquet file found in directory"))?
.path();
info!("Loading Parquet file: {}", parquet_file.display());
let file = File::open(&parquet_file)?;
let builder = ParquetRecordBatchReaderBuilder::try_new(file)?;
let reader = builder.build()?;
let mut all_ohlcv_bars = Vec::new();
for batch_result in reader {
let batch: RecordBatch = batch_result?;
// Extract columns (try both timestamp_ns and ts_event)
let timestamp_col = batch
.column_by_name("timestamp_ns")
.or_else(|| batch.column_by_name("ts_event"))
.ok_or_else(|| anyhow::anyhow!("Missing timestamp column"))?;
let timestamps = timestamp_col
.as_any()
.downcast_ref::<PrimitiveArray<TimestampNanosecondType>>()
.ok_or_else(|| anyhow::anyhow!("Invalid timestamp column type"))?;
let opens = batch
.column_by_name("open")
.ok_or_else(|| anyhow::anyhow!("Missing 'open' column"))?
.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| anyhow::anyhow!("Invalid 'open' column type"))?;
let highs = batch
.column_by_name("high")
.ok_or_else(|| anyhow::anyhow!("Missing 'high' column"))?
.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| anyhow::anyhow!("Invalid 'high' column type"))?;
let lows = batch
.column_by_name("low")
.ok_or_else(|| anyhow::anyhow!("Missing 'low' column"))?
.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| anyhow::anyhow!("Invalid 'low' column type"))?;
let closes = batch
.column_by_name("close")
.ok_or_else(|| anyhow::anyhow!("Missing 'close' column"))?
.as_any()
.downcast_ref::<Float64Array>()
.ok_or_else(|| anyhow::anyhow!("Invalid 'close' column type"))?;
let volumes = batch
.column_by_name("volume")
.ok_or_else(|| anyhow::anyhow!("Missing 'volume' column"))?
.as_any()
.downcast_ref::<UInt64Array>()
.ok_or_else(|| anyhow::anyhow!("Invalid 'volume' column type"))?;
for i in 0..batch.num_rows() {
let timestamp_ns = timestamps.value(i);
let timestamp = chrono::DateTime::from_timestamp_nanos(timestamp_ns);
let bar = OHLCVBar {
timestamp,
open: opens.value(i),
high: highs.value(i),
low: lows.value(i),
close: closes.value(i),
volume: volumes.value(i) as f64,
};
all_ohlcv_bars.push(bar);
}
}
info!("Loaded {} OHLCV bars from Parquet", all_ohlcv_bars.len());
// Sort bars chronologically
all_ohlcv_bars.sort_by_key(|bar| bar.timestamp);
// Extract features and create training data
self.extract_features_and_targets(&all_ohlcv_bars)
}
/// Load training data from DBN files
fn load_from_dbn(&self) -> anyhow::Result<Vec<([f32; 42], f64)>> {
use dbn::decode::DbnDecoder;
let dbn_files = collect_dbn_files_recursive(std::path::Path::new(&self.dbn_data_dir));
if dbn_files.is_empty() {
return Err(anyhow::anyhow!("No DBN files found in directory"));
}
info!("Found {} DBN files to load", dbn_files.len());
let mut all_bars = Vec::new();
for dbn_file in &dbn_files {
info!("Loading DBN file: {}", dbn_file.display());
let is_zstd = dbn_file.to_string_lossy().ends_with(".dbn.zst");
let bars = if is_zstd {
let mut decoder = DbnDecoder::from_zstd_file(dbn_file)
.map_err(|e| anyhow::anyhow!("Failed to open zstd DBN file {}: {}", dbn_file.display(), e))?;
decode_ohlcv_bars(&mut decoder)?
} else {
let mut decoder = DbnDecoder::from_file(dbn_file)
.map_err(|e| anyhow::anyhow!("Failed to open DBN file {}: {}", dbn_file.display(), e))?;
decode_ohlcv_bars(&mut decoder)?
};
all_bars.extend(bars);
}
info!("Loaded {} OHLCV bars from {} DBN files", all_bars.len(), dbn_files.len());
all_bars.sort_by_key(|bar| bar.timestamp);
self.extract_features_and_targets(&all_bars)
}
/// Attempt to load OFI features from MBP10 data.
/// Returns None if MBP10 data is not available or on error.
fn load_ofi_features(&self) -> Option<Vec<[f64; 20]>> {
use crate::features::mbp10_loader::load_ofi_features_parallel;
let mbp10_dir = self
.dbn_data_dir
.parent()
.map(|p| p.join("mbp10"))?;
// Derive trades directory as sibling of the OHLCV data dir
let trades_dir = self.dbn_data_dir.parent().map(|p| p.join("trades"));
load_ofi_features_parallel(&mbp10_dir, trades_dir.as_deref(), |dir| {
collect_dbn_files_recursive(dir)
}).map(|ofi8| {
ofi8.into_iter().map(|row| {
let mut wide = [0.0_f64; 20];
wide[..8].copy_from_slice(&row);
wide
}).collect()
})
}
/// Extract 42-feature vectors and targets from OHLCV bars.
fn extract_features_and_targets(
&self,
ohlcv_bars: &[crate::features::extraction::OHLCVBar],
) -> anyhow::Result<Vec<([f32; 42], f64)>> {
use crate::features::extraction::extract_ml_features;
info!(
"Extracting 42-feature vectors from {} OHLCV bars...",
ohlcv_bars.len()
);
// Need at least 50 bars for warmup period
if ohlcv_bars.len() < 51 {
return Err(anyhow::anyhow!(
"Insufficient OHLCV bars for feature extraction: {} < 51",
ohlcv_bars.len()
));
}
// Extract features using production API (returns Vec<[f64; 42]>)
let feature_vectors = extract_ml_features(ohlcv_bars)
.map_err(|e| anyhow::anyhow!("Feature extraction failed: {}", e))?;
info!(
"Extracted {} feature vectors (42 dimensions each)",
feature_vectors.len()
);
// Load OFI features if MBP10 data is available
let ofi_features = self.load_ofi_features();
let ofi_count = ofi_features.as_ref().map_or(0, |v| v.len());
if ofi_count > 0 {
info!(
"Overlaying {} OFI features onto positions 45-52",
ofi_count
);
}
// Create training data pairs (features, target)
// Target: Next bar's close price (autoregressive prediction)
// Features are 42-dim market features (matches train_baseline)
let mut training_data = Vec::new();
for i in 0..feature_vectors.len().saturating_sub(1) {
// Target is next bar's close (skip warmup period)
let target_idx = i + 51; // +50 warmup + 1 next bar
if target_idx < ohlcv_bars.len() {
let next_close = ohlcv_bars[target_idx].close;
// Convert [f64; 42] to [f32; 42]
let mut feature_array = [0.0_f32; 42];
for (j, &val) in feature_vectors[i].iter().enumerate() {
feature_array[j] = val as f32;
}
training_data.push((feature_array, next_close));
}
}
// Note: the last feature vector is excluded because there is no "next bar"
// to compute a meaningful return against. Including it with target=self
// would create a ~0 return sample that biases the model toward HOLD.
info!(
"Created {} training samples with 42-feature market vectors",
training_data.len()
);
Ok(training_data)
}
/// Generate trajectories from real market data using the PPO agent's policy.
///
/// Creates episodes by simulating trading on market data, using the PPO
/// agent to select actions with real log-probabilities and value estimates.
/// When a curiosity module is provided, intrinsic novelty rewards are added
/// to the extrinsic reward signal, scaled by `curiosity_weight`.
fn generate_trajectories_from_data(
&self,
agent: &PPO,
data: &[([f32; 42], f64)],
num_episodes: usize,
curiosity: &mut Option<CuriosityModule>,
curiosity_weight: f64,
reward_shaper: &mut Option<PPORewardShaper>,
composite_reward: &mut Option<CompositeReward>,
gae_gamma: f32,
gae_lambda: f32,
_device: &MlDevice,
) -> anyhow::Result<TrajectoryBatch> {
use crate::ppo::trajectories::{Trajectory, TrajectoryStep};
use rand::Rng;
let mut rng = rand::thread_rng();
let mut trajectories = Vec::new();
// Episode length: use up to 100 steps or available data
let max_episode_length = 100.min(data.len() / num_episodes.max(1));
for _episode_idx in 0..num_episodes {
let mut trajectory = Trajectory::new();
// Reset per-episode state for reward components
if let Some(ref mut shaper) = reward_shaper {
shaper.reset();
}
if let Some(ref mut comp) = composite_reward {
comp.reset();
}
// Start at random position in data
let start_idx = if data.len() > max_episode_length {
rng.gen_range(0..data.len() - max_episode_length)
} else {
0
};
for step_idx in 0..max_episode_length {
let data_idx = start_idx + step_idx;
if data_idx >= data.len() {
break;
}
let (features, target_price) = &data[data_idx];
// Convert features to state vector
let state = features.to_vec(); // cpu-side feature copy
// Use PPO agent for action selection with real log_prob and value
let (action, log_prob, value) = match agent.act_with_log_prob(&state) {
Ok((a, lp, v)) => (a, lp, v),
Err(_) => {
// Fallback: random factored action with uniform log_prob
let random_idx = rng.gen_range(0..45_usize);
let a = FactoredAction::from_index(random_idx).unwrap_or_else(|_| {
FactoredAction::new(
crate::common::action::ExposureLevel::Flat,
crate::common::action::OrderType::Market,
crate::common::action::Urgency::Normal,
)
});
(a, -(45.0_f32).ln(), 0.0_f32)
}
};
// Compute reward based on price movement and action, minus transaction costs
let mut reward = if step_idx + 1 < max_episode_length && data_idx + 1 < data.len() {
let current_price = target_price;
let next_price = data.get(data_idx + 1).map(|(_, p)| *p).unwrap_or(*current_price);
let price_change = (next_price - current_price) / current_price;
// Transaction cost: commission + spread (deducted on Buy/Sell)
let spread_cost = if current_price.abs() > 1e-10 {
self.tick_size * self.spread_ticks / current_price
} else {
0.0
};
let total_cost = (self.tx_cost_bps * 0.0001 + spread_cost) as f32;
// Use target_exposure for PnL: +1.0 (long), -1.0 (short), 0.0 (flat)
let exposure = action.target_exposure() as f32;
if exposure.abs() > f32::EPSILON {
exposure * price_change as f32 - total_cost
} else {
0.0 // Flat position — no PnL
}
} else {
0.0
};
// Add intrinsic curiosity reward if enabled
if let Some(ref mut curiosity_mod) = curiosity {
if let Some((next_feat, _)) = data.get(data_idx + 1) {
// Build state tensors for curiosity module (expects [batch, 35])
// Use first 35 features from the 42-dim state vector
let state_slice: Vec<f32> = features.iter().take(35).copied().collect();
let next_slice: Vec<f32> = next_feat.iter().take(35).copied().collect();
if let Ok(intrinsic) = curiosity_mod.calculate_curiosity_reward(
&state_slice, 1, 35, action, &next_slice,
) {
reward += (curiosity_weight * intrinsic) as f32;
}
}
}
// Apply composite risk-adjusted reward (downside dev + differential return)
if let Some(ref mut comp) = composite_reward {
reward = comp.compute(reward);
}
// Apply reward shaping (hold penalty + rolling Sharpe + diversity bonus)
if let Some(ref mut shaper) = reward_shaper {
let is_flat = action.target_exposure().abs() < f64::EPSILON;
// Signal: price change exceeds 0.01% threshold
let has_signal = if step_idx + 1 < max_episode_length {
data.get(data_idx + 1)
.map(|(_, next_p)| {
let current_p = target_price;
(next_p - current_p).abs() / current_p.abs().max(1e-10) > 0.0001
})
.unwrap_or(false)
} else {
false
};
reward = shaper.shape_reward(
reward,
is_flat,
has_signal,
action.to_index(),
);
}
let done = step_idx + 1 >= max_episode_length;
let step = TrajectoryStep::new(state, action, log_prob, value, reward, done);
trajectory.add_step(step);
if done {
break;
}
}
// Mark last step as done
if let Some(last_step) = trajectory.steps.last_mut() {
last_step.done = true;
}
trajectories.push(trajectory);
}
// Compute advantages and returns using GAE (params from hyperopt search space)
let gamma = gae_gamma;
let lambda = gae_lambda;
let mut advantages = Vec::new();
let mut returns = Vec::new();
for trajectory in &trajectories {
let rewards = trajectory.get_rewards();
let values = trajectory.get_values();
let dones = trajectory.get_dones();
// Compute GAE advantages
let mut traj_advantages = Vec::new();
let mut last_gae = 0.0;
for t in (0..rewards.len()).rev() {
let reward = rewards[t];
let value = values[t];
let next_value = if t + 1 < values.len() {
values[t + 1]
} else {
0.0
};
let done = dones[t];
let mask = if done { 0.0 } else { 1.0 };
let delta = reward + gamma * next_value * mask - value;
let gae = delta + gamma * lambda * mask * last_gae;
traj_advantages.push(gae);
last_gae = gae;
}
traj_advantages.reverse();
advantages.extend(traj_advantages.clone());
// Compute returns as advantage + value
let traj_returns: Vec<f32> = traj_advantages
.iter()
.zip(values.iter())
.map(|(adv, val)| adv + val)
.collect();
returns.extend(traj_returns);
}
// Create trajectory batch
Ok(TrajectoryBatch::from_trajectories(
trajectories,
advantages,
returns,
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ppo_params_roundtrip() -> Result<(), MLError> {
let params = PPOParams {
policy_learning_rate: 3e-5,
value_learning_rate: 1e-4,
clip_epsilon: 0.2,
value_loss_coeff: 1.0,
entropy_coeff: 0.05,
batch_size: 2048,
hidden_dim_base: 128,
gae_gamma: 0.99,
gae_lambda: 0.95,
mini_batch_size: 512,
max_grad_norm: 0.5,
max_position_absolute: 2.0,
clip_epsilon_high: 0.0,
curiosity_weight: 0.0,
};
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 14, "to_continuous should produce 14D vector");
let recovered = PPOParams::from_continuous(&continuous)?;
assert!((recovered.policy_learning_rate - params.policy_learning_rate).abs() < 1e-10);
assert!((recovered.value_learning_rate - params.value_learning_rate).abs() < 1e-10);
assert!((recovered.clip_epsilon - params.clip_epsilon).abs() < 1e-10);
assert!((recovered.value_loss_coeff - params.value_loss_coeff).abs() < 1e-10);
assert!((recovered.entropy_coeff - params.entropy_coeff).abs() < 1e-10);
assert!((recovered.gae_gamma - params.gae_gamma).abs() < 1e-10);
assert!((recovered.gae_lambda - params.gae_lambda).abs() < 1e-10);
assert_eq!(recovered.mini_batch_size, params.mini_batch_size);
assert!((recovered.max_grad_norm - params.max_grad_norm).abs() < 1e-10);
assert!((recovered.max_position_absolute - params.max_position_absolute).abs() < 1e-10);
assert!((recovered.clip_epsilon_high - params.clip_epsilon_high).abs() < 1e-10);
assert!((recovered.curiosity_weight - params.curiosity_weight).abs() < 1e-10);
Ok(())
}
#[test]
fn test_ppo_params_bounds() {
let bounds = PPOParams::continuous_bounds();
assert_eq!(bounds.len(), 14); // 14D: 7 original + 6 ensemble diversity + 1 curiosity
// Check log-scale bounds are reasonable
assert!(bounds.get(0).is_some_and(|b| b.0 < b.1)); // policy_learning_rate
assert!(bounds.get(1).is_some_and(|b| b.0 < b.1)); // value_learning_rate
assert!(bounds.get(4).is_some_and(|b| b.0 < b.1)); // entropy_coeff
// Check linear bounds (original)
assert_eq!(bounds.get(2).copied(), Some((0.1, 0.3))); // clip_epsilon
assert_eq!(bounds.get(3).copied(), Some((0.5, 2.0))); // value_loss_coeff
assert_eq!(bounds.get(5).copied(), Some((512.0, 8192.0))); // batch_size
assert_eq!(bounds.get(6).copied(), Some((64.0, 4096.0))); // hidden_dim_base
// Check ensemble diversity bounds
assert_eq!(bounds.get(7).copied(), Some((0.95, 0.999))); // gae_gamma
assert_eq!(bounds.get(8).copied(), Some((0.8, 1.0))); // gae_lambda
assert_eq!(bounds.get(9).copied(), Some((128.0, 2048.0))); // mini_batch_size
assert_eq!(bounds.get(10).copied(), Some((0.1, 1.0))); // max_grad_norm
assert_eq!(bounds.get(11).copied(), Some((0.5, 3.0))); // max_position_absolute
assert_eq!(bounds.get(12).copied(), Some((0.0, 0.5))); // clip_epsilon_high
// Check curiosity bounds
assert_eq!(bounds.get(13).copied(), Some((0.0, 0.5))); // curiosity_weight
}
#[test]
fn test_param_names() {
let names = PPOParams::param_names();
assert_eq!(names.len(), 14); // 14 tunable hyperparameters (7 original + 6 ensemble diversity + 1 curiosity)
assert_eq!(names.get(0).copied(), Some("policy_learning_rate"));
assert_eq!(names.get(1).copied(), Some("value_learning_rate"));
assert_eq!(names.get(2).copied(), Some("clip_epsilon"));
assert_eq!(names.get(3).copied(), Some("value_loss_coeff"));
assert_eq!(names.get(4).copied(), Some("entropy_coeff"));
assert_eq!(names.get(5).copied(), Some("batch_size"));
assert_eq!(names.get(6).copied(), Some("hidden_dim_base"));
assert_eq!(names.get(7).copied(), Some("gae_gamma"));
assert_eq!(names.get(8).copied(), Some("gae_lambda"));
assert_eq!(names.get(9).copied(), Some("mini_batch_size"));
assert_eq!(names.get(10).copied(), Some("max_grad_norm"));
assert_eq!(names.get(11).copied(), Some("max_position_absolute"));
assert_eq!(names.get(12).copied(), Some("clip_epsilon_high"));
assert_eq!(names.get(13).copied(), Some("curiosity_weight"));
}
#[test]
fn test_objective_function_maximizes_reward() {
// Test that objective function returns NEGATIVE rewards (for maximization)
// Scenario 1: Positive reward → Negative objective (optimizer will minimize to -∞)
let metrics_positive = PPOMetrics {
policy_loss: 0.5,
value_loss: 0.3,
val_policy_loss: 0.4,
val_value_loss: 0.2,
combined_loss: 0.8,
avg_episode_reward: 100.0, // Good performance
episodes_completed: 1000,
backtest_sharpe: None,
backtest_trades: None,
};
let objective_positive = PPOTrainer::extract_objective(&metrics_positive);
assert_eq!(
objective_positive, -100.0,
"Objective should be negative of reward"
);
// Scenario 2: Negative reward → Positive objective (optimizer will avoid)
let metrics_negative = PPOMetrics {
policy_loss: 0.5,
value_loss: 0.3,
val_policy_loss: 0.4,
val_value_loss: 0.2,
combined_loss: 0.8,
avg_episode_reward: -50.0, // Poor performance
episodes_completed: 1000,
backtest_sharpe: None,
backtest_trades: None,
};
let objective_negative = PPOTrainer::extract_objective(&metrics_negative);
assert_eq!(
objective_negative, 50.0,
"Negative reward should give positive objective"
);
// Scenario 3: Zero reward
let metrics_zero = PPOMetrics {
policy_loss: 0.0,
value_loss: 0.0,
val_policy_loss: 0.0,
val_value_loss: 0.0,
combined_loss: 0.0,
avg_episode_reward: 0.0, // Neutral performance
episodes_completed: 1000,
backtest_sharpe: None,
backtest_trades: None,
};
let objective_zero = PPOTrainer::extract_objective(&metrics_zero);
assert_eq!(
objective_zero, 0.0,
"Zero reward should give zero objective"
);
// Verify that higher rewards result in LOWER objectives (better for minimization)
assert!(
objective_positive < objective_zero,
"Higher reward should have lower objective"
);
assert!(
objective_zero < objective_negative,
"Negative reward should have higher objective"
);
}
#[test]
fn test_objective_ignores_loss_metrics() {
// Verify that objective function does NOT use loss metrics
// This ensures we're not accidentally rewarding frozen policies
let metrics_high_reward_high_loss = PPOMetrics {
policy_loss: 10.0, // High loss
value_loss: 10.0, // High loss
val_policy_loss: 10.0, // High validation loss
val_value_loss: 10.0, // High validation loss
combined_loss: 40.0, // High combined loss
avg_episode_reward: 200.0, // But high reward (good trading)
episodes_completed: 1000,
backtest_sharpe: None,
backtest_trades: None,
};
let metrics_low_reward_low_loss = PPOMetrics {
policy_loss: 0.0, // Zero loss (frozen policy)
value_loss: 0.0, // Zero loss
val_policy_loss: 0.0, // Zero validation loss
val_value_loss: 0.0, // Zero validation loss
combined_loss: 0.0, // Zero combined loss
avg_episode_reward: 10.0, // But low reward (poor trading)
episodes_completed: 1000,
backtest_sharpe: None,
backtest_trades: None,
};
let obj_high_reward = PPOTrainer::extract_objective(&metrics_high_reward_high_loss);
let obj_low_reward = PPOTrainer::extract_objective(&metrics_low_reward_low_loss);
// High reward should be preferred (lower objective) despite high loss
assert!(obj_high_reward < obj_low_reward,
"High reward should be preferred over low loss. Got: high_reward_obj={}, low_loss_obj={}",
obj_high_reward, obj_low_reward);
}
#[test]
fn test_objective_uses_backtest_fitness_when_available() {
// When GPU backtest results are available, extract_objective should use
// backtest_fitness instead of -avg_episode_reward.
let metrics_with_backtest = PPOMetrics {
policy_loss: 0.5,
value_loss: 0.3,
val_policy_loss: 0.4,
val_value_loss: 0.2,
combined_loss: 0.8,
avg_episode_reward: 100.0,
episodes_completed: 1000,
backtest_sharpe: Some(1.5),
backtest_trades: Some(50),
};
let obj = PPOTrainer::extract_objective(&metrics_with_backtest);
// backtest_fitness(1.5, 50, 30, 0.0) = -1.5 (trades >= min_trades)
assert!((obj - (-1.5)).abs() < 1e-9,
"Expected backtest_fitness(-1.5), got {obj}");
// Verify fallback: without backtest, should be -avg_episode_reward
let metrics_no_backtest = PPOMetrics {
backtest_sharpe: None,
backtest_trades: None,
..metrics_with_backtest.clone()
};
let obj_fallback = PPOTrainer::extract_objective(&metrics_no_backtest);
assert!((obj_fallback - (-100.0)).abs() < 1e-9,
"Expected -avg_episode_reward (-100.0), got {obj_fallback}");
}
#[test]
fn test_objective_backtest_few_trades_penalty() {
// When trades < min_trades, backtest_fitness applies a linear penalty
let metrics = PPOMetrics {
policy_loss: 0.0,
value_loss: 0.0,
val_policy_loss: 0.0,
val_value_loss: 0.0,
combined_loss: 0.0,
avg_episode_reward: 0.0,
episodes_completed: 100,
backtest_sharpe: Some(2.0),
backtest_trades: Some(15), // half of min_trades=30
};
let obj = PPOTrainer::extract_objective(&metrics);
// backtest_fitness(2.0, 15, 30, 0.0) = -2.0 * (15/30) = -1.0
assert!((obj - (-1.0)).abs() < 1e-9,
"Expected penalized fitness (-1.0), got {obj}");
}
#[test]
fn test_curiosity_weight_in_params() -> Result<(), MLError> {
let params = PPOParams {
curiosity_weight: 0.25,
..Default::default()
};
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 14);
let recovered = PPOParams::from_continuous(&continuous)?;
assert!((recovered.curiosity_weight - 0.25).abs() < 1e-10);
Ok(())
}
}