Here are the step-by-step code modifications to implement 45-action space support. 1. **Update `ml/src/dqn/dqn.rs`** to support a configurable action space size in the core DQN model. This involves making diagnostics and entropy calculations aware of the action space dimensionality. 2. **Update `ml/src/trainers/dqn.rs`** to pass the action space configuration down to the DQN model and adjust related logic. 3. **Update `ml/src/hyperopt/adapters/dqn.rs`** to expose the action space configuration at the hyperparameter optimization level. ```rust ... context_start_text: /// Configuration for the working `DQN` #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkingDQNConfig { /// State dimension pub state_dim: usize, /// Number of actions pub num_actions: usize, /// Use 45-action space (5x3x3) instead of 3-action (Buy/Sell/Hold) pub use_45_action_space: bool, /// Hidden layer dimensions pub hidden_dims: Vec, /// Learning rate ... context_end_text: pub warmup_steps: usize, } impl WorkingDQNConfig { ... context_start_text: pub fn emergency_safe_defaults() -> Self { tracing::error!("Using emergency DQN defaults - check configuration system immediately!"); Self { state_dim: 32, // Smaller state space num_actions: 3, // Conservative action space use_45_action_space: false, // Default to 3-action space for safety hidden_dims: vec![256, 128, 64], // Larger network (Wave 10-A1: prevents gradient collapse) learning_rate: 1e-5, // Very conservative learning rate gamma: 0.9, // Conservative discount factor ... context_end_text: warmup_steps: 0, // No warmup for emergency mode (safety first) } } } ... context_start_text: /// Log Q-values for the first state in batch (Wave 10-A4 diagnostic monitoring) fn log_q_values(&self, states_tensor: &Tensor) -> Result<(), MLError> { // Get Q-values for first state in batch let first_state = states_tensor.i(0)?; let first_state = first_state.unsqueeze(0)?; // Add batch dimension let q_values = self.q_network.forward(&first_state)?; if self.config.num_actions == 3 { // Extract Q-values for each action let q_buy = q_values.i((0, 0))?.to_scalar::()?; let q_sell = q_values.i((0, 1))?.to_scalar::()?; let q_hold = q_values.i((0, 2))?.to_scalar::()?; tracing::info!( "Step {} Q-values: BUY={:.6}, SELL={:.6}, HOLD={:.6}", self.training_steps, q_buy, q_sell, q_hold ); // Alert if Q-value collapse detected (all Q-values near zero) if q_buy.abs() < 0.0001 && q_sell.abs() < 0.0001 && q_hold.abs() < 0.0001 { tracing::warn!( "⚠️ Q-VALUE COLLAPSE DETECTED at step {}: BUY={:.6}, SELL={:.6}, HOLD={:.6}", self.training_steps, q_buy, q_sell, q_hold ); } } else { // For 45-action space, log top 3 Q-values let q_vec = q_values.squeeze(0)?.to_vec1::()?; let mut indexed_q: Vec<(usize, f32)> = q_vec.into_iter().enumerate().collect(); indexed_q.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); let mut log_str = format!("Step {} Top 3 Q-values:", self.training_steps); for (i, (idx, q_val)) in indexed_q.iter().take(3).enumerate() { if let Ok(action) = FactoredAction::from_index(*idx) { log_str.push_str(&format!(" {}. {:?}:{:.6}", i + 1, action, q_val)); } } tracing::info!("{}", log_str); } Ok(()) } /// Detect dead neurons and log comprehensive diagnostics (Wave 10-A4) ... context_start_text: /// Calculate entropy-based diversity penalty from recent actions /// Returns a penalty tensor (negative entropy encourages diversity) fn calculate_entropy_penalty(&self) -> Result { if self.recent_actions.is_empty() { return Tensor::zeros(&[], DType::F32, &self.device) .map_err(|e| MLError::ModelError(format!("Failed to create zero penalty: {}", e))); } // Count action frequencies based on the configured action space let counts: Vec = if self.config.use_45_action_space { let mut c = vec![0; self.config.num_actions]; for action in &self.recent_actions { c[action.to_index()] += 1; } c } else { let mut c = vec![0; 3]; for action in &self.recent_actions { c[action.to_legacy_action() as usize] += 1; } c }; // Calculate Shannon entropy: H = -Σ(p_i * log2(p_i)) let total = self.recent_actions.len() as f64; let mut entropy = 0.0_f64; for &count in &counts { if count > 0 { let p = count as f64 / total; entropy -= p * p.log2(); } } // Return negative entropy as penalty (lower entropy = higher penalty) // This encourages the agent to maximize entropy (balanced actions) let penalty = -entropy as f32; Tensor::from_vec(vec![penalty], &[], &self.device).map_err(|e| { MLError::ModelError(format!("Failed to create entropy penalty tensor: {}", e)) }) } /// Update exploration epsilon (called once per epoch by trainer) ... ``` ```rust ... context_start_text: /// DQN training hyperparameters from gRPC request #[derive(Debug, Clone)] pub struct DQNHyperparameters { /// Learning rate (typically 1e-4 to 1e-3) pub learning_rate: f64, /// Batch size (must be ≤230 for RTX 3050 Ti 4GB) pub batch_size: usize, /// Discount factor (typically 0.95-0.99) pub gamma: f64, /// Initial exploration rate pub epsilon_start: f64, /// Final exploration rate pub epsilon_end: f64, /// Exploration decay rate pub epsilon_decay: f64, /// Replay buffer capacity pub buffer_size: usize, /// Minimum replay buffer size before training starts pub min_replay_size: usize, /// Number of training epochs pub epochs: usize, /// Checkpoint save frequency (epochs) pub checkpoint_frequency: usize, /// Enable early stopping based on convergence criteria pub early_stopping_enabled: bool, /// Minimum Q-value threshold before stopping (default: 0.5) pub q_value_floor: f64, /// Minimum loss improvement percentage over window (default: 2.0%) pub min_loss_improvement_pct: f64, /// Window size for plateau detection (default: 30 epochs) pub plateau_window: usize, /// Minimum epochs before early stopping can trigger (default: 50) pub min_epochs_before_stopping: usize, /// Small negative penalty encourages action diversity (Bug #3 fix) pub hold_penalty: f64, /// Use Huber loss instead of MSE (more robust to outliers) pub use_huber_loss: bool, /// Huber loss delta threshold (default: 1.0) pub huber_delta: f64, /// Use Double DQN to reduce overestimation bias pub use_double_dqn: bool, /// Gradient clipping max norm (None = disabled) pub gradient_clip_norm: Option, /// HOLD action penalty weight (penalizes holding during large price movements) pub hold_penalty_weight: f64, /// Price movement threshold for HOLD penalty (as fraction, e.g., 0.02 = 2%) pub movement_threshold: f64, /// Enable preprocessing (log returns + normalization + outlier clipping) pub enable_preprocessing: bool, /// Preprocessing window size (default: 50) pub preprocessing_window: i64, /// Preprocessing clip sigma (default: 5.0) pub preprocessing_clip_sigma: f64, // WAVE 16 (Agent 36): Target update configuration /// Polyak averaging coefficient for soft target updates (default: 0.001) /// Rainbow DQN standard: τ=0.001 gives 693-step convergence half-life pub tau: f64, /// Target update mode: Soft (Polyak averaging) or Hard (periodic full copy) pub target_update_mode: crate::trainers::TargetUpdateMode, /// Target network hard update frequency in training steps (default: 10000) /// Used when target_update_mode = Hard. Rainbow DQN: 32K frames, Stable Baselines3: 10K steps pub target_update_frequency: usize, // Rainbow DQN warmup period /// Warmup steps for random exploration (Rainbow DQN standard: 80K for 50M+ steps) /// For short training (<200K steps), warmup=0 is recommended. /// Adaptive CLI defaults: 0 (<200K), 5% (200K-500K), 8% (500K-1M), 80K (>1M) pub warmup_steps: usize, /// Use 45-action space (5x3x3) instead of 3-action (Buy/Sell/Hold) pub use_45_action_space: bool, // P2-A Enhancement: Initial capital for portfolio /// Initial capital for portfolio trading (default: $100,000) /// Minimum: $1,000 (validated at CLI layer) ... context_end_text: pub max_position_absolute: f64, } // REMOVED: Default implementation removed to force explicit hyperparameter specification. ... context_start_text: pub fn conservative() -> Self { Self { learning_rate: 0.0001, batch_size: 128, gamma: 0.99, epsilon_start: 1.0, epsilon_end: 0.01, epsilon_decay: 0.995, buffer_size: 100000, min_replay_size: 1000, epochs: 100, checkpoint_frequency: 10, early_stopping_enabled: true, q_value_floor: 0.5, min_loss_improvement_pct: 2.0, plateau_window: 30, min_epochs_before_stopping: 50, hold_penalty: -0.001, use_huber_loss: true, // Default: Huber loss enabled (more robust) huber_delta: 1.0, // Default: delta=1.0 (standard for trading) use_double_dqn: true, // Default: Double DQN enabled (reduces overestimation) gradient_clip_norm: Some(10.0), // Default: gradient clipping enabled (prevents explosions) hold_penalty_weight: 0.01, // Default: 1% penalty weight movement_threshold: 0.02, // Default: 2% price movement threshold enable_preprocessing: true, // Default: preprocessing enabled (Wave 14 Agent 32) preprocessing_window: 50, // Default: 50-bar rolling window preprocessing_clip_sigma: 5.0, // Default: clip at ±5σ // WAVE 16 (Agent 36): Target update defaults (REVERTED to Hard updates for stability) tau: 1.0, // No Polyak averaging (hard updates) target_update_mode: crate::trainers::TargetUpdateMode::Hard, // Hard updates (original DQN standard) target_update_frequency: 10000, // Hard update frequency: 10K steps // Rainbow DQN warmup warmup_steps: 0, // Adaptive in CLI (0 for <200K, scaled 200K-1M, 80K for >1M) use_45_action_space: false, // Default to 3-action space // P2-A Enhancement initial_capital: 100_000.0, // $100K default // P2-B Enhancement ... context_end_text: max_position_absolute: 2.0, // Default: ±2.0 position limit (matches production) } } } ... context_start_text: /// Run all validations fn validate_all(&mut self) -> Result<()> { self.validate_rewards()?; self.validate_action_diversity()?; // self.validate_q_value_balance()?; // TODO: Generalize for 45 actions self.log_action_distribution(); Ok(()) } } ... context_start_text: let device = Device::cuda_if_available(0) .map_err(|e| anyhow::anyhow!("Failed to initialize device: {}", e))?; let num_actions = if hyperparams.use_45_action_space { 45 } else { 3 }; info!( "Initializing DQN trainer on device: {:?}, using {} actions", if device.is_cuda() { "CUDA GPU" } else { "CPU" }, num_actions ); // Create DQN configuration // WAVE 16D: Reduced from 225 to 128 features (125 market + 3 portfolio) // The feature vector passed to the model is ALWAYS 128 dimensions // Portfolio features are populated via PortfolioTracker (Bug #2 fix) let config = WorkingDQNConfig { state_dim: 128, // 128-feature vectors (125 market + 3 portfolio) num_actions, use_45_action_space: hyperparams.use_45_action_space, hidden_dims: vec![256, 128, 64], // Larger 3-layer network (Wave 10-A1: 4x capacity to prevent gradient collapse) learning_rate: hyperparams.learning_rate, gamma: hyperparams.gamma as f32, ... ``` ```rust ... context_start_text: #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct DQNParams { /// Learning rate for Adam optimizer (log-scale) pub learning_rate: f64, /// Batch size for training (linear scale, integer, max 230 for RTX 3050 Ti) pub batch_size: usize, /// Discount factor for future rewards (linear scale) pub gamma: f64, /// Replay buffer capacity (log-scale) pub buffer_size: usize, /// HOLD penalty weight (linear scale: 0.5 - 5.0 for HFT active trading) pub hold_penalty_weight: f64, // movement_threshold removed - now fixed at 0.02 (2%) to align with production /// Maximum absolute position size for action masking (1.0-10.0 contracts) /// BLOCKER #2: Exposes position limits to hyperopt for optimization pub max_position_absolute: f64, /// Use 45-action space (5x3x3) instead of 3-action (Buy/Sell/Hold) #[serde(default)] pub use_45_action_space: bool, } impl Default for DQNParams { fn default() -> Self { ... context_end_text: hold_penalty_weight: 2.0, // User-discovered optimal value max_position_absolute: 2.0, // BLOCKER #2: Default matches production (±2.0) use_45_action_space: false, } } } ... context_start_text: pub struct DQNTrainer { dbn_data_dir: PathBuf, epochs: usize, buffer_size_max: usize, runtime_handle: Option, training_paths: TrainingPaths, device: candle_core::Device, // Initialize CUDA early like MAMBA-2 /// Early stopping plateau window (epochs to check for improvement) early_stopping_plateau_window: usize, /// Early stopping minimum epochs (minimum epochs before early stopping can trigger) early_stopping_min_epochs: usize, /// Trial counter for checkpoint naming (incremented on each train_with_params call) trial_counter: usize, /// WAVE 16 (Agent 38): Polyak averaging coefficient for soft target updates tau: f64, /// WAVE 16 (Agent 38): Target update mode (Soft or Hard) target_update_mode: crate::trainers::TargetUpdateMode, /// Target network hard update frequency (steps) target_update_frequency: usize, /// WAVE 16 (Agent 38): Enable preprocessing (log returns + normalization) enable_preprocessing: bool, /// WAVE 16 (Agent 38): Preprocessing window size preprocessing_window: i64, /// WAVE 16 (Agent 38): Preprocessing clip sigma preprocessing_clip_sigma: f64, /// Enable backtest metrics calculation (Sharpe-based optimization) enable_backtest: bool, /// Use 45-action space instead of 3-action space use_45_action_space: bool, } impl DQNTrainer { /// Create a new DQN trainer ... context_end_text: // Use temporary default paths - should be replaced with with_training_paths() let training_paths = TrainingPaths::new("/tmp/ml_training", "dqn", "default"); Ok(Self { dbn_data_dir, epochs, buffer_size_max, runtime_handle, training_paths, device, early_stopping_plateau_window: 5, // Default: 5 epochs (hyperopt optimized) early_stopping_min_epochs: 1000, // Default: 1000 (effectively disabled - Wave 7 validation) trial_counter: 0, // Start at trial 0 // WAVE 16 (Agent 38): Switched to Hard updates for stability tau: 1.0, // Hard updates use full copy target_update_mode: crate::trainers::TargetUpdateMode::Hard, // Hard updates (Stable Baselines3) target_update_frequency: 10000, // Stable Baselines3 standard: 10K steps enable_preprocessing: true, // Preprocessing enabled by default (Wave 14) preprocessing_window: 50, // Default: 50-bar rolling window preprocessing_clip_sigma: 5.0, // Default: clip at ±5σ enable_backtest: true, // Wave 8: Backtest integration operational - enabled by default use_45_action_space: false, // Default to 3-action space }) } /// Set maximum buffer size (for 4GB GPU memory constraints) ... context_end_text: pub fn with_backtest(mut self, enable: bool) -> Self { self.enable_backtest = enable; self } /// Enable 45-action space (5x3x3 factored) instead of 3-action (Buy/Sell/Hold) pub fn with_45_action_space(mut self, use_45: bool) -> Self { self.use_45_action_space = use_45; self } /// Load training data from Parquet or DBN files (auto-detect) /// /// This method checks if the data directory contains Parquet files, ... context_start_text: impl HyperparameterOptimizable for DQNTrainer { type Params = DQNParams; type Metrics = DQNMetrics; fn train_with_params(&mut self, mut params: Self::Params) -> Result { // Configure action space for this trial params.use_45_action_space = self.use_45_action_space; // START: Add trial timing let trial_start = std::time::Instant::now(); ... context_end_text: // High LR causes larger gradient updates, needs tighter clipping to prevent explosions let _gradient_clip_norm = if params.learning_rate > 1e-4 { 5.0 // Tighter clipping for high LR } else { 10.0 // Standard clipping for low LR }; // Create DQN hyperparameters from optimization params let hyperparams = DQNHyperparameters { learning_rate: params.learning_rate, batch_size: params.batch_size, gamma: params.gamma, epsilon_start: 0.3, // Wave 11 certified: 70% exploitation from epoch 1 epsilon_end: 0.05, // Wave 11 certified: 5% minimum exploration epsilon_decay: 0.995, // Wave 11 certified: Reaches 28% after 10 epochs (FIXED, not optimized) buffer_size: clamped_buffer_size, min_replay_size: params.batch_size * 2, // Need at least 2x batch size epochs: self.epochs, checkpoint_frequency: (self.epochs / 5).max(1), // Save 5 checkpoints per trial, min 1 early_stopping_enabled: true, q_value_floor: 0.5, // Aligned with production (train_dqn.rs default) min_loss_improvement_pct: 2.0, plateau_window: self.early_stopping_plateau_window, min_epochs_before_stopping: self.early_stopping_min_epochs, hold_penalty: -0.001, // Aligned with production (train_dqn.rs:285) // WAVE 1 AGENT 3: Huber loss configuration (matches production) use_huber_loss: true, // CRITICAL: Must match production (--use-huber-loss) huber_delta: 1.0, // CRITICAL: Must match production (--huber-delta 1.0) use_double_dqn: true, // Production feature: --use-double-dqn gradient_clip_norm: Some(10.0), // Aligned with production (fixed at 10.0, train_dqn.rs:287) hold_penalty_weight: params.hold_penalty_weight, // Use optimized value from search movement_threshold: 0.02, // Aligned with production (fixed at 2%, train_dqn.rs:296) // WAVE 16 (Agent 38): Use stored configuration values (allow CLI overrides) enable_preprocessing: self.enable_preprocessing, preprocessing_window: self.preprocessing_window, preprocessing_clip_sigma: self.preprocessing_clip_sigma, tau: self.tau, target_update_mode: self.target_update_mode.clone(), target_update_frequency: self.target_update_frequency, warmup_steps: 0, // MANDATORY: Hyperopt trials are short (~50 epochs = 70K steps) // 80K warmup would consume >100% of training - catastrophic. // Adaptive CLI handles this automatically for production. use_45_action_space: params.use_45_action_space, // P2-A Enhancement initial_capital: 100_000.0, // Default: $100K (same as production) cash_reserve_percent: 0.0, // P2-B: Default no reserve for hyperopt (can be added to search space later) ... context_end_text: mod tests { use super::*; #[test] fn test_dqn_params_roundtrip() { let params = DQNParams { learning_rate: 0.0001, batch_size: 128, gamma: 0.99, buffer_size: 100_000, hold_penalty_weight: 0.5, // WAVE 13: Adjusted from 2.0 to 0.5 max_position_absolute: 2.0, // BLOCKER #2: Default value use_45_action_space: true, // Test with 45 actions enabled }; let continuous = params.to_continuous(); let recovered = DQNParams::from_continuous(&continuous).unwrap(); ... ```