//! DQN Hyperparameter Optimization Adapter (Two-Phase)
//!
//! This module provides a production-ready adapter for optimizing DQN
//! hyperparameters using the generic optimization framework. It implements:
//!
//! - 45D continuous parameter space with log-scale handling
//! - Training wrapper that integrates with existing DQN pipeline
//! - Backtest-based objective using Sharpe ratio (production metric)
//! - Prioritized Experience Replay (PER) tuning for Rainbow DQN performance (+25-40% convergence speed)
//!
//! ## Optimization Objective
//!
//! **WAVE 17**: This adapter maximizes `backtest_sharpe_ratio` (production metric).
//! The objective uses a weighted combination:
//! - 60% Exponential Sharpe+Drawdown incentive (production metric)
//! - 25% HFT activity score (rewards active BUY/SELL trading)
//! - 15% Stability penalty (penalizes gradient explosion)
//!
//! Fallback to avg_episode_reward only if backtest is disabled or fails.
//!
//! ## Usage Example
//!
//! ```rust,no_run
//! use ml::hyperopt::ArgminOptimizer;
//! use ml::hyperopt::adapters::dqn::{DQNTrainer, DQNParams};
//!
//! # async fn example() -> anyhow::Result<()> {
//! // Create trainer
//! let trainer = DQNTrainer::new(
//!     "test_data/real/databento/ml_training/",
//!     100, // epochs per trial
//! )?;
//!
//! // Run optimization
//! let optimizer = ArgminOptimizer::with_trials(30, 5);
//! let result = optimizer.optimize(trainer)?;
//!
//! println!("Best learning rate: {}", result.best_params.learning_rate);
//! println!("Best batch size: {}", result.best_params.batch_size);
//! println!("Best episode reward: {:.6}", -result.best_objective);  // Negate to get actual reward
//! # Ok(())
//! # }
//! ```

use ml_core::device::MlDevice;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write as IoWrite;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use tracing::{debug, info, warn};
use chrono::Utc;

use crate::hyperopt::paths::TrainingPaths;
use crate::hyperopt::traits::{HardwareBudget, HyperparameterOptimizable, ParameterSpace};
use crate::features::extraction::FeatureVector;
use crate::trainers::dqn::{DQNHyperparameters, DQNTrainer as InternalDQNTrainer};
use crate::MLError;

// Re-export ObjectiveMode from ml-hyperopt (canonical definition)
pub use crate::hyperopt::ObjectiveMode;

/// Backtest metrics from EvaluationEngine
///
/// Tracks comprehensive trading performance metrics including Sharpe ratio,
/// win rate, and drawdown from actual trade execution on validation data.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BacktestMetrics {
    /// Sharpe ratio (risk-adjusted return)
    pub sharpe_ratio: f64,
    /// Win rate (percentage of profitable trades)
    pub win_rate: f64,
    /// Maximum drawdown as percentage
    pub max_drawdown_pct: f64,
    /// Total return as percentage
    pub total_return_pct: f64,
    /// Total number of trades executed
    pub total_trades: usize,
    /// Sortino ratio (measures return against downside deviation)
    pub sortino_ratio: f64,
    /// Calmar ratio (measures return against maximum drawdown)
    pub calmar_ratio: f64,
    /// Value at Risk 95% (estimates potential loss)
    pub var_95: f64,
    /// Conditional Value at Risk 95% (average loss beyond VaR)
    pub cvar_95: f64,
    /// Beta (market correlation - 0.0 if no benchmark)
    pub beta: f64,
    /// Alpha (excess return over benchmark - 0.0 if no benchmark)
    pub alpha: f64,
    /// Information Ratio (risk-adjusted excess return - 0.0 if no benchmark)
    pub information_ratio: f64,
    /// Omega Ratio (upside vs. downside potential)
    pub omega_ratio: f64,
    /// Number of unique actions used during eval (out of 5 exposure actions)
    pub unique_actions: usize,
    /// Buy action percentage in backtest [0.0, 1.0] (Long50 + Long100)
    pub buy_action_pct: f64,
    /// Sell action percentage in backtest [0.0, 1.0] (Short100 + Short50)
    pub sell_action_pct: f64,
    /// Hold action percentage in backtest [0.0, 1.0] (Flat)
    pub hold_action_pct: f64,
}

/// Best hyperopt trial result for JSON export/import
///
/// Stores optimal hyperparameters and performance metrics from hyperopt campaign.
/// Enables saving/loading best configurations across context limits.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BestTrialExport {
    /// Trial number from hyperopt campaign
    pub trial_number: usize,
    /// Sharpe ratio (primary production metric)
    pub sharpe: f64,
    /// Win rate percentage
    pub win_rate: f64,
    /// Maximum drawdown percentage
    pub max_drawdown: f64,
    /// Total return percentage
    pub total_return: f64,
    /// Optimal hyperparameters found
    pub hyperparameters: DQNParams,
    /// Timestamp when trial completed
    pub timestamp: String,
    /// Gradient clipping norm
    pub gradient_clip_norm: f64,
}

/// Pure model VRAM in MB (main + target networks + optimizer + gradients).
/// Used for single-model batch-size capping in `continuous_bounds_for`.
/// [54->4096->2048->1024->45] ~ 12M params, ~200MB with Adam optimizer.
const MODEL_OVERHEAD_MB: f64 = 200.0;

/// Total per-trial VRAM in MB during concurrent hyperopt.
/// Includes model, GPU replay buffer (100K×185×4B×2), experience collector
/// (128 eps × 500 steps), CUDA per-trial allocations, and memory fragmentation.
/// Empirically derived: 5 DQN trials fit on L40S 46GB (36.8 GB usable after
/// 20% safety margin → ~7 GB/trial). The VRAM watchdog reduces concurrency
/// if free memory drops below 10%, so this estimate can be slightly aggressive.
const TRIAL_VRAM_MB: f64 = 7000.0;
/// DQN per-sample memory in MB (45 features × 4B ≈ 0.18KB per sample)
/// Corrected: was 0.02 (20KB) — 100x too high
const MB_PER_SAMPLE: f64 = 0.0005;

/// DQN hyperparameter space (45D continuous)
///
/// Fixed params (not in search space):
/// - noisy_epsilon_floor: 0.10 (minimum random exploration to prevent action collapse)
///
/// Tuned params restored in C1-C4:
/// - entropy_coefficient: (0.05, 0.5) — 10x wider for Q-values ~7.0
/// - weight_decay: log(1e-4, 1e-2) — 10x stronger to prevent overfitting
/// - count_bonus_coefficient: (0.0, 0.3) — UCB exploration bonus, complementary to NoisyNet
/// - sharpe_weight: (0.0, 0.5) — tunable Sharpe ratio blending in composite reward
///
/// Composite reward weights (indices 31-38):
/// - w_dsr: DSR (Sharpe) weight
/// - w_pnl: Normalized PnL weight
/// - w_dd: Drawdown penalty weight
/// - w_idle: Idle penalty weight (replaces hold_penalty_weight)
/// - dd_threshold: Drawdown tolerance before penalty
/// - loss_aversion: Asymmetric loss scaling (prospect theory)
/// - time_decay_rate: Position staleness rent per step
///
/// ## Parameter Scaling
///
/// - **Log-scale**: Learning rate, buffer_size, huber_delta, noisy_sigma_init, tau, weight_decay
/// - **Linear scale**: Batch size, gamma, max_position, entropy, tx_cost, per_alpha, per_beta, v_min, v_max, dueling_hidden_dim, n_steps, num_atoms, reward weights
///
/// This scaling ensures efficient exploration by egobox's Bayesian optimization.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
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,
    // movement_threshold removed - now fixed at 0.02 (2%) to align with production

    /// Maximum absolute position size for action masking (4.0-8.0 contracts) - OPTIMIZED from [1.0, 10.0]
    /// BLOCKER #2: Exposes position limits to hyperopt for optimization
    pub max_position_absolute: f64,

    /// Huber loss delta (0.1-2.0, log scale) - controls MSE→MAE transition
    pub huber_delta: f64,

    /// Entropy regularization coefficient (0.005-0.05) - SAC-style diversity regularization
    pub entropy_coefficient: f64,

    /// Transaction cost multiplier (0.5-2.0) - fee sensitivity
    pub transaction_cost_multiplier: f64,

    /// PER alpha: prioritization exponent (0.4-0.8)
    /// Controls how much to prioritize high TD-error transitions
    /// Rainbow DQN standard: 0.6, Range: 0.4 (conservative) to 0.8 (aggressive)
    pub per_alpha: f64,

    /// PER beta start: importance sampling correction exponent (0.2-0.6)
    /// Anneals from beta_start to 1.0 over training to remove bias
    /// Rainbow DQN standard: 0.4, Range: 0.2 (weak correction) to 0.6 (strong correction)
    pub per_beta_start: f64,

    /// Hidden dimension for dueling value/advantage streams (64-256)
    /// Wave 6.3: Tunable dueling architecture capacity
    pub dueling_hidden_dim: usize,

    /// Multi-step return horizon (1-10 steps)
    /// Wave 2.2: N-step TD for faster credit assignment
    /// Default: 1 (standard TD), Rainbow uses 3
    pub n_steps: usize,

    /// Soft target update coefficient (0.0001-0.01)
    /// Wave 2.2: Polyak averaging rate for target network
    /// Default: 0.001 (Rainbow standard)
    pub tau: f64,

    /// Number of atoms for distributional RL (21, 51, 101)
    /// Wave 2.3: Distribution resolution (more atoms = finer granularity)
    /// Rainbow DQN standard: 51
    pub num_atoms: usize,

    /// Minimum value for distributional support (always -v_max for symmetry)
    /// Wave 2.3 / Q-freeze fix: Symmetric support prevents init bias
    pub v_min: f64,

    /// Maximum value for distributional support (5 to 30)
    /// Wave 2.3 / Q-freeze fix: v_range = v_max, v_min = -v_max
    pub v_max: f64,

    /// Initial noise parameter for NoisyNets (0.1-1.0)
    /// Wave 2.4: Controls exploration magnitude
    /// Rainbow DQN standard: 0.5
    pub noisy_sigma_init: f64,

    /// Minimum profit threshold (1.1-2.0)
    /// BUG #7: Profit must exceed cost by this factor (1.5 = 50% margin above breakeven)
    /// Protects against marginal trades vulnerable to slippage
    pub minimum_profit_factor: f64,

    /// Weight decay (L2 regularization) for Adam optimizer (log-scale: 1e-5 to 1e-3)
    /// Prevents overfitting by penalizing large weights
    pub weight_decay: f64,

    // WAVE 19: Tunable Kelly risk parameters
    pub kelly_fractional: f64,
    pub kelly_max_fraction: f64,
    pub kelly_min_trades: usize,
    pub volatility_window: usize,

    // WAVE 26 P1.4: Ensemble Uncertainty Exploration (5D hyperopt expansion)
    /// Enable ensemble uncertainty-based exploration
    pub use_ensemble_uncertainty: bool,
    /// Number of Q-network heads in ensemble (3-10, stored as f64 for hyperopt, cast to usize)
    pub ensemble_size: f64,
    /// Variance penalty weight (0.1-1.0)
    pub beta_variance: f64,
    /// Disagreement penalty weight (0.1-1.0)
    pub beta_disagreement: f64,
    /// Entropy bonus weight (0.05-0.5)
    pub beta_entropy: f64,
    // Note: variance_cap is NOT in search space, it's fixed in DQNHyperparameters

    // NOTE: warmup_ratio removed from search space — warmup_steps forced to 0 because
    // the batch training path (select_actions_batch) never increments DQN::total_steps.
    // Kept in struct for serde backward compat with saved JSON results.
    pub warmup_ratio: f64,

    // WAVE 26 P1.8: Curiosity-Driven Exploration
    /// Curiosity weight for intrinsic reward (0.0-0.5)
    pub curiosity_weight: f64,

    // WAVE 26 P0: TD Error and Batch Diversity
    /// Maximum TD error clamp value (1.0-100.0, default 10.0)
    /// Prevents extreme TD errors from destabilizing training
    pub td_error_clamp_max: f64,
    /// Batch diversity cooldown period in steps (10.0-100.0, default 50.0)
    /// Controls frequency of diversity-based batch sampling
    pub batch_diversity_cooldown: f64,

    // WAVE 26 P1: Advanced Training Parameters
    /// Learning rate decay type (0=constant, 1=linear, 2=cosine)
    /// Stored as f64 for hyperopt, cast to enum in train_with_params
    pub lr_decay_type: f64,
    /// Sharpe ratio weight in reward function (0.0-0.5, default 0.3)
    /// Balances risk-adjusted returns in composite reward
    pub sharpe_weight: f64,
    /// GAE lambda for advantage estimation (0.9-0.99, default 0.95)
    /// Controls bias-variance tradeoff in advantage estimates
    pub gae_lambda: f64,
    /// Initial noise parameter for NoisyNets (0.4-0.8, default 0.6)
    /// Starting exploration magnitude
    pub noisy_sigma_initial: f64,
    /// Final noise parameter for NoisyNets (0.2-0.5, default 0.4)
    /// Ending exploration magnitude after annealing
    pub noisy_sigma_final: f64,

    // WAVE 26 P1: Network Architecture
    /// Enable spectral normalization for stability
    pub use_spectral_norm: bool,
    /// Enable attention mechanisms in Q-network
    pub use_attention: bool,
    /// Enable residual connections in Q-network
    pub use_residual: bool,
    /// Normalization type (0=LayerNorm, 1=RMSNorm, 2=None)
    /// Stored as f64 for hyperopt, cast to enum in train_with_params
    pub norm_type: f64,
    /// Activation function type (0=ReLU, 1=LeakyReLU, 2=GELU, 3=Mish)
    /// Stored as f64 for hyperopt, cast to enum in train_with_params
    pub activation_type: f64,

    // IQN auxiliary head (Implicit Quantile Network)
    /// Number of quantiles for IQN dual-head (32-200). Active when iqn_lambda > 0.
    pub num_quantiles: usize,
    /// Kappa for quantile Huber loss (0.5-2.0, controls L1<->L2 transition)
    pub qr_kappa: f64,
    /// Lambda weight for IQN quantile loss relative to C51 loss in dual-head ensemble.
    /// L_total = L_c51 + iqn_lambda * L_iqn. Range [0.0, 2.0].
    pub iqn_lambda: f64,

    /// Spectral norm σ_max. Constrains ||W||_σ ≤ σ_max. Range [1.0, 10.0].
    /// Lower = tighter constraint (more stability, less capacity).
    /// Default 3.0 (permits Xavier-init scaling, prevents Q-explosion).
    pub spectral_norm_sigma_max: f64,

    /// Hidden dimension base for dynamic network sizing.
    /// Network shape: [base, base/2, base/4]. Range: [256, 4096], step=256.
    /// Bounded by VRAM via HardwareBudget at runtime.
    pub hidden_dim_base: usize,

    /// Minimum epsilon when noisy nets are enabled (0.02-0.10, default 0.05)
    /// Guarantees random exploration even with learned noise
    pub noisy_epsilon_floor: f64,

    /// Count-based exploration bonus coefficient (0.01-0.5, default 0.1)
    /// UCB-style bonus: β * sqrt(log(N) / (1 + n_a))
    pub count_bonus_coefficient: f64,

    /// Conservative Q-Learning (CQL) regularization alpha (0.0-1.0)
    /// Penalizes overestimation of OOD actions: 0.0=disabled, 0.1=mild, 1.0=full offline-RL strength
    pub cql_alpha: f64,

    /// Softmax temperature for hyperopt walk-forward eval (log scale: 0.01-2.0)
    /// Low T (~0.05) = nearly greedy, high T (~1.0) = more diverse action sampling.
    /// Prevents degenerate single-action collapse when Q-values are uniform.
    pub eval_softmax_temp: f64,

    /// DSR EMA decay rate (log scale: 0.001-0.05). Controls DSR lookback window.
    pub dsr_eta: f64,

    /// Hidden dimension for each branching advantage head (64-256, step=64).
    /// Controls capacity of the per-dimension advantage streams in Branching DQN.
    pub branch_hidden_dim: usize,

    /// Gradient accumulation steps (1=disabled, 2/4/8 for larger effective batch).
    /// Only in search space for large GPUs (≥40GB): effective_batch = batch_size × accum_steps.
    /// H100 with batch=1024, accum=4 → effective batch 4096 (more stable gradients).
    pub gradient_accumulation_steps: usize,

    // === Composite Reward Weights (indices 31-38 in search space) ===
    /// DSR (Sharpe) weight in composite reward (0.1-2.0)
    pub w_dsr: f64,
    /// Normalized PnL weight in composite reward (0.0-1.0)
    pub w_pnl: f64,
    /// Drawdown penalty weight in composite reward (0.0-5.0)
    pub w_dd: f64,
    /// Idle penalty weight — replaces hold_penalty_weight (0.0-0.1)
    pub w_idle: f64,
    /// Drawdown tolerance before penalty kicks in (0.01-0.10)
    pub dd_threshold: f64,
    /// Asymmetric loss scaling factor — prospect theory (1.0-3.0)
    pub loss_aversion: f64,
    /// Position staleness rent per step (0.0001-0.005)
    pub time_decay_rate: f64,
    /// Q-value gap threshold for trade conviction filter (0.0-0.5)
    pub q_gap_threshold: f64,

    /// C51 warmup epochs: use MSE loss for first N epochs instead of C51 cross-entropy.
    /// C51 cross-entropy between nearly-identical distributions produces near-zero gradient.
    /// Range [0, 20], default 5.
    pub c51_warmup_epochs: usize,

    /// Decision Transformer pre-training epochs (0 = disabled).
    /// Not in the hyperopt search space — fixed to 0 for all trials.
    pub dt_pretrain_epochs: usize,

    // === Task 11: New hyperopt-tunable parameters ===

    /// HER relabeling ratio (0.0 = disabled, 0.5 = 50% relabeled)
    /// Improves sample efficiency by relabeling failed episodes toward achieved goals.
    pub her_ratio: f64,

    /// Curiosity weight for intrinsic reward (0.0 = disabled, 0.2 = 20% intrinsic)
    /// Encourages exploration of novel state-action pairs via prediction error.
    pub curiosity_weight_tunable: f64,

    /// Enable CVaR (Conditional Value-at-Risk) action selection (0.0 = off, 1.0 = on)
    /// Stored as f64 for hyperopt, rounded to bool in build_hyperparams.
    pub use_cvar_action_selection: f64,

    /// CVaR alpha confidence level (0.01-0.20). Lower = more risk-averse.
    /// 0.05 = optimize for worst 5% outcomes.
    pub cvar_alpha: f64,
}

impl Default for DQNParams {
    fn default() -> Self {
        Self {
            learning_rate: 1e-4,
            batch_size: 128,
            gamma: 0.95,
            buffer_size: 100_000,
            max_position_absolute: 2.0, // BLOCKER #2: Default matches production (±2.0)
            huber_delta: 10.0,  // Conservative default (hyperopt can scale to 15-40)
            entropy_coefficient: 0.02, // C2: SAC-style regularization only (narrowed from 0.1)
            transaction_cost_multiplier: 1.0,
            per_alpha: 0.6,       // Rainbow DQN standard
            per_beta_start: 0.4,  // Rainbow DQN standard
            dueling_hidden_dim: 128,  // Wave 6.3: Default 128 hidden units
            n_steps: 3,           // Rainbow standard: 3-step TD (raised from 1)
            tau: 0.005,           // Wave 2.2: Polyak soft update (raised lower bound for short hyperopt runs)
            // =============================================================================
            // WAVE 23 P1 FIX: C51 DISTRIBUTIONAL RL DISABLED (BUG #36)
            // =============================================================================
            // BUG #36: Candle's scatter_add has broken gradient flow in backward pass
            // Symptom: 40% of trials experience complete gradient collapse at Epoch 2
            // Root Cause: CPU scatter loop breaks autograd graph in project_distribution()
            // Status: BUG #36 FIXED — scatter_add gradient flow verified (test_scatter_add_gradient_flow)
            // Re-enabled: Candle's scatter_add preserves BackpropOp on GPU
            // =============================================================================
            num_atoms: 51,        // Wave 2.3: Rainbow DQN standard
            // v_min/v_max computed from reward_scale=10, gamma=0.92:
            // Q* ~ 10 / (1-0.92) * 1.2 = 150, clamped to [20, 300] → ±150
            v_min: -(10.0_f64 / (1.0 - 0.92) * 1.2).clamp(20.0, 300.0),
            v_max: (10.0_f64 / (1.0 - 0.92) * 1.2).clamp(20.0, 300.0),
            noisy_sigma_init: 0.5,  // Wave 2.4: Rainbow DQN standard
            minimum_profit_factor: 1.5,  // BUG #7: Default 50% margin above breakeven
            weight_decay: 1e-4,  // Default: 0.0001 (standard L2 regularization strength)
            kelly_fractional: 0.5,       // WAVE 19: Default half-Kelly
            kelly_max_fraction: 0.25,    // WAVE 19: Default 25% max position
            kelly_min_trades: 20,        // WAVE 19: Default 20 trades minimum
            volatility_window: 20,       // WAVE 19: Default 20-bar volatility window
            warmup_ratio: 0.0,           // WAVE 26 P1.5: Default no warmup (production stable)
            curiosity_weight: 0.0,       // C2: fixed to 0.0 (conflicts with NoisyNet primary exploration)
            // WAVE 26 P1.4: Ensemble Uncertainty (default: DISABLED)
            use_ensemble_uncertainty: false,  // Default: disabled (use noisy networks)
            ensemble_size: 5.0,               // Default: 5 heads
            beta_variance: 0.5,               // Default: 50% variance penalty
            beta_disagreement: 0.5,           // Default: 50% disagreement penalty
            beta_entropy: 0.1,                // Default: 10% entropy bonus
            // WAVE 26 P0: TD Error and Batch Diversity
            td_error_clamp_max: 10.0,         // Default: moderate clamping
            batch_diversity_cooldown: 50.0,   // Default: moderate cooldown
            // WAVE 26 P1: Advanced Training Parameters
            lr_decay_type: 0.0,               // Default: constant (no decay)
            sharpe_weight: 0.3,               // Default: 30% weight on risk-adjusted returns
            gae_lambda: 0.95,                 // Default: standard GAE lambda
            noisy_sigma_initial: 0.6,         // Default: moderate initial noise
            noisy_sigma_final: 0.4,           // Default: moderate final noise
            // Architecture — always on. Built, tested, use it.
            use_spectral_norm: true,
            use_attention: true,
            use_residual: true,
            norm_type: 1.0,                   // RMSNorm
            activation_type: 1.0,             // Default: LeakyReLU (prevents dead neurons vs ReLU)
            // QR-DQN: hyperopt-controlled — activates when num_atoms > 100 in from_continuous()
            // Default: disabled (num_atoms=51 in defaults)
            num_quantiles: 32,
            qr_kappa: 1.0,
            iqn_lambda: 0.25,     // Default: mild IQN regularization alongside C51
            spectral_norm_sigma_max: 3.0, // Default: permits Xavier scaling
            hidden_dim_base: 256, // Conservative default (backward compatible)
            noisy_epsilon_floor: 0.10, // Minimum 10% random exploration to prevent action collapse
            count_bonus_coefficient: 0.0, // C2: fixed to 0.0 (conflicts with NoisyNet)
            cql_alpha: 0.1, // CQL regularization: mild penalty on OOD Q-values
            eval_softmax_temp: 0.1, // Default: nearly greedy (hyperopt will tune)
            dsr_eta: 0.01, // Default: ~100-step DSR lookback window
            branch_hidden_dim: 128, // Default: 128 hidden units per branching head
            gradient_accumulation_steps: 1, // Default: no accumulation
            // Composite reward weights (defaults match dqn-production.toml)
            w_dsr: 1.0,
            w_pnl: 0.3,
            w_dd: 1.0,
            w_idle: 0.01,
            dd_threshold: 0.02,
            loss_aversion: 1.5,
            time_decay_rate: 0.0005,
            q_gap_threshold: 0.0,
            c51_warmup_epochs: 5,   // C51 warmup: MSE loss for first 5 epochs
            dt_pretrain_epochs: 0,  // Disabled by default; not in hyperopt search space
            // Task 11: New hyperopt-tunable parameters
            her_ratio: 0.2,                     // Default: 20% HER relabeling
            curiosity_weight_tunable: 0.1,      // Default: 10% curiosity-driven intrinsic reward
            use_cvar_action_selection: 1.0,     // Default: enabled (risk-aware IQN)
            cvar_alpha: 0.05,                   // Default: worst 5% tail
        }
    }
}

impl ParameterSpace for DQNParams {
    fn continuous_bounds() -> Vec<(f64, f64)> {
        // 45D search space (Task 11: +4 dims at indices 41-44)
        // All bounds loaded from config/training/dqn-hyperopt.toml [search_space].
        // To change search ranges, edit the TOML — no code changes needed.
        // Log-scale transforms are applied here; TOML stores linear values.
        let hp = crate::training_profile::HyperoptProfile::load("dqn-hyperopt");
        let b = |name: &str, default: (f64, f64)| hp.bound(name, default);

        let lr = b("learning_rate", (1e-5, 3e-4));
        let bs = b("batch_size", (64.0, 512.0));
        let gm = b("gamma", (0.88, 0.99));
        let bf = b("buffer_size", (50_000.0, 100_000.0));
        let mp = b("max_position_absolute", (1.0, 4.0));
        let hd = b("huber_delta", (10.0, 40.0));
        let ec = b("entropy_coefficient", (0.05, 0.5));
        let tc = b("transaction_cost_multiplier", (0.5, 2.0));
        let pa = b("per_alpha", (0.4, 0.8));
        let pb = b("per_beta_start", (0.2, 0.6));
        let mut vr = b("v_range", (10.0, 50.0));
        let ns = b("noisy_sigma_init", (0.1, 1.0));
        let mut dh = b("dueling_hidden_dim", (128.0, 512.0));
        let nst = b("n_steps", (3.0, 5.0));
        let mut na = b("num_atoms", (11.0, 101.0));
        let wd = b("weight_decay", (1e-4, 1e-2));
        let kf = b("kelly_fractional", (0.25, 0.75));
        let km = b("kelly_max_fraction", (0.1, 0.5));
        let vw = b("volatility_window", (10.0, 30.0));
        let tau = b("tau", (0.005, 0.01));
        let mut hdim = b("hidden_dim_base", (128.0, 512.0));
        let cql = b("cql_alpha", (0.0, 1.0));
        let lrd = b("lr_decay_type", (0.0, 2.0));
        let dsr = b("dsr_eta", (0.001, 0.05));
        let mpf = b("minimum_profit_factor", (1.1, 2.0));
        let cb = b("count_bonus_coefficient", (0.0, 0.3));
        let sw = b("sharpe_weight", (0.0, 0.5));
        let mut bh = b("branch_hidden_dim", (64.0, 256.0));
        let ga = b("gradient_accumulation_steps", (1.0, 1.0));
        let iq = b("iqn_lambda", (0.0, 2.0));

        // Composite reward weights (indices 31-38)
        let mut rw_dsr = b("w_dsr", (0.1, 2.0));
        let mut rw_pnl = b("w_pnl", (0.0, 1.0));
        let mut rw_dd = b("w_dd", (0.0, 5.0));
        let mut rw_idle = b("w_idle", (0.0, 0.1));
        let mut rw_ddt = b("dd_threshold", (0.01, 0.10));
        let mut rw_la = b("loss_aversion", (1.0, 3.0));
        let mut rw_tdr = b("time_decay_rate", (0.0001, 0.005));
        let mut rw_qgt = b("q_gap_threshold", (0.0, 0.5));
        let sn_sigma = b("spectral_norm_sigma_max", (1.0, 10.0));
        let cw = b("c51_warmup_epochs", (0.0, 20.0));
        // Task 11: New hyperopt-tunable parameters
        let hr = b("her_ratio", (0.0, 0.5));
        let cwt = b("curiosity_weight_tunable", (0.0, 0.2));
        let ucvar = b("use_cvar_action_selection", (0.0, 1.0));
        let cva = b("cvar_alpha", (0.01, 0.2));

        // Two-phase hyperopt: fix architecture OR dynamics params to single-point bounds.
        // Single-point bounds (v, v) make the optimizer trivially converge on those
        // dimensions, focusing search power on the free dimensions.
        use crate::training_profile::{get_hyperopt_phase, HyperoptPhase};
        let phase = get_hyperopt_phase();

        match phase {
            HyperoptPhase::Fast => {
                // Phase 1: fix architecture to small network from [phase_fast] TOML.
                // Also fix reward weights to defaults (search learning dynamics only).
                // All values MUST exist in [phase_fast] — no silent defaults.
                let pf = hp.phase_fast.as_ref()
                    .expect("[phase_fast] section missing from dqn-hyperopt.toml — required for --phase fast");
                let fix_hdim = pf.hidden_dim_base
                    .expect("phase_fast.hidden_dim_base missing from dqn-hyperopt.toml");
                let fix_na = pf.num_atoms
                    .expect("phase_fast.num_atoms missing from dqn-hyperopt.toml");
                let fix_bh = pf.branch_hidden_dim
                    .expect("phase_fast.branch_hidden_dim missing from dqn-hyperopt.toml");
                let fix_dh = pf.dueling_hidden_dim
                    .expect("phase_fast.dueling_hidden_dim missing from dqn-hyperopt.toml");
                let fix_vr = pf.v_range
                    .expect("phase_fast.v_range missing from dqn-hyperopt.toml");

                hdim = (fix_hdim, fix_hdim);
                na = (fix_na, fix_na);
                bh = (fix_bh, fix_bh);
                dh = (fix_dh, fix_dh);
                vr = (fix_vr, fix_vr);

                // Fix reward weights to defaults in Phase Fast (not searched)
                let fix_w_dsr = pf.w_dsr
                    .expect("phase_fast.w_dsr missing from dqn-hyperopt.toml");
                let fix_w_pnl = pf.w_pnl
                    .expect("phase_fast.w_pnl missing from dqn-hyperopt.toml");
                let fix_w_dd = pf.w_dd
                    .expect("phase_fast.w_dd missing from dqn-hyperopt.toml");
                let fix_w_idle = pf.w_idle
                    .expect("phase_fast.w_idle missing from dqn-hyperopt.toml");
                let fix_ddt = pf.dd_threshold
                    .expect("phase_fast.dd_threshold missing from dqn-hyperopt.toml");
                let fix_la = pf.loss_aversion
                    .expect("phase_fast.loss_aversion missing from dqn-hyperopt.toml");
                let fix_tdr = pf.time_decay_rate
                    .expect("phase_fast.time_decay_rate missing from dqn-hyperopt.toml");
                let fix_qgt = pf.q_gap_threshold.unwrap_or(0.0);

                rw_dsr = (fix_w_dsr, fix_w_dsr);
                rw_pnl = (fix_w_pnl, fix_w_pnl);
                rw_dd = (fix_w_dd, fix_w_dd);
                rw_idle = (fix_w_idle, fix_w_idle);
                rw_ddt = (fix_ddt, fix_ddt);
                rw_la = (fix_la, fix_la);
                rw_tdr = (fix_tdr, fix_tdr);
                rw_qgt = (fix_qgt, fix_qgt);

                tracing::info!(
                    "Phase FAST: fixed architecture (hidden_dim={}, num_atoms={}, branch_hidden={}, dueling_hidden={}, v_range={}) + reward weights",
                    fix_hdim, fix_na, fix_bh, fix_dh, fix_vr
                );
            }
            HyperoptPhase::Full(ref best_vec) => {
                // Phase 2: fix learning dynamics from Phase 1 best params.
                // Search only architecture dims (~5D free, ~33D fixed).
                // Architecture dims (indices 10, 13, 15, 21, 28) stay free.
                tracing::info!("Phase FULL: fixing {} dynamics params from Phase 1 best", best_vec.len());
                // Bounds will be applied after vec construction below.
            }
            HyperoptPhase::Reward(ref best_vec) => {
                // Phase 3: fix dynamics + architecture from Phase 2 best params.
                // Search ONLY reward weights (indices 31-38, 7D).
                // All other dims fixed to Phase 2's best values.
                tracing::info!("Phase REWARD: fixing {} dynamics+architecture params, searching 7 reward dims", best_vec.len());
                // Bounds will be applied after vec construction below.
            }
            HyperoptPhase::Risk(ref best_vec) => {
                // Phase 4: fix dynamics + architecture from Phase 2 best params.
                // Search ONLY risk params (8D):
                //   kelly_fractional (17), dd_threshold (35), loss_aversion (36),
                //   time_decay_rate (37), q_gap_threshold (38), w_dsr (31), w_pnl (32), w_dd (33).
                tracing::info!("Phase RISK: fixing {} dynamics+architecture params, searching 8 risk dims", best_vec.len());
                // Bounds will be applied after vec construction below.
            }
        }

        let mut bounds = vec![
            (lr.0.ln(), lr.1.ln()),         // 0: learning_rate (log scale)
            bs,                              // 1: batch_size
            gm,                              // 2: gamma
            (bf.0.ln(), bf.1.ln()),          // 3: buffer_size (log scale)
            mp,                              // 4: max_position_absolute
            (hd.0.ln(), hd.1.ln()),          // 5: huber_delta (log scale)
            ec,                              // 6: entropy_coefficient
            tc,                              // 7: transaction_cost_multiplier
            pa,                              // 8: per_alpha
            pb,                              // 9: per_beta_start
            vr,                              // 10: v_range
            (1.0, 1.0),                      // 11: use_branching (FIXED: always true)
            (ns.0.ln(), ns.1.ln()),          // 12: noisy_sigma_init (log scale)
            dh,                              // 13: dueling_hidden_dim
            nst,                             // 14: n_steps
            na,                              // 15: num_atoms
            (wd.0.ln(), wd.1.ln()),          // 16: weight_decay (log scale)
            kf,                              // 17: kelly_fractional
            km,                              // 18: kelly_max_fraction
            vw,                              // 19: volatility_window
            (tau.0.ln(), tau.1.ln()),         // 20: tau (log scale)
            hdim,                            // 21: hidden_dim_base
            cql,                             // 22: cql_alpha
            lrd,                             // 23: lr_decay_type
            (dsr.0.ln(), dsr.1.ln()),        // 24: dsr_eta (log scale)
            mpf,                             // 25: minimum_profit_factor
            cb,                              // 26: count_bonus_coefficient
            sw,                              // 27: sharpe_weight
            bh,                              // 28: branch_hidden_dim
            ga,                              // 29: gradient_accumulation_steps
            iq,                              // 30: iqn_lambda
            // Composite reward weights (indices 31-38)
            rw_dsr,                          // 31: w_dsr
            rw_pnl,                          // 32: w_pnl
            rw_dd,                           // 33: w_dd
            rw_idle,                         // 34: w_idle
            rw_ddt,                          // 35: dd_threshold
            rw_la,                           // 36: loss_aversion
            rw_tdr,                          // 37: time_decay_rate
            rw_qgt,                          // 38: q_gap_threshold
            sn_sigma,                        // 39: spectral_norm_sigma_max
            cw,                              // 40: c51_warmup_epochs
            // Task 11: New hyperopt-tunable parameters
            hr,                              // 41: her_ratio
            cwt,                             // 42: curiosity_weight_tunable
            ucvar,                           // 43: use_cvar_action_selection
            cva,                             // 44: cvar_alpha
        ];

        // Phase FULL: fix all non-architecture dims to Phase 1 best values.
        // Architecture dims that stay FREE: 10 (v_range), 13 (dueling_hidden),
        // 15 (num_atoms), 21 (hidden_dim_base), 28 (branch_hidden_dim).
        if let HyperoptPhase::Full(ref best_vec) = phase {
            let arch_dims: &[usize] = &[10, 13, 15, 21, 28];
            for (i, bv) in best_vec.iter().enumerate() {
                if i < bounds.len() && !arch_dims.contains(&i) {
                    bounds[i] = (*bv, *bv);
                }
            }
        }

        // Phase REWARD: fix ALL dims EXCEPT reward weights (indices 31-38).
        // This is the fastest phase — only 7D search, ~10s/trial.
        if let HyperoptPhase::Reward(ref best_vec) = phase {
            let reward_dims: &[usize] = &[31, 32, 33, 34, 35, 36, 37, 38];
            for (i, bv) in best_vec.iter().enumerate() {
                if i < bounds.len() && !reward_dims.contains(&i) {
                    bounds[i] = (*bv, *bv);
                }
            }
        }

        // Phase RISK: fix ALL dims EXCEPT risk params (8D).
        // Risk dims: kelly_fractional (17), w_dsr (31), w_pnl (32), w_dd (33),
        //            dd_threshold (35), loss_aversion (36), time_decay_rate (37),
        //            q_gap_threshold (38).
        if let HyperoptPhase::Risk(ref best_vec) = phase {
            let risk_dims: &[usize] = &[17, 31, 32, 33, 35, 36, 37, 38];
            for (i, bv) in best_vec.iter().enumerate() {
                if i < bounds.len() && !risk_dims.contains(&i) {
                    bounds[i] = (*bv, *bv);
                }
            }
        }

        bounds
    }

    fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
        if x.len() < 39 {
            return Err(MLError::ConfigError(format!("Expected at least 39 continuous parameters, got {}", x.len())));
        }
        // Task 11: New parameters (indices 41-44) — backwards compatible via .get()
        let her_ratio = x.get(41).copied().unwrap_or(0.2).clamp(0.0, 0.5);
        let curiosity_weight_tunable = x.get(42).copied().unwrap_or(0.1).clamp(0.0, 0.2);
        let use_cvar_action_selection = x.get(43).copied().unwrap_or(1.0).clamp(0.0, 1.0);
        let cvar_alpha = x.get(44).copied().unwrap_or(0.05).clamp(0.01, 0.2);

        // === 26 TUNED parameters (from search space) ===
        // C2: Removed curiosity_weight and noisy_epsilon_floor (exploration stacking)
        let learning_rate = x[0].exp();
        let mut batch_size = x[1].round().clamp(64.0, 1024.0) as usize;
        let buffer_size = x[3].exp().round().max(50_000.0) as usize;
        let max_position_absolute = x[4].clamp(1.0, 4.0);
        let huber_delta = x[5].exp();
        let entropy_coefficient = x[6];
        let transaction_cost_multiplier = x[7];
        let per_alpha = x[8].clamp(0.4, 0.8);
        let per_beta_start = x[9].clamp(0.2, 0.6);

        // Rainbow DQN extensions — DYNAMIC v_range from gamma + reward_scale.
        // V_max = reward_scale / (1 - gamma) with 20% headroom.
        // Q* ~ reward_scale / (1-gamma). gamma=0.95, reward_scale=10 → Q*=200. Headroom 1.2x.
        let gamma_val = x[2].clamp(0.88, 0.99);
        // Reward v6: REWARD_SCALE=10 (single source of truth via DQNHyperparameters::reward_scale).
        let reward_scale = 10.0_f64;
        let v_range = (reward_scale / (1.0 - gamma_val) * 1.2).clamp(20.0, 300.0);
        let v_min = -v_range;
        let v_max = v_range;
        // x[11] was use_branching — always true, slot kept for index compatibility
        let noisy_sigma_init = x[12].exp().clamp(0.1, 1.0);
        let dueling_hidden_dim = (x[13].round() / 128.0).round() * 128.0;
        // Upper clamp matches large_gpu expansion in continuous_bounds_for (768);
        // VRAM estimator already caps the search bound for smaller GPUs.
        let dueling_hidden_dim = dueling_hidden_dim.clamp(128.0, 768.0) as usize;
        let n_steps = x[14].round().clamp(3.0, 5.0) as usize;
        // Phase Fast fixes num_atoms=11. The old (round/50, clamp 51-301) silently
        // rounded 11→0→51, making Phase Fast's small-network config dead code.
        let num_atoms = (x[15].round() as usize).max(11);

        // Weight decay
        let weight_decay = x[16].exp().clamp(1e-5, 1e-3);

        // Kelly risk parameters (kelly_min_trades fixed to 20)
        let kelly_fractional = x[17].clamp(0.25, 0.75);
        let kelly_max_fraction = x[18].clamp(0.1, 0.5);

        // Volatility window
        let volatility_window = x[19].round().clamp(10.0, 30.0) as usize;

        // Soft update (C2: curiosity_weight removed from index 20, tau shifted from 21 to 20)
        let tau = x[20].exp().clamp(0.005, 0.01);

        // GPU-dynamic hidden dims (C2: shifted from 22 to 21)
        // Upper clamp matches large_gpu expansion (2048); VRAM estimator caps for smaller GPUs.
        let hidden_dim_base = ((x[21].round() / 64.0).round() * 64.0).clamp(64.0, 2048.0) as usize;

        // noisy_epsilon_floor FIXED to 0.10 below (C2: was index 23, not in search space)

        // CQL regularization (C2: shifted from 24 to 22)
        let cql_alpha = x[22].clamp(0.0, 1.0);

        // Training dynamics (3D) (C2: shifted from 25-27 to 23-25)
        let lr_decay_type = x[23].round().clamp(0.0, 2.0);
        let dsr_eta = x[24].exp().clamp(0.001, 0.05);
        let minimum_profit_factor = x[25].clamp(1.1, 2.0);
        let count_bonus_coefficient = x[26].clamp(0.0, 0.3);

        // === FIXED parameters (validated defaults, removed from search) ===
        let kelly_min_trades: usize = 20;
        let ensemble_size = 5.0;
        let beta_variance = 0.5;
        let beta_disagreement = 0.5;
        let beta_entropy = 0.2;
        let td_error_clamp_max = 10.0;
        let batch_diversity_cooldown = 50.0;
        let sharpe_weight = x[27].clamp(0.0, 0.5); // C4 FIX: tunable Sharpe blending
        // Upper clamp matches large_gpu expansion (512); VRAM estimator caps for smaller GPUs.
        let branch_hidden_dim = ((x[28].round() / 64.0).round() * 64.0).clamp(64.0, 512.0) as usize;
        // C6: Gradient accumulation — round to nearest power of 2 in {1,2,4,8}
        let gradient_accumulation_steps = {
            let raw = x[29].round().clamp(1.0, 8.0) as usize;
            // Snap to nearest power of 2: 1,2,3→2, 4,5,6→4, 7,8→8
            match raw {
                0..=1 => 1,
                2..=3 => 2,
                4..=5 => 4,
                _ => 8,
            }
        };
        let eval_softmax_temp = 1.0; // Fixed: backtest uses greedy argmax (Fix A)
        let gae_lambda = 0.95;
        let noisy_sigma_initial = 0.5;
        let noisy_sigma_final = 0.3;
        let norm_type = 1.0;            // RMSNorm
        let activation_type = 1.0;      // LeakyReLU
        // M1: Enable QR-DQN when num_atoms > 100 (high atom count signals distributional preference)
        let num_quantiles: usize = 32; // IQN default quantiles
        let qr_kappa = 1.0;
        // C7: IQN dual-head lambda (idx 30)
        let iqn_lambda = x[30].clamp(0.0, 2.0);

        // C8: Composite reward weights (indices 31-38)
        let w_dsr = x[31].clamp(0.1, 2.0);
        let w_pnl = x[32].clamp(0.0, 1.0);
        let w_dd = x[33].clamp(0.0, 5.0);
        let w_idle = x[34].clamp(0.0, 0.1);
        let dd_threshold = x[35].clamp(0.01, 0.10);
        let loss_aversion = x[36].clamp(1.0, 3.0);
        let time_decay_rate = x[37].clamp(0.0001, 0.005);
        let q_gap_threshold = x[38].clamp(0.0, 0.5);
        let spectral_norm_sigma_max = if x.len() > 39 { x[39].clamp(1.0, 10.0) } else { 3.0 };
        let c51_warmup_epochs = x.get(40).copied().unwrap_or(5.0).round().clamp(0.0, 20.0) as usize;

        // WAVE 6 FIX #2: Batch size floor for high learning rates
        if learning_rate > 2e-4 && batch_size < 120 {
            tracing::info!(
                "Adjusting batch_size from {} to 120 (LR={:.2e} requires larger batches)",
                batch_size,
                learning_rate
            );
            batch_size = 120;
        }

        let params = Self {
            learning_rate,
            batch_size,
            gamma: x[2].clamp(0.88, 0.99),
            buffer_size,
            max_position_absolute,
            huber_delta,
            entropy_coefficient,
            transaction_cost_multiplier,
            per_alpha,
            per_beta_start,
            dueling_hidden_dim,
            n_steps,
            num_atoms,
            v_min,
            v_max,
            noisy_sigma_init,
            minimum_profit_factor,
            weight_decay,
            kelly_fractional,
            kelly_max_fraction,
            kelly_min_trades,
            volatility_window,
            use_ensemble_uncertainty: false,
            ensemble_size,
            beta_variance,
            beta_disagreement,
            beta_entropy,
            warmup_ratio: 0.0, // Fixed: batch training never increments total_steps
            curiosity_weight: 0.0, // C2: fixed to 0.0 (conflicts with NoisyNet)
            tau,
            td_error_clamp_max,
            batch_diversity_cooldown,
            lr_decay_type,
            sharpe_weight,
            gae_lambda,
            noisy_sigma_initial,
            noisy_sigma_final,
            // Architecture — always on. Built, tested, use it.
            use_spectral_norm: true,
            use_attention: true,
            use_residual: true,
            norm_type,                 // 1.0 = RMSNorm
            activation_type,           // 1.0 = LeakyReLU
            num_quantiles,
            qr_kappa,
            iqn_lambda,  // C7: IQN dual-head loss weight
            spectral_norm_sigma_max,  // dim 39: Lipschitz constraint [1.0, 10.0]
            hidden_dim_base,
            noisy_epsilon_floor: 0.10, // Minimum 10% random exploration to prevent action collapse
            count_bonus_coefficient, // C3 FIX: re-enabled UCB exploration bonus (complementary to NoisyNet)
            cql_alpha,
            eval_softmax_temp,
            dsr_eta,
            branch_hidden_dim,
            gradient_accumulation_steps,
            // C8: Composite reward weights (indices 31-38)
            w_dsr,
            w_pnl,
            w_dd,
            w_idle,
            dd_threshold,
            loss_aversion,
            time_decay_rate,
            q_gap_threshold,
            c51_warmup_epochs,     // Index 40: C51 warmup epochs [0, 20]
            dt_pretrain_epochs: 0, // Fixed: DT pre-training not in hyperopt search space
            // Task 11: New hyperopt-tunable parameters (indices 41-44)
            her_ratio,
            curiosity_weight_tunable,
            use_cvar_action_selection,
            cvar_alpha,
        };

        Ok(params)
    }

    fn to_continuous(&self) -> Vec<f64> {
        // 45D search space (Task 11: +4 dims at indices 41-44)
        vec![
            self.learning_rate.ln(),           // 0
            self.batch_size as f64,            // 1
            self.gamma,                        // 2
            (self.buffer_size as f64).ln(),    // 3
            self.max_position_absolute,        // 4
            self.huber_delta.ln(),             // 5
            self.entropy_coefficient,          // 6
            self.transaction_cost_multiplier,  // 7
            self.per_alpha,                    // 8
            self.per_beta_start,               // 9
            self.v_max,                        // 10: v_range (symmetric: v_min=-v_range)
            1.0, // 11: use_branching (always on — fixed at 1.0)
            self.noisy_sigma_init.ln(),        // 12
            self.dueling_hidden_dim as f64,    // 13
            self.n_steps as f64,               // 14
            self.num_atoms as f64,             // 15
            self.weight_decay.ln(),            // 16
            self.kelly_fractional,             // 17
            self.kelly_max_fraction,           // 18
            self.volatility_window as f64,     // 19
            self.tau.ln(),                     // 20
            self.hidden_dim_base as f64,       // 21
            self.cql_alpha,                    // 22
            self.lr_decay_type,                // 23
            self.dsr_eta.ln(),                 // 24
            self.minimum_profit_factor,        // 25
            self.count_bonus_coefficient,      // 26
            self.sharpe_weight,                // 27: C4 FIX
            self.branch_hidden_dim as f64,     // 28: C5 branch_hidden_dim
            self.gradient_accumulation_steps as f64, // 29: C6 gradient accumulation
            self.iqn_lambda,                   // 30: C7 IQN dual-head loss weight
            // C8: Composite reward weights (indices 31-38)
            self.w_dsr,                        // 31
            self.w_pnl,                        // 32
            self.w_dd,                         // 33
            self.w_idle,                       // 34
            self.dd_threshold,                 // 35
            self.loss_aversion,                // 36
            self.time_decay_rate,              // 37
            self.q_gap_threshold,              // 38
            self.spectral_norm_sigma_max,      // 39
            self.c51_warmup_epochs as f64,     // 40
            // Task 11: New hyperopt-tunable parameters
            self.her_ratio,                    // 41
            self.curiosity_weight_tunable,     // 42
            self.use_cvar_action_selection,    // 43
            self.cvar_alpha,                   // 44
        ]
    }

    fn param_names() -> Vec<&'static str> {
        // 45 tuned parameters (Task 11: +4 new dims at indices 41-44)
        vec![
            "learning_rate",            // 0
            "batch_size",               // 1
            "gamma",                    // 2
            "buffer_size",              // 3
            "max_position_absolute",    // 4
            "huber_delta",              // 5
            "entropy_coefficient",      // 6
            "transaction_cost_multiplier", // 7
            "per_alpha",                // 8
            "per_beta_start",           // 9
            "v_range",                  // 10: symmetric support ±v_range
            "use_branching_fixed",      // 11: always true (slot kept for index compat)
            "noisy_sigma_init",         // 12
            "dueling_hidden_dim",       // 13
            "n_steps",                  // 14
            "num_atoms",                // 15
            "weight_decay",             // 16
            "kelly_fractional",         // 17
            "kelly_max_fraction",       // 18
            "volatility_window",        // 19
            "tau",                      // 20
            "hidden_dim_base",          // 21
            "cql_alpha",                // 22
            "lr_decay_type",            // 23
            "dsr_eta",                  // 24
            "minimum_profit_factor",    // 25
            "count_bonus_coefficient",  // 26
            "sharpe_weight",            // 27: C4 FIX
            "branch_hidden_dim",       // 28: C5 branching head capacity
            "gradient_accumulation_steps", // 29: C6 effective batch multiplier
            "iqn_lambda",              // 30: C7 IQN dual-head loss weight
            // C8: Composite reward weights
            "w_dsr",                   // 31
            "w_pnl",                   // 32
            "w_dd",                    // 33
            "w_idle",                  // 34
            "dd_threshold",            // 35
            "loss_aversion",           // 36
            "time_decay_rate",         // 37
            "q_gap_threshold",         // 38
            "spectral_norm_sigma_max", // 39
            "c51_warmup_epochs",       // 40
            // Task 11: New hyperopt-tunable parameters
            "her_ratio",               // 41
            "curiosity_weight",        // 42
            "use_cvar_action_selection", // 43
            "cvar_alpha",              // 44
        ]
    }

    fn continuous_bounds_for(budget: &HardwareBudget) -> Vec<(f64, f64)> {
        let mut bounds = Self::continuous_bounds();
        let vram_mb = budget.gpu_memory_mb as f64;

        // Derive upper bounds from VRAM via HardwareBudget methods (same estimators
        // used by AutoBatchSizer and the DQN constructor). No hardcoded tiers.
        // Skip fixed bounds (lo==hi from Phase Fast).

        // batch_size: constrain upper bound to VRAM capacity, but never exceed
        // the TOML-configured upper bound (b.1). The TOML is the source of truth;
        // VRAM sizing only REDUCES the upper bound, never expands it.
        if let Some(b) = bounds.get_mut(1) {
            if (b.0 - b.1).abs() > 1e-6 {
                let toml_max = b.1;
                if let Some(vram_max) = budget.max_batch_size(MODEL_OVERHEAD_MB, MB_PER_SAMPLE, b.0, toml_max) {
                    b.1 = vram_max.min(toml_max);
                }
            }
        }

        // buffer_size (ln-scale): 15% of VRAM / ~120 bytes per replay entry
        // Cap to VRAM capacity but never exceed TOML upper bound
        let max_buffer = (vram_mb * 0.15 * 1024.0 * 1024.0 / 120.0).clamp(5000.0, 1_000_000.0);
        if let Some(b) = bounds.get_mut(3) {
            if (b.0 - b.1).abs() > 1e-6 { b.1 = b.1.min(max_buffer.ln()); }
        }

        // hidden_dim_base + related dims: delegate to budget.max_hidden_dim_base_full()
        let worst_batch = bounds.get(1).map_or(512, |b| b.1 as usize);
        let worst_atoms = bounds.get(15).map_or(51, |b| b.1 as usize);
        let worst_buffer = (vram_mb * 0.15 * 1024.0 * 1024.0 / 120.0) as usize;
        let max_base = budget.max_hidden_dim_base_full(
            1, worst_batch, 56, 5, worst_atoms, true, true, worst_buffer,
        );
        // Cap hidden, dueling, branch proportionally from max_base
        let max_hidden = max_base as f64;
        let max_dueling = (max_hidden * 0.5).clamp(128.0, 768.0);
        let max_branch = (max_hidden * 0.25).clamp(64.0, 512.0);
        // num_atoms: proportional to VRAM (5% budget / per-atom cost)
        let max_atoms = (vram_mb * 0.05 * 1024.0 * 1024.0
            / (worst_batch as f64 * 11.0 * 4.0 * 2.0)).clamp(11.0, 301.0);
        // gradient_accum: proportional to compute budget (no extra VRAM)
        let max_accum = (vram_mb / 10240.0 * 8.0).clamp(1.0, 8.0);

        // Apply VRAM-derived caps (skip fixed Phase Fast bounds)
        for &(idx, cap) in &[
            (15, max_atoms), (21, max_hidden), (13, max_dueling),
            (28, max_branch), (29, max_accum),
        ] {
            if let Some(b) = bounds.get_mut(idx) {
                if (b.0 - b.1).abs() > 1e-6 { b.1 = b.1.min(cap); }
            }
        }
        tracing::trace!(
            "VRAM bounds: {}MB → batch≤{} hidden≤{:.0} atoms≤{:.0} buffer≤{}",
            budget.gpu_memory_mb, worst_batch, max_hidden, max_atoms, worst_buffer
        );
        bounds
    }

    fn model_overhead_mb() -> f64 {
        TRIAL_VRAM_MB
    }

    fn per_sample_mb() -> f64 {
        MB_PER_SAMPLE
    }
}

impl DQNParams {
    /// Validates fundamental parameter invariants before training.
    ///
    /// These are not strategy-specific constraints (those were removed in Wave 16V)
    /// but mathematical invariants that would cause panics, NaN, or silent corruption
    /// if violated. Hyperopt is still free to explore the full parameter space within
    /// these physically meaningful bounds.
    fn validate_for_hft_trendfollowing(&self) -> Result<(), String> {
        if self.v_min >= self.v_max {
            return Err(format!(
                "v_min ({}) must be < v_max ({})",
                self.v_min, self.v_max
            ));
        }
        if self.num_atoms < 2 {
            return Err(format!(
                "num_atoms ({}) must be >= 2 for distributional support",
                self.num_atoms
            ));
        }
        if self.batch_size == 0 {
            return Err("batch_size must be > 0".to_string());
        }
        if self.learning_rate <= 0.0 || self.learning_rate > 0.1 {
            return Err(format!(
                "learning_rate ({}) must be in (0, 0.1]",
                self.learning_rate
            ));
        }
        if self.gamma <= 0.0 || self.gamma > 1.0 {
            return Err(format!(
                "gamma ({}) must be in (0, 1]",
                self.gamma
            ));
        }
        Ok(())
    }
}

/// DQN training metrics
///
/// Contains all relevant metrics from a DQN training run.
/// The primary optimization target is avg_episode_reward (higher is better).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DQNMetrics {
    /// Final training loss
    pub train_loss: f64,
    /// Final validation loss
    pub val_loss: f64,
    /// Average Q-value (higher indicates better value estimation)
    pub avg_q_value: f64,
    /// Final epsilon (exploration rate)
    pub final_epsilon: f64,
    /// Number of epochs completed
    pub epochs_completed: usize,
    /// Average episode reward (optimization target)
    pub avg_episode_reward: f64,
    /// Buy action percentage (0.0 to 1.0)
    pub buy_action_pct: f64,
    /// Sell action percentage (0.0 to 1.0)
    pub sell_action_pct: f64,
    /// HOLD action percentage (0.0 to 1.0)
    pub hold_action_pct: f64,
    /// Average gradient norm (for stability monitoring)
    pub gradient_norm: f64,
    /// Q-value standard deviation (for volatility monitoring)
    pub q_value_std: f64,
    /// Optional backtest metrics from validation set
    pub backtest_metrics: Option<BacktestMetrics>,
}

/// DQN trainer for hyperparameter optimization
///
/// This struct wraps the DQN training pipeline and implements
/// `HyperparameterOptimizable` for use with optimization backends.
///
/// ## Configuration
///
/// - **DBN data dir**: Market data source (OHLCV bars from Databento)
/// - **Epochs**: Number of training epochs 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`: 50 without OFI (42 market + 8 portfolio), or 58 with MBP-10 data (+8 OFI)
///   - Market features (42): OHLCV, technical indicators, price patterns, volume, time, statistical, ADX, CUSUM direction
///   - OFI features (8, optional): TRUE OFI from MBP-10 order book (present when MBP-10 data available)
///   - Portfolio features (3): Position, unrealized PnL, drawdown
/// - `num_actions`: 9 (9 exposure levels: S100/S75/S50/S25/Flat/L25/L50/L75/L100)
/// - `hidden_dims`: [256, 128, 64]
///
/// ## Optimized Hyperparameters
///
/// The following are optimized by `DQNParams`:
/// - Learning rate
/// - Batch size
/// - Gamma (discount factor)
/// - Epsilon decay
/// - Buffer size
#[derive(Debug, Clone)]
pub struct DQNTrainer {
    dbn_data_dir: PathBuf,
    epochs: usize,
    buffer_size_max: usize,
    runtime_handle: Option<tokio::runtime::Handle>,
    /// Owned multi-thread Tokio runtime — keeps the handle valid for the adapter lifetime.
    /// Without this, each trial creates a single-threaded `Runtime::new()` that pins all
    /// async work (training, backtest, data loading) to one OS thread.
    #[allow(dead_code)]
    _owned_runtime: Option<Arc<tokio::runtime::Runtime>>,
    training_paths: TrainingPaths,
    device: MlDevice, // Initialize CUDA early like MAMBA-2
    /// Pool of GPU devices for multi-GPU trial parallelism.
    /// Each trial picks `device_pool[trial_num % pool.len()]`.
    /// Single-GPU: pool of 1 (no behavior change).
    device_pool: Vec<MlDevice>,
    /// 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 — shared across clones for parallel execution
    trial_counter: Arc<AtomicUsize>,
    /// 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,
    /// Best trial result so far (for JSON export) — shared across clones for parallel execution
    best_trial: Arc<Mutex<Option<BestTrialExport>>>,

    /// Optional feature cache directory
    feature_cache_dir: Option<PathBuf>,
    /// Objective mode for two-phase optimization
    objective_mode: ObjectiveMode,
    /// Initial trading capital in dollars
    initial_capital: f64,
    /// Transaction cost in basis points (commission per trade)
    tx_cost_bps: f64,
    /// Tick size for spread calculation (e.g. 0.25 for ES futures)
    tick_size: f64,
    /// Spread width in ticks (e.g. 1.0 for ES)
    spread_ticks: f64,

    /// Preloaded training data cached across hyperopt trials.
    /// Loaded once via `preload_data()`, then shared (via Arc) across all trials
    /// to avoid re-reading 36 `.dbn.zst` files and re-extracting features per trial.
    preloaded_training_data: Option<Arc<Vec<(FeatureVector, Vec<f64>)>>>,
    /// Preloaded validation data (same lifecycle as training data)
    preloaded_val_data: Option<Arc<Vec<(FeatureVector, Vec<f64>)>>>,

    /// MBP-10 data directory for OFI features (order book snapshots)
    mbp10_data_dir: Option<String>,
    /// Trades data directory for VPIN / Kyle's Lambda (Schema::Trades)
    trades_data_dir: Option<String>,
    /// Preloaded OFI features from MBP-10 data (8 features per bar).
    /// Loaded during `preload_data()` and injected into each trial's DQNTrainer.
    preloaded_ofi_features: Option<Arc<[[f64; 8]]>>,
}

/// Decode OHLCV bars from an already-opened DBN decoder.
// Re-use shared DBN loading utilities
use super::dbn_loader::{collect_dbn_files, decode_ohlcv_bars};

impl DQNTrainer {
    /// Create a new DQN trainer
    ///
    /// # Arguments
    ///
    /// * `dbn_data_dir` - Path to directory with DBN market data files
    /// * `epochs` - Number of training epochs per trial
    ///
    /// # Returns
    ///
    /// Configured trainer ready for optimization
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - DBN data directory doesn't exist
    /// - No DBN files found in directory
    pub fn new<P: Into<PathBuf>>(dbn_data_dir: P, epochs: usize) -> anyhow::Result<Self> {
        Self::with_buffer_max(dbn_data_dir, epochs, 100_000)
    }

    /// Create a new DQN trainer with custom buffer size limit
    ///
    /// # Arguments
    ///
    /// * `dbn_data_dir` - Path to directory with DBN market data files
    /// * `epochs` - Number of training epochs per trial
    /// * `buffer_size_max` - Maximum replay buffer size (default: 100_000 for 4GB GPUs)
    ///
    /// # Returns
    ///
    /// Configured trainer ready for optimization
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - DBN data directory doesn't exist
    /// - No DBN files found in directory
    pub fn with_buffer_max<P: Into<PathBuf>>(
        dbn_data_dir: P,
        epochs: usize,
        buffer_size_max: 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());
        }

        info!("DQN Trainer initialized:");
        info!("  Data directory: {}", dbn_data_dir.display());
        info!("  Epochs per trial: {}", epochs);
        info!("  Max buffer size: {}", buffer_size_max);

        // Initialize CUDA device at construction time — GPU is required for RL hyperopt
        let device = MlDevice::new_cuda(0)
            .map_err(|e| MLError::ConfigError(format!("CUDA GPU required for DQN hyperopt: {}", e)))?;
        info!("  Device: CUDA GPU");

        // Reuse existing runtime or create a multi-threaded one.
        // Runtime::new() creates a current-thread scheduler → 1 OS thread → CPU bottleneck.
        // Multi-thread allows async overlap: GPU kernel dispatch + CPU backtest + data I/O.
        let (runtime_handle, _owned_runtime) = match tokio::runtime::Handle::try_current() {
            Ok(handle) => {
                info!("  Runtime: Reusing existing Tokio runtime");
                (Some(handle), None)
            },
            Err(_) => {
                let rt = tokio::runtime::Builder::new_multi_thread()
                    .worker_threads(4)
                    .enable_all()
                    .build()
                    .map_err(|e| MLError::ConfigError(format!("Failed to create Tokio runtime: {}", e)))?;
                let handle = rt.handle().clone();
                let rt_arc = Arc::new(rt);
                info!("  Runtime: Created multi-thread Tokio runtime (4 workers)");
                (Some(handle), Some(rt_arc))
            },
        };

        // 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,
            _owned_runtime,
            training_paths,
            device_pool: vec![device.clone()],
            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: Arc::new(AtomicUsize::new(0)), // Shared across clones for parallel trials
            // FIX: Enable soft updates (root cause of gradient explosion)
            // Hard updates were causing 10K-16K gradient norms (Wave 16H baseline: 1,707)
            tau: 0.001, // Soft updates: 0.1% target blend per step (Rainbow DQN standard)
            target_update_mode: crate::trainers::TargetUpdateMode::Soft, // Soft updates prevent Q-value explosion
            target_update_frequency: 1, // Update every step with soft blending
            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
            best_trial: Arc::new(Mutex::new(None)), // Shared across clones for parallel trials
            feature_cache_dir: None,         // No cache by default
            objective_mode: ObjectiveMode::default(), // Default: Sharpe ratio
            initial_capital: 35_000.0,       // Default: $35K (realistic retail futures)
            tx_cost_bps: 0.1,               // IBKR ES all-in: ~0.08 bps ($2.06/$275K)
            tick_size: 0.25,                 // ES futures tick size
            spread_ticks: 1.0,              // ES typical: 1 tick spread
            preloaded_training_data: None,   // No preloaded data by default
            preloaded_val_data: None,        // Loaded on first call to preload_data()
            mbp10_data_dir: None,            // No MBP-10 OFI by default
            trades_data_dir: None,           // No trades (VPIN/Kyle) by default
            preloaded_ofi_features: None,    // Loaded during preload_data()
        })
    }

    /// Set maximum buffer size (for 4GB GPU memory constraints)
    pub fn with_buffer_size_max(&mut self, max_size: usize) -> &mut Self {
        self.buffer_size_max = max_size;
        info!("Buffer size max updated to: {}", max_size);
        self
    }

    /// Set training paths configuration (recommended over hardcoded checkpoint directories)
    ///
    /// # Arguments
    ///
    /// * `paths` - Training paths configuration
    ///
    /// # Returns
    ///
    /// Self for method chaining
    pub fn with_training_paths(mut self, paths: TrainingPaths) -> Self {
        info!("DQN training paths set: run_dir={:?}", paths.run_dir());
        self.training_paths = paths;
        self
    }

    /// Configure early stopping parameters (overrides defaults)
    pub fn with_early_stopping(mut self, plateau_window: usize, min_epochs: usize) -> Self {
        self.early_stopping_plateau_window = plateau_window;
        self.early_stopping_min_epochs = min_epochs;
        self
    }

    /// Configure initial trading capital
    pub fn with_initial_capital(mut self, capital: f64) -> Self {
        self.initial_capital = capital;
        info!("Initial capital set to: ${:.0}", capital);
        self
    }

    /// Configure transaction costs (commission + spread)
    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!("DQN costs configured: tx={} bps, spread={} ticks (tick_size={})",
              tx_cost_bps, spread_ticks, tick_size);
        self
    }

    /// Override the compute device (CPU or CUDA).
    /// Use CPU for parallel hyperopt — DQN's tiny network runs faster on CPU
    /// when multiple trials share a single GPU context.
    pub fn with_device(mut self, device: MlDevice) -> Self {
        info!(
            "Device overridden to: {}",
            "CUDA GPU"
        );
        self.device_pool = vec![device.clone()];
        self.device = device;
        self
    }

    /// Set a pool of GPU devices for multi-GPU trial parallelism.
    ///
    /// Each trial selects `devices[trial_num % devices.len()]`, spreading
    /// independent hyperopt trials across all available GPUs. No inter-GPU
    /// communication — pure trial-level parallelism.
    pub fn with_devices(mut self, devices: Vec<MlDevice>) -> Self {
        if devices.is_empty() {
            debug!("Empty device pool passed to with_devices(), keeping existing");
            return self;
        }
        info!("Multi-GPU device pool: {} devices", devices.len());
        for (i, _d) in devices.iter().enumerate() {
            info!("  GPU {}: CUDA", i);
        }
        self.device = devices[0].clone();
        self.device_pool = devices;
        self
    }

    /// Configure Polyak averaging coefficient (tau) for soft target updates
    /// Default: 0.001 (Rainbow DQN standard, ~693-step convergence half-life)
    pub fn with_tau(mut self, tau: f64) -> Self {
        self.tau = tau;
        self
    }

    /// Configure target update mode (Soft or Hard)
    /// Default: Soft (Polyak averaging every step)
    pub fn with_target_update_mode(mut self, mode: crate::trainers::TargetUpdateMode) -> Self {
        self.target_update_mode = mode;
        self
    }

    /// Configure preprocessing settings
    /// Default: Enabled with window=50, clip_sigma=5.0
    pub fn with_preprocessing(mut self, enable: bool, window: i64, clip_sigma: f64) -> Self {
        self.enable_preprocessing = enable;
        self.preprocessing_window = window;
        self.preprocessing_clip_sigma = clip_sigma;
        self
    }

    /// Enable backtest metrics calculation (Sharpe-based optimization)
    ///
    /// When enabled, runs backtest on validation set after training and includes
    /// Sharpe ratio in the objective function. Disabled by default for backward compatibility.
    pub fn with_backtest(mut self, enable: bool) -> Self {
        self.enable_backtest = enable;
        self
    }
    
    /// Enable feature caching for faster hyperopt trials
    ///
    /// When enabled, pre-computed features are loaded from cache instead of
    /// being extracted from OHLCV data on every trial.
    pub fn with_feature_cache(mut self, cache_dir: PathBuf) -> Self {
        self.feature_cache_dir = Some(cache_dir);
        self
    }

    /// Set MBP-10 and trades data directories for OFI features (VPIN, Kyle's Lambda)
    pub fn with_ofi_data_dirs(mut self, mbp10_dir: Option<String>, trades_dir: Option<String>) -> Self {
        self.mbp10_data_dir = mbp10_dir;
        self.trades_data_dir = trades_dir;
        self
    }

    /// Preload training data from DBN files once, caching it for reuse across
    /// all hyperopt trials. This eliminates the per-trial cost of reading 36
    /// `.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:
    /// - DBN data directory is a single parquet file (use file cache instead)
    /// - Internal DQN trainer creation fails
    /// - Data loading or feature extraction fails
    pub fn preload_data(&mut self) -> Result<(), MLError> {
        let is_parquet = self
            .dbn_data_dir
            .extension()
            .and_then(|s| s.to_str())
            == Some("parquet");
        if is_parquet {
            info!("Skipping data preload for parquet path (uses its own cache)");
            return Ok(());
        }

        let data_path_str = self.dbn_data_dir.to_str().ok_or_else(|| MLError::ConfigError("Invalid UTF-8 in data path".to_owned()))?;

        info!("Preloading training data from: {} ...", data_path_str);
        let preload_start = std::time::Instant::now();

        // Create a minimal internal trainer for data loading only.
        // Disable GPU-heavy features: no replay buffers, no regime heads, no experience collector.
        // These would waste ~300+ MB of VRAM that's never used (preload only calls load_training_data).
        let mut preload_hyperparams = DQNHyperparameters::default();
        preload_hyperparams.mbp10_data_dir = self.mbp10_data_dir.clone();
        preload_hyperparams.trades_data_dir = self.trades_data_dir.clone();
        preload_hyperparams.enable_regime_qnetwork = false;
        preload_hyperparams.buffer_size = 1;
        let mut loader = InternalDQNTrainer::new_with_device(preload_hyperparams, self.device.clone())
            .map_err(|e| MLError::TrainingError(format!(
                "Failed to create data loader: {}", e
            )))?;

        // Enable feature cache if configured
        if let Some(cache_dir) = &self.feature_cache_dir {
            loader = loader.with_feature_cache(cache_dir.clone());
        }

        // Load data using the internal trainer's async method
        let preload_handle = self.runtime_handle.as_ref().ok_or_else(|| {
            MLError::ConfigError("BUG: runtime_handle is None — DQNTrainer::new() should always set it".to_owned())
        })?;
        let (train_data, val_data) = preload_handle
            .block_on(loader.load_training_data(data_path_str))
            .map_err(|e| MLError::TrainingError(format!("Failed to preload data: {}", e)))?;

        // Extract per-bar OFI features computed by load_training_data().
        // These are properly aligned 1:1 with OHLCV bars (NOT per-MBP-10-snapshot).
        // Falls back to the parallel snapshot-level loader if the internal trainer
        // didn't compute per-bar OFI (e.g. MBP-10 files unavailable at load time).
        let ofi_features = loader.ofi_features.take().or_else(|| {
            debug!("Per-bar OFI not computed by loader, trying parallel snapshot loader");
            self.load_ofi_features()
        });
        if let Some(ref ofi) = ofi_features {
            info!("OFI features preloaded: {} bars x 8 dims (per-bar aligned)", ofi.len());
        } else if self.mbp10_data_dir.is_some() {
            warn!("MBP-10 data dir configured but no OFI features loaded — state_dim mismatch likely");
        } else {
            // No OFI features and no MBP-10 dir — standard feature set only
        }

        let elapsed = preload_start.elapsed();
        info!(
            "Data preloaded: {} train + {} val samples in {:.1}s (cached for all trials)",
            train_data.len(),
            val_data.len(),
            elapsed.as_secs_f64()
        );

        self.preloaded_training_data = Some(Arc::new(train_data));
        self.preloaded_val_data = Some(Arc::new(val_data));
        self.preloaded_ofi_features = ofi_features;
        Ok(())
    }

    /// Returns true if training data has been preloaded.
    pub fn has_preloaded_data(&self) -> bool {
        self.preloaded_training_data.is_some() && self.preloaded_val_data.is_some()
    }

    /// Get the current objective mode
    pub fn objective_mode(&self) -> ObjectiveMode {
        self.objective_mode
    }

    /// Set the objective mode for two-phase optimization
    pub fn set_objective_mode(&mut self, mode: ObjectiveMode) {
        self.objective_mode = mode;
    }

    /// Save best trial to JSON file in ml/hyperopt_results/
    ///
    /// Exports optimal hyperparameters for easy loading in future training runs.
    /// Enables persistence of best results across context limits.
    fn save_best_trial_json(&self, trial_export: &BestTrialExport) -> Result<(), MLError> {
        let json_dir = PathBuf::from("ml/hyperopt_results");
        std::fs::create_dir_all(&json_dir).map_err(|e| {
            MLError::CheckpointError(format!("Failed to create hyperopt_results directory: {}", e))
        })?;

        let timestamp_safe = trial_export.timestamp.replace(":", "-");
        let filename = format!("dqn_best_trial_{}_sharpe_{:.4}.json",
                              timestamp_safe, trial_export.sharpe);
        let json_path = json_dir.join(&filename);

        let json_str = serde_json::to_string_pretty(&trial_export).map_err(|e| {
            MLError::CheckpointError(format!("Failed to serialize best trial to JSON: {}", e))
        })?;

        std::fs::write(&json_path, json_str).map_err(|e| {
            MLError::CheckpointError(format!("Failed to write best trial JSON to {:?}: {}", json_path, e))
        })?;

        info!("✅ Best trial saved to: {:?}", json_path);
        Ok(())
    }

    /// Load training data from Parquet or DBN files (auto-detect)
    ///
    /// This method checks if the data directory contains Parquet files,
    /// and if so, uses them. Otherwise, falls back to DBN files.
    ///
    /// # Returns
    ///
    /// Vector of (state, reward) tuples for DQN training
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - No Parquet or DBN files found
    /// - File format is invalid
    /// - Feature extraction fails
    fn load_training_data(&self) -> anyhow::Result<Vec<([f32; 45], 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 (42-feature vectors)
    ///
    /// Reads OHLCV bars from Parquet file, extracts 42-feature vectors,
    /// and creates (state, reward) tuples for DQN training
    ///
    /// # Returns
    ///
    /// Vector of (state, reward) tuples
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - No Parquet file found in directory
    /// - Parquet file is malformed
    /// - Feature extraction fails
    fn load_from_parquet(&self) -> anyhow::Result<Vec<([f32; 45], 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 (timestamp_ns/ts_event, open, high, low, close, volume)
            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 (expected 'timestamp_ns' or 'ts_event')"
                    )
                })?;

            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"))?;

            // Convert to OHLCV bars
            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; 45], f64)>> {
        use dbn::decode::DbnDecoder;

        let dbn_files = collect_dbn_files(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<Arc<[[f64; 8]]>> {
        use crate::features::mbp10_loader::load_ofi_features_parallel;

        let mbp10_dir = if let Some(ref dir) = self.mbp10_data_dir {
            std::path::PathBuf::from(dir)
        } else {
            std::path::Path::new(&self.dbn_data_dir)
                .parent()
                .map(|p| p.join("mbp10"))?
        };

        // Use explicit trades_data_dir if set, else derive as sibling of OHLCV data
        let trades_dir = if let Some(ref dir) = self.trades_data_dir {
            Some(std::path::PathBuf::from(dir))
        } else {
            std::path::Path::new(&self.dbn_data_dir)
                .parent()
                .map(|p| p.join("trades"))
        };

        load_ofi_features_parallel(&mbp10_dir, trades_dir.as_deref(), |dir| {
            collect_dbn_files(dir)
        }).map(Arc::from)
    }

    /// Extract 53-feature vectors from OHLCV bars and create training data
    ///
    /// Uses the production feature extraction API (extract_ml_features) to
    /// generate 53-feature vectors from OHLCV bars. Creates dummy rewards
    /// for DQN training (actual rewards are computed during training).
    /// When MBP10 data is available, OFI features are overlaid at positions 45-52.
    ///
    /// # Arguments
    ///
    /// * `ohlcv_bars` - Chronologically sorted OHLCV bars
    ///
    /// # Returns
    ///
    /// Vector of (state, reward) tuples where
    /// - state: [f32; 45] feature vector (42 market + 3 portfolio)
    /// - reward: f64 dummy reward (0.0)
    ///
    /// # Errors
    ///
    /// Returns error if:
    /// - Insufficient bars for warmup period (need 51+)
    /// - Feature extraction fails
    fn extract_features_and_targets(
        &self,
        ohlcv_bars: &[crate::features::extraction::OHLCVBar],
    ) -> anyhow::Result<Vec<([f32; 45], f64)>> {
        use crate::features::extraction::extract_ml_features;

        info!(
            "Extracting 53-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; 54]>)
        let feature_vectors = extract_ml_features(ohlcv_bars)
            .map_err(|e| anyhow::anyhow!("Feature extraction failed: {}", e))?;

        info!("Extracted {} feature vectors", 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
            );
        }

        // Convert to [f32; 45] and create dummy rewards (actual rewards computed during training)
        // 42 market features + 3 portfolio state zeros = 45
        let training_data: Vec<([f32; 45], f64)> = feature_vectors
            .into_iter()
            .map(|vec_f64| {
                let mut vec_f32 = [0.0_f32; 45];
                for (j, &val) in vec_f64.iter().enumerate() {
                    vec_f32[j] = val as f32;
                }
                // Indices 42-44 remain 0.0 (portfolio features populated during training)
                (vec_f32, 0.0_f64)
            })
            .collect();

        info!("Created {} training samples", training_data.len());

        Ok(training_data)
    }

    /// GPU-accelerated walk-forward backtest evaluation.
    ///
    /// Uploads validation data to GPU, runs the backtest step loop (env kernel +
    /// model forward) entirely on-device, and downloads only the final per-window
    /// metrics (6 floats per window). Falls back to `None` if data is insufficient.
    fn evaluate_gpu(
        &self,
        internal_trainer: &InternalDQNTrainer,
        val_close_prices: &[f64],
        window_size: usize,
        stride: usize,
        device: &MlDevice,
        max_position_absolute: f64,
    ) -> Result<Option<BacktestMetrics>, MLError> {
        use crate::cuda_pipeline::gpu_backtest_evaluator::{
            DqnBacktestConfig, GpuBacktestConfig, GpuBacktestEvaluator,
        };

        let total_bars = val_close_prices.len();
        let window_count = if window_size == 0 || stride == 0 {
            0
        } else {
            (total_bars.saturating_sub(window_size)) / stride + 1
        };
        if window_count == 0 {
            return Ok(None);
        }

        let val_data = internal_trainer.get_val_data();

        // FIX: The gather kernel places live portfolio at [feat_dim..feat_dim+3].
        // Training builds states as [market(42), portfolio(3), OFI(8)], so portfolio
        // is at indices 42-44. We must pass only market features (42 dims) as the
        // "feature" part so the kernel inserts portfolio at exactly index 42.
        //
        // For OFI-enabled models, we pad features to 50 (42 market + 8 OFI placeholder)
        // so state_dim = (50+3+7)&!7 = 56 (matching training). The forward closure
        // then shuffles [market,ofi_pad,portfolio,pad] → [market,portfolio,ofi_pad,pad].
        let ofi_enabled = self.mbp10_data_dir.is_some();
        let market_dim: usize = 42;
        let feature_dim: usize = if ofi_enabled { 50 } else { market_dim };

        // OFI feature overlay: preloaded OFI covers ALL bars (train+val), left-aligned.
        // Validation bars start at index `train_len` in the global OFI array.
        let ofi_offset = self
            .preloaded_training_data
            .as_ref()
            .map_or(0, |d| d.len());
        let ofi_ref = self.preloaded_ofi_features.as_deref();
        if ofi_enabled {
            let ofi_total = ofi_ref.map_or(0, |o| o.len());
            let ofi_avail = ofi_total.saturating_sub(ofi_offset);
            debug!(
                ofi_offset,
                ofi_total,
                ofi_avail,
                total_bars,
                "walk-forward OFI overlay: {} of {} val bars have real OFI",
                ofi_avail.min(total_bars),
                total_bars,
            );
        }

        let mut window_prices = Vec::with_capacity(window_count);
        let mut window_features = Vec::with_capacity(window_count);

        for win_idx in 0..window_count {
            let start = win_idx * stride;
            let end = (start + window_size).min(total_bars);
            let mut prices = Vec::with_capacity(end - start);
            let mut features = Vec::with_capacity(end - start);

            for i in start..end {
                let close = *val_close_prices.get(i).ok_or_else(|| {
                    MLError::ConfigError(format!(
                        "val_close_prices index {i} out of bounds (len={})",
                        total_bars
                    ))
                })? as f32;
                prices.push([close, close, close, close]);
                let (fv, _target) = val_data.get(i).ok_or_else(|| {
                    MLError::ConfigError(format!(
                        "val_data index {i} out of bounds (len={})",
                        val_data.len()
                    ))
                })?;
                // Take only the 42 market features, strip portfolio zeros at 42-44.
                let fv_slice = fv.get(..market_dim).ok_or_else(|| {
                    MLError::ConfigError(format!(
                        "feature vector too short: {} < {market_dim}",
                        fv.len()
                    ))
                })?;
                let mut fv_f32: Vec<f32> = fv_slice.iter().map(|&v| v as f32).collect();
                // Overlay real OFI features from preloaded MBP-10 data.
                // Falls back to zero-padding only when OFI data is unavailable.
                if ofi_enabled {
                    let ofi_idx = ofi_offset + i;
                    if let Some(ofi) = ofi_ref.filter(|o| ofi_idx < o.len()) {
                        for &v in ofi.get(ofi_idx).iter().flat_map(|f| f.iter()) {
                            fv_f32.push(v as f32);
                        }
                    } else {
                        fv_f32.resize(feature_dim, 0.0);
                    }
                }
                features.push(fv_f32);
            }
            window_prices.push(prices);
            window_features.push(features);
        }
        // Use the hyperopt param's position limit — NOT hardcoded 1.0.
        // Training env uses max_position_absolute directly, so the walk-forward
        // evaluator must match. Leverage cap disabled (max_leverage=0) because
        // the training env doesn't have one; applying it here creates a
        // train/eval mismatch that makes transaction costs dominate any alpha.
        #[allow(clippy::cast_possible_truncation)]
        let config = GpuBacktestConfig {
            max_position: max_position_absolute as f32,
            // Use the SAME tx_cost as training (multiplier from hyperopt).
            // Training: |delta| * close * (multiplier * 0.0001 * impact + premium)
            // Backtest: |delta| * close * tx_cost_bps * 0.0001
            // Pass the training multiplier so both see the same friction.
            tx_cost_bps: internal_trainer.hyperparams().transaction_cost_multiplier as f32,
            spread_cost: (self.tick_size * self.spread_ticks) as f32,
            initial_capital: self.initial_capital as f32,
            max_leverage: 0.0, // Disabled: match training env (no leverage cap)
            // OFI reorder in gather kernel: produces [market, portfolio, OFI, pad]
            // directly, eliminating the Candle narrow+cat closure.
            ofi_dim: if ofi_enabled { 8 } else { 0 },
            ..Default::default()
        };

        let evaluator_stream = device.cuda_stream()
            .map_err(|e| MLError::ConfigError(format!("GpuBacktestEvaluator needs CUDA stream: {e}")))?;
        let mut evaluator = GpuBacktestEvaluator::new(
            &window_prices,
            &window_features,
            feature_dim,
            config,
            evaluator_stream,
        )?;

        let agent_arc = internal_trainer.get_agent().clone();
        let bt_handle = self.runtime_handle.as_ref().ok_or_else(|| {
            MLError::ConfigError(
                "BUG: runtime_handle is None -- DQNTrainer::new() should always set it".to_owned(),
            )
        })?;
        let agent_guard = bt_handle.block_on(agent_arc.read());

        // cuBLAS SGEMM forward for both OFI and non-OFI paths.
        // The gather kernel handles OFI reordering directly (ofi_dim > 0 produces
        // [market, portfolio, OFI, pad] layout), so no Candle dispatch needed.
        let is_branching = agent_guard.is_using_branching();
        let network_dims = agent_guard.network_dims();
        let vars = agent_guard.get_q_network_vars();
        let stream = evaluator.stream();

        let online_weights = if is_branching {
            crate::cuda_pipeline::gpu_weights::extract_dueling_weights_branching(
                &vars, stream,
            )?
        } else {
            crate::cuda_pipeline::gpu_weights::extract_dueling_weights(
                &vars, stream,
            )?
        };

        let branching_weights = if is_branching {
            Some(crate::cuda_pipeline::gpu_weights::extract_branching_weights(
                &vars, stream,
            )?)
        } else {
            None
        };

        let hp = internal_trainer.hyperparams();
        let (b0, b1, b2) = agent_guard.branch_sizes();
        #[allow(clippy::cast_possible_truncation)]
        let dqn_cfg = DqnBacktestConfig {
            shared_h1: network_dims.0,
            shared_h2: network_dims.1,
            value_h: network_dims.2,
            adv_h: network_dims.3,
            num_atoms: hp.num_atoms,
            branch_0_size: b0,
            branch_1_size: if is_branching { b1 } else { b0 },
            branch_2_size: if is_branching { b2 } else { b0 },
            v_min: hp.v_min as f32,
            v_max: hp.v_max as f32,
        };

        info!(
            is_branching,
            ofi_enabled,
            shared_h1 = network_dims.0,
            shared_h2 = network_dims.1,
            value_h = network_dims.2,
            adv_h = network_dims.3,
            num_atoms = hp.num_atoms,
            "cuBLAS SGEMM backtest forward: DuelingWeightSet extracted"
        );

        // Use CUDA Graph-accelerated path for maximum throughput.
        // Falls back to non-graphed evaluate_dqn() on unsupported drivers.
        let metrics = evaluator.evaluate_dqn_graphed(
            &online_weights,
            branching_weights.as_ref(),
            &dqn_cfg,
        )?;

        drop(agent_guard);

        // Aggregate per-window metrics into BacktestMetrics
        let n = metrics.len() as f64;
        if n < 1.0 {
            return Ok(None);
        }

        let mean_sharpe = metrics.iter().map(|m| m.sharpe as f64).sum::<f64>() / n;
        let mean_pnl = metrics.iter().map(|m| m.total_pnl as f64).sum::<f64>() / n;
        let worst_dd = metrics
            .iter()
            .map(|m| m.max_drawdown as f64)
            .fold(0.0_f64, f64::max);
        let mean_sortino = metrics.iter().map(|m| m.sortino as f64).sum::<f64>() / n;
        let mean_wr = metrics.iter().map(|m| m.win_rate as f64).sum::<f64>() / n;
        let total_trades = metrics.iter().map(|m| m.total_trades as f64).sum::<f64>();
        // Extended metrics from the GPU kernel (indices 6-9)
        let mean_var_95 = metrics.iter().map(|m| m.var_95 as f64).sum::<f64>() / n;
        let mean_cvar_95 = metrics.iter().map(|m| m.cvar_95 as f64).sum::<f64>() / n;
        let mean_omega = metrics.iter().map(|m| m.omega_ratio as f64).sum::<f64>() / n;

        // Action distribution from GPU kernel (indices 10-13) — summed across all windows
        let total_buys = metrics.iter().map(|m| m.buy_count as f64).sum::<f64>();
        let total_sells = metrics.iter().map(|m| m.sell_count as f64).sum::<f64>();
        let total_holds = metrics.iter().map(|m| m.hold_count as f64).sum::<f64>();
        let action_denom = (total_buys + total_sells + total_holds).max(1.0);

        // Unique actions: max across windows (union of observed action IDs)
        let unique_actions = metrics
            .iter()
            .map(|m| m.unique_actions as usize)
            .max()
            .unwrap_or(1);

        Ok(Some(BacktestMetrics {
            sharpe_ratio: mean_sharpe,
            total_return_pct: mean_pnl * 100.0,
            max_drawdown_pct: worst_dd * 100.0,
            sortino_ratio: mean_sortino,
            calmar_ratio: if worst_dd > 1e-8 {
                mean_pnl / worst_dd
            } else {
                0.0
            },
            win_rate: mean_wr,
            total_trades: total_trades as usize,
            var_95: mean_var_95,
            cvar_95: mean_cvar_95,
            beta: 0.0,
            alpha: 0.0,
            information_ratio: 0.0,
            omega_ratio: mean_omega,
            unique_actions,
            buy_action_pct: total_buys / action_denom,
            sell_action_pct: total_sells / action_denom,
            hold_action_pct: total_holds / action_denom,
        }))
    }
}

/// Write a log entry to the training log file
fn write_training_log_dqn(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_dqn(
    hyperopt_dir: &std::path::Path,
    trial_result: &crate::hyperopt::traits::TrialResult<DQNParams>,
) -> 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(())
}

// ============================================================================
// Multi-Objective Helper Functions (Wave 4)
// ============================================================================

/// Normalize reward for multi-objective optimization
///
/// Normalizes avg_episode_reward from raw range [-10.0, 10.0] to [-1.0, 1.0].
/// Values are clamped to prevent outliers from dominating the objective function.
///
/// # Arguments
///
/// * `reward` - Raw episode reward from training
///
/// # Returns
///
/// Normalized reward in range [-1.0, 1.0]
///
/// # Why Normalize by 10.0
///
/// The expected reward range is [-10.0, 10.0] based on empirical observations:
/// - Typical rewards: [-0.001, 0.001] (0.01% to 0.1% of expected range)
/// - Extreme rewards: [-1.0, 1.0] (10% of expected range)
/// - Outliers: [-10.0, 10.0] (100% of expected range, rare)
///
/// Dividing by 10.0 maps the expected range to [-1.0, 1.0] for consistent
/// weighting across objective components.
///
/// # Why Clamp to [-1.0, 1.0]
///
/// Prevents outliers (e.g., reward = 50.0) from dominating the objective:
/// - Without clamping: reward=50.0 → normalized=5.0 (10x penalty vs other components)
/// - With clamping: reward=50.0 → normalized=1.0 (equal weight to other components)
///
/// # Why 40% Weight
///
/// Reward is the primary signal (actual trading performance), but not the only signal:
/// - Reward (40%): Measures trading performance (PnL)
/// - Diversity (10%): Ensures balanced action exploration
/// - Stability (10%): Prevents Q-value collapse and gradient explosion
/// - Completion (5%): Encourages full training runs
/// - Hard constraints (35% implicit): Rejects catastrophically bad trials
fn normalize_reward(reward: f64) -> f64 {
    // Target range: [-1.0, 1.0] corresponds to [-10.0, 10.0] raw reward
    // Clamp to prevent outliers from dominating
    (reward / 10.0).clamp(-1.0, 1.0)
}

/// Calculate Shannon entropy of action distribution
///
/// Returns entropy in range [0, log2(3)] where:
/// - 0 = all actions are the same (maximum bias)
/// - log2(3) ≈ 1.585 = uniform distribution (no bias)
fn calculate_action_entropy(action_counts: &[usize]) -> f64 {
    let total: usize = action_counts.iter().sum();
    if total == 0 {
        return 0.0;
    }

    let mut entropy = 0.0;
    for &count in action_counts {
        if count > 0 {
            let p = count as f64 / total as f64;
            entropy -= p * p.log2();
        }
    }
    entropy
}

/// Calculate diversity penalty for action distribution (WAVE 2 FIX #4: ENTROPY-BASED)
///
/// Penalizes configurations where action entropy is too low, indicating extreme bias
/// toward a single action (BUY, SELL, or HOLD). This addresses the critical issue of
/// 99.4% HOLD bias discovered in baseline DQN training.
///
/// # Arguments
///
/// * `action_distribution` - Array of [buy_pct, sell_pct, hold_pct] where each is 0.0-1.0
///
/// # Returns
///
/// Penalty value (0.0 if entropy ≥ 0.5, else catastrophic penalty)
///
/// # Penalty Formula
///
/// ```text
/// entropy = -Σ(p_i × log2(p_i)) for i in [BUY, SELL, HOLD]
/// max_entropy = log2(3) ≈ 1.585 (uniform distribution)
///
/// if entropy < 0.5:
///     penalty = -10.0
/// else:
///     penalty = 0.0
/// ```
///
/// # Why Entropy-Based?
///
/// Entropy directly measures action diversity:
/// - Entropy = 0.0: 100% single action (catastrophic bias)
/// - Entropy = 0.5: ~70% single action (threshold for penalty)
/// - Entropy = 1.0: ~50% single action (healthy diversity)
/// - Entropy = 1.585: 33% per action (perfect uniform distribution)
///
/// # Why 0.5 Threshold?
///
/// Corresponds to approximately 70% single-action dominance:
/// - Below 0.5: Action distribution is degenerate (>70% bias)
/// - Above 0.5: Acceptable action diversity (<70% bias)
///
/// Calibrated based on:
/// - Baseline DQN: 99.4% HOLD → entropy ≈ 0.02 (catastrophic)
/// - Target: Entropy > 0.5 for healthy diversity
/// - Natural preferences (60% HOLD) → entropy ≈ 0.97 (acceptable)
///
/// # Smooth Quadratic Penalty
///
/// Uses -5.0 * (1 - entropy/max_entropy)^2 for continuous gradient signal.
/// At zero entropy: -5.0, at max entropy (uniform): 0.0.
/// This gives PSO smooth gradients everywhere instead of a cliff at 0.5.
///
/// Objective components:
/// - Reward component: ±0.40 (40% weight on normalized reward)
/// - Diversity penalty: 0.0 to -5.0 (smooth quadratic)
/// - Stability penalty: ±0.20 (20% weight)
/// - Completion penalty: 0.0 to 50.0 (linear scale)
///
/// # Reference
///
/// This addresses Bug #0 discovered in Wave 3-A1:
/// - Baseline DQN training: 99.4% HOLD, 0.4% BUY, 0.2% SELL
/// - Root cause: Reward function did not penalize action homogeneity
/// - Original: cliff penalty at entropy < 0.5 (zero gradient signal for PSO)
/// - Current: smooth quadratic penalty (continuous gradient)
fn calculate_diversity_penalty(action_distribution: &[f64; 3]) -> f64 {
    let total_actions = 1000;
    let action_counts: Vec<usize> = action_distribution
        .iter()
        .map(|&pct| (pct * total_actions as f64).round() as usize)
        .collect();

    let entropy = calculate_action_entropy(&*action_counts);
    let max_entropy = (3.0_f64).log2(); // ~1.585

    // Smooth quadratic penalty: -5.0 * (1 - entropy/max_entropy)²
    // At entropy=0: -5.0, at entropy=max: 0.0
    // Smooth gradient everywhere — no cliff
    let normalized = (entropy / max_entropy).clamp(0.0, 1.0);
    -5.0 * (1.0 - normalized).powi(2)
}

/// Calculate stability penalty from gradient norms and Q-value volatility
///
/// Provides smooth PSO gradient signal for training instability detection.
/// Uses log-scale for gradient norm (spans orders of magnitude) and linear
/// ramp for Q-value std.
///
/// # Gradient norm thresholds
///
/// The reported gradient_norm is the **average raw (pre-clip) gradient norm**
/// across all training steps. Gradient clipping is at 10.0, so:
/// - Normal: 1-20 (clip rarely or mildly engaged)
/// - Elevated: 20-200 (clip consistently engaged, training struggling)
/// - Unstable: 200+ (severe gradient pressure, model not converging)
///
/// Threshold=50 starts the penalty where the clip is engaged 5x on average.
/// Log-scale ramp: provides smooth gradient across 50→5000 range.
///
/// # Q-value std thresholds
///
/// Q-value std is the standard deviation of per-epoch average Q-values.
/// With DSR and v_range=[10,50], stable training shows std 0.5-5.
/// - Normal: 0-15
/// - Elevated: 15-100 (Q-values fluctuating significantly across epochs)
/// - Unstable: 100+ (Q-function hasn't converged)
///
/// # Edge Cases
///
/// - NaN/Inf: Assigns maximum penalty (f64::MAX triggers cap)
///
/// # Wave 10 Helper Functions
///
/// These functions implement the exponential tier-based reward shaping system
/// designed to push DQN performance from "mediocre" (Sharpe ~1.0) to "elite" (Sharpe >2.5).
///
/// ## Motivation
///
/// Wave 9's linear objective achieved Sharpe 0.9876 (47% of industry "Good" standard 2.1).
/// The linear formulation provides NO incentive to improve from "good enough" to "elite".
/// Wave 10 addresses this via exponential bonuses for crossing industry benchmark tiers.
///
/// ## References
///
/// - Industry benchmarks (2024-2025): Good (2.0-2.5), Excellent (3.0-3.7), Elite (4.0+)
/// - Wave 9 analysis: /tmp/ml_training/wave9_production/WAVE9_ANALYSIS_REPORT.md
/// - Wave 10 design: /tmp/wave10_incentive_design.md (663 lines, 10 academic citations)

/// Calculate HFT activity score
///
/// Penalizes models that don't actively trade (high HOLD%), rewards balanced BUY/SELL activity.
///
/// ## Arguments
///
/// * `buy_pct` - BUY action percentage [0, 100] (e.g. 27.0 = 27%)
/// * `sell_pct` - SELL action percentage [0, 100]
/// * `hold_pct` - HOLD action percentage [0, 100]
///
/// ## Returns
///
/// Activity score in [-5.0, 2.0] (higher = more active trading)

/// Calculate graduated penalty for insufficient trade count.
///
/// Produces different objective values for different failure modes so PSO
/// gets gradient signal instead of a flat plateau at 1.25.
///
/// Returns penalty in [0.0, 10.0] (additive on objective, higher = worse).
fn calculate_trade_insufficiency_penalty(total_trades: usize) -> f64 {
    const MIN_VIABLE_TRADES: usize = 20; // 20 quality trades >> 100 noise trades

    if total_trades == 0 {
        10.0 // Model does nothing at all
    } else if total_trades < 10 {
        5.0 + 5.0 * (1.0 - total_trades as f64 / 10.0) // 5.0-10.0 range
    } else if total_trades < MIN_VIABLE_TRADES {
        2.0 * (1.0 - total_trades as f64 / MIN_VIABLE_TRADES as f64) // 0.0-2.0 smooth
    } else {
        0.0 // Sufficient trades → rely on Sharpe/Sortino
    }
}

fn calculate_hft_activity_score_wave10(buy_pct: f64, sell_pct: f64, hold_pct: f64) -> f64 {
    // NOTE: buy_pct/sell_pct/hold_pct arrive in 0-100 range (e.g. 27.0 = 27%).
    // No additional scaling needed — thresholds are calibrated for 0-100 values.
    let buy_sell_ratio = (buy_pct + sell_pct) / (hold_pct + 1e-6);
    let min_action_threshold = 15.0;

    if buy_pct < min_action_threshold || sell_pct < min_action_threshold {
        // Penalize models that don't use all actions
        -5.0 * (min_action_threshold - buy_pct.min(sell_pct)) / min_action_threshold
    } else {
        // Reward high BUY/SELL activity (trend-following needs decisive actions)
        2.0 * (buy_sell_ratio / 3.0).min(1.0) // Cap reward at 3:1 ratio
    }
}

fn calculate_stability_penalty(gradient_norm: f64, q_value_std: f64) -> f64 {
    // Handle NaN/Inf gracefully (assign maximum penalty)
    let gradient_norm = if gradient_norm.is_finite() {
        gradient_norm
    } else {
        f64::MAX
    };
    let q_value_std = if q_value_std.is_finite() {
        q_value_std
    } else {
        f64::MAX
    };

    // Gradient penalty: log-scale ramp above threshold=50.
    // Gradient clipping is at 10.0; avg raw grad norm of 50 means the clip
    // is engaged 5x on average — training is struggling to converge.
    // Log scale gives smooth PSO gradient across 50→5000 range.
    // Capped at 3.0 to prevent catastrophic failures from dominating
    // (those are handled by completion_penalty and trade_penalty instead).
    let gradient_penalty = if gradient_norm > 50.0 {
        (gradient_norm / 50.0).ln().min(3.0)
    } else {
        0.0
    };

    // Q-value std penalty: linear ramp above threshold=15.
    // With DSR and v_range=[10,50], stable Q-value std ≈ 0.5-5.
    // Std > 15 means Q-values are fluctuating significantly across epochs.
    let q_value_penalty = if q_value_std > 15.0 {
        ((q_value_std - 15.0) / 85.0).min(3.0)
    } else {
        0.0
    };

    gradient_penalty + q_value_penalty
}

/// Calculate completion penalty for multi-objective optimization
///
/// This function detects catastrophic training failures by checking if trials
/// terminated prematurely (before reaching minimum required epochs).
///
/// ## Penalty Logic
///
/// Linear scale: 50.0 * (1 - epochs_completed / min_epochs), clamped to [0, 50].
/// This gives PSO smooth gradient signal instead of cliff penalties (1000/500)
/// that dominated the objective and gave zero gradient information.
///   - This might indicate a configuration error (e.g., wrong epoch count)
///   - Still penalize to avoid training instability
///
/// - **0.0**: No penalty for successful completion
///   - epochs_completed >= min_epochs
///   - Training completed as expected
///
/// ## Arguments
///
/// * `epochs_completed` - Actual number of epochs trained
/// * `min_epochs` - Minimum expected epochs (typically 10 for DQN hyperopt)
/// * `early_stop_triggered` - Whether early stopping was triggered
///
/// ## Edge Cases
///
/// - If `min_epochs` is 0, no penalty is applied (0.0)
/// - Epochs beyond min_epochs are clamped to 0.0 penalty
///
/// ## Integration
///
/// This is Component 4 of the multi-objective function. The final objective will be:
/// ```
/// objective = reward_weighted + diversity_penalty + stability_penalty + completion_penalty
/// ```
///
/// ## References
///
/// - Wave 3-A2: Multi-objective function design
/// - Wave 4-A5: Completion penalty implementation
/// - Smooth linear penalty replaces cliff (1000/500) for PSO gradient signal
fn calculate_completion_penalty(
    epochs_completed: u32,
    min_epochs: u32,
    _early_stop_triggered: bool,
) -> f64 {
    if min_epochs == 0 {
        return 0.0;
    }
    // Linear scale: 50.0 * (1 - completed/min_epochs), clamped to [0, 50]
    let completion_ratio = (epochs_completed as f64 / min_epochs as f64).clamp(0.0, 1.0);
    50.0 * (1.0 - completion_ratio)
}

impl HyperparameterOptimizable for DQNTrainer {
    type Params = DQNParams;
    type Metrics = DQNMetrics;

    fn train_with_params(&mut self, params: Self::Params) -> Result<Self::Metrics, MLError> {
        tracing::debug!("train_with_params called");
        // 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());
        }

        // MEMORY LEAK FIX: Monitor memory usage per trial
        // new_all() + refresh_all() scans /proc for ALL PIDs — expensive on servers.
        // Only refresh memory: 100x cheaper, same diagnostic value.
        use sysinfo::System;
        let mut sys = System::new();
        sys.refresh_memory();
        let mem_before = sys.used_memory();
        let mem_available_before = sys.available_memory();

        tracing::info!(
            "MEMORY_PROFILE_START: Trial {}, used={}MB, available={}MB",
            current_trial,
            mem_before / 1024 / 1024,
            mem_available_before / 1024 / 1024
        );

        // Fix 1: Clamp buffer size to max (4GB GPU constraint)
        let clamped_buffer_size = params.buffer_size.min(self.buffer_size_max);

        info!("Training DQN with parameters (45D search space):");
        info!("  Learning rate: {:.6}", params.learning_rate);
        info!("  Batch size: {}", params.batch_size);
        info!("  Gamma: {:.3}", params.gamma);
        info!(
            "  Buffer size: {} (requested: {})",
            clamped_buffer_size, params.buffer_size
        );
        info!("  Max position: {:.1}", params.max_position_absolute);
        info!("  Huber delta: {:.3}", params.huber_delta);
        info!("  Entropy coeff: {:.4}", params.entropy_coefficient);
        info!("  Transaction cost mult: {:.2}", params.transaction_cost_multiplier);
        info!("  PER alpha: {:.3}", params.per_alpha);
        info!("  PER beta start: {:.3}", params.per_beta_start);
        info!("  V range: ±{:.1} (symmetric support)", params.v_max);
        info!("  V min: {:.1}, V max: {:.1}", params.v_min, params.v_max);
        info!("  Noisy sigma init: {:.3}", params.noisy_sigma_init);
        info!("  Dueling hidden dim: {}", params.dueling_hidden_dim);
        info!("  N-steps: {}", params.n_steps);
        info!("  Num atoms: {}", params.num_atoms);
        info!("  Eval softmax temp: {:.3}", params.eval_softmax_temp);
        info!("  LR decay type: {} (0=const, 1=linear, 2=cosine)", params.lr_decay_type.round() as i32);
        info!("  DSR eta: {:.4}", params.dsr_eta);
        info!("  Minimum profit factor: {:.2}", params.minimum_profit_factor);

        // HFT constraint validation - return penalized objective on violation
        if let Err(constraint_msg) = params.validate_for_hft_trendfollowing() {
            tracing::warn!(
                "⚠️  Trial {} PRUNED (HFT constraint): {}",
                current_trial,
                constraint_msg
            );

            // Log constraint violation (ensure directory exists first)
            std::fs::create_dir_all(self.training_paths.logs_dir()).ok();
            write_training_log_dqn(
                &self.training_paths.logs_dir(),
                &format!("Trial PRUNED (HFT constraint): {}", constraint_msg),
            )
            .ok();

            // Return heavily penalized metrics to prune this trial
            return Ok(DQNMetrics {
                train_loss: 1000.0,
                val_loss: 1000.0,
                avg_q_value: 0.0,
                final_epsilon: 1.0,
                epochs_completed: 0,
                avg_episode_reward: -1000.0, // Heavy penalty (will give objective = +1000)
                buy_action_pct: 0.0,
                sell_action_pct: 0.0,
                hold_action_pct: 1.0,    // Assume worst case (100% HOLD)
                gradient_norm: f64::MAX, // Maximum penalty
                q_value_std: f64::MAX,   // Maximum penalty
                backtest_metrics: None,  // No backtest for pruned trials
            });
        }

        // VRAM budget gate: reject trial configs that exceed GPU memory BEFORE
        // allocating any tensors. Same estimator runs on all GPUs (4GB or 80GB).
        let budget = HardwareBudget::detect();
        if budget.gpu_memory_mb > 0 {
            // On small GPUs, GPU PER is disabled (CPU-side buffer), so don't
            // penalize VRAM estimate for replay buffer capacity.
            let vram_buffer = if budget.gpu_memory_mb <= 8192 { 0 } else { clamped_buffer_size };
            // Aligned state_dim: 64 with OFI (58→64), 56 without (50→56)
            let aligned_state_dim: usize = if self.mbp10_data_dir.is_some() { 80 } else { 72 };
            let vram_check = budget.trial_fits_vram(
                params.hidden_dim_base,
                params.batch_size,
                aligned_state_dim,
                9,  // DQN actions (branching: 9 exposure head)
                params.num_atoms,
                true,  // noisy_nets always on
                true,  // dueling always on
                vram_buffer,
            );
            match vram_check {
                Ok(estimated_mb) => {
                    info!(
                        "Trial {} VRAM estimate: {:.0} MB / {} MB budget (OK)",
                        current_trial, estimated_mb, budget.gpu_memory_mb
                    );
                }
                Err(vram_msg) => {
                    tracing::warn!(
                        "⚠️  Trial {} PRUNED (VRAM): {}",
                        current_trial, vram_msg
                    );
                    std::fs::create_dir_all(self.training_paths.logs_dir()).ok();
                    write_training_log_dqn(
                        &self.training_paths.logs_dir(),
                        &format!("Trial PRUNED (VRAM): {}", vram_msg),
                    ).ok();
                    return Ok(DQNMetrics {
                        train_loss: 1000.0,
                        val_loss: 1000.0,
                        avg_q_value: 0.0,
                        final_epsilon: 1.0,
                        epochs_completed: 0,
                        avg_episode_reward: -1000.0,
                        buy_action_pct: 0.0,
                        sell_action_pct: 0.0,
                        hold_action_pct: 1.0,
                        gradient_norm: f64::MAX,
                        q_value_std: f64::MAX,
                        backtest_metrics: None,
                    });
                }
            }
        }

        // Log trial start (ensure directory exists first)
        std::fs::create_dir_all(self.training_paths.logs_dir()).ok();
        write_training_log_dqn(
            &self.training_paths.logs_dir(),
            &format!("=== Starting DQN Trial ===\nParams: {:#?}", params),
        )
        .ok();

        // Create all training directories
        self.training_paths
            .create_all()
            .map_err(|e| MLError::ConfigError(format!("Failed to create training directories: {}", e)))?;

        info!("Training directories created:");
        info!("  Checkpoints: {:?}", self.training_paths.checkpoints_dir());
        info!("  Logs: {:?}", self.training_paths.logs_dir());
        info!("  Hyperopt: {:?}", self.training_paths.hyperopt_dir());

        // Create checkpoint callback for saving models
        let checkpoints_dir = self.training_paths.checkpoints_dir();
        let checkpoint_callback = move |epoch: usize,
                                        model_data: Vec<u8>,
                                        is_best: bool|
              -> Result<String, anyhow::Error> {
            let filename = if is_best {
                // Best model checkpoint (final best checkpoint for this trial)
                format!("trial_{}_best.safetensors", current_trial)
            } else {
                // Periodic checkpoint (overwrite previous periodic checkpoint for this trial)
                format!("trial_{}_epoch_{}.safetensors", current_trial, epoch)
            };

            let checkpoint_path = checkpoints_dir.join(&filename);

            // Save checkpoint to disk
            std::fs::write(&checkpoint_path, &model_data)
                .context(format!("Failed to save checkpoint: {:?}", checkpoint_path))?;

            let checkpoint_type = if is_best { "🎉 BEST" } else { "💾" };

            info!(
                "{} Trial {} checkpoint saved: {} ({} bytes)",
                checkpoint_type,
                current_trial,
                checkpoint_path.display(),
                model_data.len()
            );

            Ok(checkpoint_path.to_string_lossy().to_string())
        };

        // WAVE 6 FIX #4: Dynamic gradient clipping based on learning rate
        // High LR causes larger gradient updates, needs tighter clipping to prevent explosions
        // WAVE 64 BUG FIX: Actually use this variable (was unused, causing gradient 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 * 50).min(clamped_buffer_size / 2), // 50x batch for sample diversity
            epochs: self.epochs,
            // Hyperopt: only save best checkpoint (walk-forward eval restores from _best).
            // Periodic saves waste ~250 MB disk I/O per trial on H100 for unused files.
            checkpoint_frequency: self.epochs + 1, // Effectively disabled — best-only via callback
            // Disable early stopping for hyperopt — plateau detector fires falsely
            // on short trials and returns penalty metrics instead of real backtest.
            // Long training runs (train-best, 50 epochs) re-enable via TrainerConfig.
            early_stopping_enabled: false,
            q_value_floor: -5.0,
            min_loss_improvement_pct: 2.0,
            plateau_window: (self.epochs / 2).max(5),
            min_epochs_before_stopping: self.epochs, // Disabled: early stopping off in hyperopt
            hold_penalty: -0.001, // Aligned with production (train_dqn.rs:285)
            // WAVE 1 AGENT 3: Huber loss configuration (matches production)
            huber_delta: params.huber_delta,     // WAVE 17: Use hyperopt value instead of fixed 1.0
            gradient_clip_norm: Some(gradient_clip_norm), // WAVE 64: Dynamic clipping (5.0 for high LR, 10.0 for low LR)
            hold_penalty_weight: params.w_idle, // Deprecated: mapped from w_idle for backward compat
            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,
            // Note: tau is set later at line 2086 (WAVE 26 P1.12)
            target_update_mode: self.target_update_mode.clone(),
            target_update_frequency: self.target_update_frequency,
            // CRITICAL FIX: warmup_steps MUST be 0 for hyperopt.
            // The batch training path (select_actions_batch → forward()) does NOT
            // increment DQN::total_steps. Only select_action() does. So if warmup_steps > 0,
            // train_step() returns (0.0, 0.0) forever — zero loss, zero gradients, no learning.
            warmup_steps: 0,

            // P2-A Enhancement
            #[allow(clippy::cast_possible_truncation)]
            initial_capital: self.initial_capital as f32,
            cash_reserve_percent: 0.0, // P2-B: Default no reserve for hyperopt (can be added to search space later)

            // WAVE 16S: Adaptive Risk Management (WAVE 16S: NOW ENABLED BY DEFAULT)
            enable_kelly_sizing: true,            // Enabled for all hyperopt trials
            enable_volatility_epsilon: true,      // Enabled for all hyperopt trials
            enable_risk_adjusted_rewards: false,  // Disabled: DSR handles risk-adjustment internally
            kelly_fractional: params.kelly_fractional,      // WAVE 19: Use tuned value
            kelly_max_fraction: params.kelly_max_fraction,  // WAVE 19: Use tuned value
            kelly_min_trades: params.kelly_min_trades,      // WAVE 19: Use tuned value
            volatility_window: params.volatility_window,    // WAVE 19: Use tuned value

            // WAVE 35: Advanced Features (WAVE 16S: NOW ENABLED BY DEFAULT)
            enable_regime_qnetwork: true,         // Enabled for all hyperopt trials
            enable_compliance: true,              // Enabled for all hyperopt trials

            // WAVE 16: Core Risk Management (ALL ENABLED BY DEFAULT)
            enable_drawdown_monitoring: true,     // Enabled for all hyperopt trials
            enable_position_limits: true,         // Enabled for all hyperopt trials
            enable_circuit_breaker: true,         // Enabled for all hyperopt trials

            // Wave 16 Portfolio Features (ALL ENABLED BY DEFAULT)
            enable_action_masking: true,          // Enabled for all hyperopt trials
            enable_entropy_regularization: true,  // Enabled for all hyperopt trials
            enable_stress_testing: true,          // Enabled for all hyperopt trials
            max_position_absolute: params.max_position_absolute, // BLOCKER #2: Use hyperopt-tunable position limit

            // WAVE 17: Hyperopt-tuned parameters
            entropy_coefficient: Some(params.entropy_coefficient), // Use hyperopt value
            noisy_epsilon_floor: Some(params.noisy_epsilon_floor), // Exploration floor for noisy nets
            count_bonus_coefficient: Some(params.count_bonus_coefficient), // UCB exploration bonus
            transaction_cost_multiplier: params.transaction_cost_multiplier, // Use hyperopt value

            // Wave 3 (Phase 2): Triple Barrier Method (enabled for production)
            enable_triple_barrier: true,                // Enable for more robust reward signals
            triple_barrier_profit_target_bps: 100,      // Default: 1% profit target (can be tuned later)
            triple_barrier_stop_loss_bps: 50,           // Default: 0.5% stop loss (can be tuned later)
            triple_barrier_max_holding_seconds: 3600,   // Default: 1 hour max holding

            // P0 CRITICAL: Prioritized Experience Replay (now tunable in hyperopt)
            per_alpha: params.per_alpha,                // Tunable 0.4-0.8 (default: 0.6)
            per_beta_start: params.per_beta_start,      // Tunable 0.2-0.6 (default: 0.4)

            // Wave 2.1: Dueling Networks
            dueling_hidden_dim: params.dueling_hidden_dim,  // Wave 6.3: Use hyperopt value

            // Wave 2.2: Multi-Step Returns + Soft Updates
            n_steps: params.n_steps,                    // Wave 6.3: Use hyperopt value (default: 1)
            // Note: tau is already set above in preprocessing section (line 1590)

            // Wave 2.3: Distributional RL (C51)
            num_atoms: params.num_atoms,                // Wave 6.3: Tunable categorical (default: 51)
            v_min: params.v_min,                        // Computed from reward_scale + gamma
            v_max: params.v_max,                        // Computed from reward_scale + gamma
            reward_scale: 10.0,                         // Reward v6 REWARD_SCALE

            // Wave 2.4: Noisy Networks
            noisy_sigma_init: params.noisy_sigma_init,  // Wave 6.3: Tunable continuous (default: 0.5)

            // Two-Phase Feature Normalization Configuration
            feature_stats_collection_ratio: 0.3,        // Default: 30% of epochs for stats collection
            max_feature_stats_epochs: Some(10),         // Default: cap at 10 epochs

            // WAVE 23 P0 Fix: Early Stopping for Hyperopt Efficiency
            // - Kills trials with persistent gradient collapse (5 consecutive epochs)
            // - Adaptive threshold: LR × 100 (not hardcoded 0.1)
            // - Expected savings: 13-26% GPU time across hyperopt run
            // - Trial #2 validation: Would have been killed at epoch 5/20 (75% time saved)
            // - False positive risk: LOW (5-epoch patience + adaptive threshold)
            gradient_collapse_multiplier: 100.0,  // Adaptive threshold (LR × 100)
            gradient_collapse_patience: 5,        // 5 consecutive epochs before early stop

            // LR scheduling — tunable via hyperopt search space (idx 23 in 39D)
            // 0=constant, 1=linear decay, 2=cosine annealing
            // BUG FIX: total_steps = self.epochs (scheduler steps once per epoch,
            // NOT per training step). Previous calc used epochs × steps_per_epoch
            // = ~125,000 while scheduler only advanced 20 steps → cosine progress
            // was 0.016%, LR never decayed. lr_decay_type search dimension was dead.
            lr_decay_type: {
                let total_steps = self.epochs;
                match params.lr_decay_type.round() as i32 {
                    1 => crate::trainers::dqn::lr_scheduler::LRDecayType::Linear {
                        end_lr: 1e-6,
                        total_steps,
                    },
                    2 => crate::trainers::dqn::lr_scheduler::LRDecayType::Cosine {
                        min_lr: 1e-6,
                        total_steps,
                    },
                    _ => crate::trainers::dqn::lr_scheduler::LRDecayType::Constant,
                }
            },
            min_learning_rate: 1e-6,
            lr_min: 1e-6,           // Minimum learning rate floor
            lr_decay_steps: 1000,   // Steps for LR decay (used by Exponential, not currently in search)
            lr_decay_rate: 0.99,    // LR decay rate (used by Exponential, not currently in search)

            // CRITICAL GAP FIX: Weight decay (L2 regularization) - now tunable via hyperopt
            weight_decay: params.weight_decay,

            // WAVE 26 P1.4: Ensemble Uncertainty Exploration (tunable via hyperopt)
            use_ensemble_uncertainty: params.use_ensemble_uncertainty,
            ensemble_size: params.ensemble_size.round() as usize, // Cast f64 to usize
            beta_variance: params.beta_variance,
            beta_disagreement: params.beta_disagreement,
            beta_entropy: params.beta_entropy,
            variance_cap: 1.0, // Fixed in all trials (not in search space)

            // WAVE 26 P1.8: Curiosity-Driven Exploration (Task 11: tunable via hyperopt)
            curiosity_weight: params.curiosity_weight_tunable,

            // WAVE 26 P1.12: Polyak Soft Update
            tau: params.tau, // Use hyperopt-tunable tau

            // WAVE 26 P2.2: Gradient Accumulation (C6: now in hyperopt search space for large GPUs)
            gradient_accumulation_steps: params.gradient_accumulation_steps,

            // WAVE 26 P1.3: Sharpe Ratio Reward Component (C4 FIX: wire from params)
            sharpe_weight: params.sharpe_weight,
            sharpe_window: 20,  // Default: 20-step rolling window

            // WAVE 26 P1.6: Adaptive Dropout Scheduling (wired to dueling network)
            enable_dropout_scheduler: true,
            dropout_initial: 0.5,            // Default: 50% initial dropout
            dropout_final: 0.1,              // Default: 10% final dropout
            dropout_anneal_steps: 10000,     // Default: 10K steps for annealing

            // WAVE 26 P1.7: Hindsight Experience Replay (Task 11: now in search space)
            her_ratio: params.her_ratio,
            her_strategy: "future".to_owned(), // Default: future strategy

            // WAVE 26 P1.9: Generalized Advantage Estimation
            enable_gae: false,               // Default: disabled (not in search space yet)
            gae_lambda: 0.95,                // Default: 0.95 (standard PPO/A2C value)

            // WAVE 26 P1.11: Noisy Network Sigma Scheduling
            // Must match conservative() defaults so hyperopt params generalize to train_best
            enable_noisy_sigma_scheduler: true,  // Match production defaults (conservative())
            noisy_sigma_initial: 0.8,            // Match conservative(): 80% initial noise for 45-action space
            noisy_sigma_final: 0.4,              // 40% final noise
            noisy_sigma_anneal_steps: 10000,     // 10K steps for annealing

            // QR-DQN (replaces disabled C51)
            num_quantiles: params.num_quantiles,
            qr_kappa: params.qr_kappa,
            iqn_lambda: params.iqn_lambda,
            spectral_norm_sigma_max: params.spectral_norm_sigma_max,

            // Conservative Q-Learning (CQL) — wired from hyperopt search space
            cql_alpha: params.cql_alpha,

            // BUG #7: minimum_profit_factor — tunable via hyperopt (idx 25 in 26D, [1.1, 2.0])
            // Previously hardcoded to 1.5, meaning this dimension of the search space was wasted.
            minimum_profit_factor: params.minimum_profit_factor,

            // Phase 3: GPU experience collection — enabled by default.
            // Dynamic scaling (optimal_n_episodes, VRAM budget cap) handles memory
            // constraints at runtime. The trainer falls back to CPU gracefully on
            // init failure (warn + continue), so no static VRAM gate needed.
            // Floor for episode count — optimal_n_episodes() scales up dynamically:
            // H100 (132 SMs) → 8192 episodes, but a higher floor avoids under-utilizing
            // medium GPUs (16-24GB) where optimal_n_episodes might not kick in.
            gpu_n_episodes: if budget.gpu_memory_mb >= 40960 { 512 } else { 128 },
            gpu_timesteps_per_episode: 500,
            avg_spread: 0.0001,

            // GPU-dynamic network sizing (from hyperopt search space)
            hidden_dim_base: Some(params.hidden_dim_base),

            // DSR: always enabled for hyperopt trials, eta from search space
            use_dsr: true,
            dsr_eta: params.dsr_eta,

            // MBP-10 + trades data directories for OFI features (VPIN, Kyle's Lambda)
            mbp10_data_dir: self.mbp10_data_dir.clone(),
            trades_data_dir: self.trades_data_dir.clone(),

            // Offline RL: disabled in hyperopt (online experience collection)
            offline_mode: false,
            dataset_path: None,

            // IQL: disabled in hyperopt (not in search space yet)
            iql_expectile_tau: 0.7,
            iql_advantage_temperature: 3.0,
            use_iql: false,
            branch_hidden_dim: params.branch_hidden_dim,
            // AutoReplaySizer VRAM fraction: scale linearly from 0% at ≤8GB to 80% at ≥80GB.
            // Below 8GB the static buffer_size is used (regime heads + C51 need the headroom).
            // Large GPUs (≥48GB) get 75-80% — model+optimizer is <10% of total VRAM,
            // so a larger replay buffer improves sample diversity per PER batch.
            replay_buffer_vram_fraction: {
                let raw = ((budget.gpu_memory_mb as f64 - 8192.0)
                    / (49152.0 - 8192.0))
                    .clamp(0.0, 1.0);
                let max_fraction = if budget.gpu_memory_mb >= 81920 { 0.80 } else { 0.70 };
                raw * max_fraction
            },

            // GPU walk-forward: disabled in hyperopt (uses preloaded data with manual splits)
            enable_gpu_walk_forward: false,
            wf_initial_train_fraction: 0.5,
            wf_val_fraction: 0.125,
            wf_test_fraction: 0.125,
            wf_step_fraction: 0.125,

            // C8: Composite reward weights — wired from hyperopt search space (indices 31-38)
            w_dsr: params.w_dsr,
            w_pnl: params.w_pnl,
            w_dd: params.w_dd,
            w_idle: params.w_idle,
            dd_threshold: params.dd_threshold,
            loss_aversion: params.loss_aversion,
            time_decay_rate: params.time_decay_rate,
            q_gap_threshold: params.q_gap_threshold,
            // C51 warmup: MSE loss for first N epochs (index 40 in search space)
            c51_warmup_epochs: params.c51_warmup_epochs,
            // Decision Transformer pre-training: disabled for all hyperopt trials
            dt_pretrain_epochs: params.dt_pretrain_epochs,
            // Task 11: CVaR action selection — wired from hyperopt search space (indices 43-44)
            use_cvar_action_selection: params.use_cvar_action_selection.round() >= 1.0,
            cvar_alpha: params.cvar_alpha as f32,
            ..Default::default()
        };

        let data_path_str = self
            .dbn_data_dir.to_str()
            .ok_or_else(|| MLError::ConfigError("Invalid UTF-8 in data path".to_owned()))?;

        // Parquet path eliminated — all data loads via DBN pipeline.

        // Create a FRESH MlDevice per trial to avoid cuDevicePrimaryCtx bind failures.
        // Reusing the pool's Arc<CudaContext> after a previous trial's Drop leaves the
        // CUDA thread context stack in a bad state on some drivers (RTX 3050).
        let trial_gpu_ordinal = match &trial_device {
            MlDevice::Cuda { context, .. } => context.ordinal(),
            _ => 0,
        };
        let fresh_device = MlDevice::cuda(trial_gpu_ordinal)
            .map_err(|e| MLError::TrainingError(format!("Failed to create fresh CUDA device: {}", e)))?;
        let mut internal_trainer = InternalDQNTrainer::new_with_device(hyperparams.clone(), fresh_device)
            .map_err(|e| MLError::TrainingError(format!("Failed to create DQN trainer: {}", e)))?;
        
        // Enable feature cache if provided
        if let Some(cache_dir) = &self.feature_cache_dir {
            internal_trainer = internal_trainer.with_feature_cache(cache_dir.clone());
            info!("📦 Feature cache enabled: {:?}", cache_dir);
        }

        // Two-phase stress tester init: now that the trainer is fully constructed,
        // resolve the circular dependency by creating the inner stress tester.
        // Stress tester creates a SECOND DQNTrainer — may OOM on small GPUs (4GB).
        // Non-fatal: training proceeds without stress testing.
        if let Err(e) = internal_trainer.init_stress_tester() {
            tracing::warn!("Stress tester init skipped (non-fatal, likely OOM): {}", e);
        }

        // Inject preloaded OFI features into the trial trainer (Arc-clone, zero-copy)
        if let Some(ref ofi) = self.preloaded_ofi_features {
            internal_trainer.ofi_features = Some(Arc::clone(ofi));
            info!("Injected preloaded OFI features: {} bars x 8 dims (Arc-shared)", ofi.len());
        }

        // Cache reference for the closure (preloaded data lives on self, but
        // catch_unwind requires the closure to be UnwindSafe — Arc<Vec<_>> is).
        let cached_train = self.preloaded_training_data.clone();
        let cached_val = self.preloaded_val_data.clone();

        // Wrap training in catch_unwind for CUDA OOM handling (NOT trainer creation)
        let training_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            // Decide whether to use preloaded data or load from disk.
            // Parquet files still go through the original path (normalisation + stats).
            let use_preloaded = cached_train.is_some() && cached_val.is_some();

            // Borrow preloaded data (zero-copy) or fall back to loading from disk.
            // Val data is always cloned into the trainer (small: ~20% of total).
            // Training data is borrowed via train_with_shared_data (large: ~80%).
            let handle = self.runtime_handle.as_ref().ok_or_else(|| {
                MLError::ConfigError("BUG: runtime_handle is None — DQNTrainer::new() should always set it".to_owned())
            })?;
            let training_metrics = if use_preloaded {
                let train_ref = cached_train.as_ref().ok_or_else(|| {
                    MLError::TrainingError("cached_train is None but use_preloaded is true".to_owned())
                })?;
                let val_data = cached_val.as_ref().map(|a| a.as_ref().clone()).unwrap_or_default();
                info!("Training DQN with shared data ({} train, {} val samples)",
                      train_ref.len(), val_data.len());
                handle.block_on(internal_trainer.train_with_shared_data(
                    train_ref, val_data, checkpoint_callback,
                ))
            } else {
                info!("Training DQN with DBN directory: {}", data_path_str);
                handle.block_on(internal_trainer.train(data_path_str, checkpoint_callback))
            }
            .map_err(|e| MLError::TrainingError(format!("DQN training failed: {}", e)))?;

            Ok::<_, MLError>(training_metrics)
        }));

        // Handle panic (CUDA OOM or other catastrophic failure)
        let training_metrics = match training_result {
            Ok(Ok(metrics)) => metrics,
            Ok(Err(e)) => {
                // Check if this is an early stopping error (valid trial outcome, not a crash)
                let err_msg = format!("{}", e);
                if err_msg.contains("early stopping") || err_msg.contains("Training terminated") {
                    tracing::warn!("Trial hit early stopping: {}", err_msg);
                    tracing::warn!("Returning penalty metrics to continue hyperopt (trial scored as poor)");
                    return Ok(DQNMetrics {
                        train_loss: 100.0,
                        val_loss: 100.0,
                        avg_q_value: 0.0,
                        final_epsilon: 1.0,
                        epochs_completed: 0,
                        avg_episode_reward: -500.0,
                        buy_action_pct: 0.0,
                        sell_action_pct: 0.0,
                        hold_action_pct: 1.0,
                        gradient_norm: 100.0,
                        q_value_std: 100.0,
                        backtest_metrics: None,
                    });
                }
                // Other training errors are still fatal
                return Err(e);
            },
            Err(panic_err) => {
                // Panic occurred (likely CUDA OOM)
                let panic_msg = if let Some(s) = panic_err.downcast_ref::<&str>() {
                    s.to_string()
                } else if let Some(s) = panic_err.downcast_ref::<String>() {
                    s.clone()
                } else {
                    "Unknown panic".to_owned()
                };

                tracing::warn!("DQN training panicked (likely CUDA OOM): {}", panic_msg);
                tracing::warn!("Returning penalty metrics to continue hyperopt");

                // Return penalty metrics to avoid crashing entire hyperopt run
                // Use large negative reward so optimizer avoids this configuration
                return Ok(DQNMetrics {
                    train_loss: 1000.0,
                    val_loss: 1000.0,
                    avg_q_value: 0.0,
                    final_epsilon: 1.0,
                    epochs_completed: 0,
                    avg_episode_reward: -1000.0, // Penalty reward (will give objective = +1000)
                    buy_action_pct: 0.0,
                    sell_action_pct: 0.0,
                    hold_action_pct: 1.0,    // Assume worst case (100% HOLD)
                    gradient_norm: f64::MAX, // Maximum penalty
                    q_value_std: f64::MAX,   // Maximum penalty
                    backtest_metrics: None,
                });
            },
        };

        tracing::debug!("Training completed successfully, extracting metrics");
        tracing::debug!("Checking validation data - internal_trainer.get_val_data().len() = {}", internal_trainer.get_val_data().len());

        // Extract metrics from TrainingMetrics struct
        // Note: TrainingMetrics.loss is a single f64, not a Vec
        // Q-values, epsilon, and rewards are stored in additional_metrics HashMap
        let avg_q_value = training_metrics
            .additional_metrics
            .get("avg_q_value")
            .copied()
            .unwrap_or(0.0);
        let avg_gradient_norm = training_metrics
            .additional_metrics
            .get("avg_gradient_norm")
            .copied()
            .unwrap_or(0.0);
        let avg_episode_reward = training_metrics
            .additional_metrics
            .get("avg_episode_reward")
            .copied()
            .unwrap_or(0.0);

        // WAVE 3 AGENT A3: Check hard constraints for early trial pruning
        // BACKTEST INTEGRATION FIX: Constraints disabled to allow backtest-based evaluation
        // Previously, trials were pruned based on Q-values and gradients BEFORE backtest ran
        // This caused 100% fallback objective usage. Now we rely on backtest Sharpe ratio
        // to determine trial quality, which is a superior metric to arbitrary thresholds.
        //
        // Historical thresholds (now disabled):
        // - HOLD bias: >95% (too restrictive for diverse strategies)
        // - Gradient norm: >3000.0 (varies widely across hyperparameters, not indicative of failure)
        // - Q-value: <-100.0 (healthy DQN can have Q-values from -500 to +500 in trading)
        //
        // New approach: Let all trials complete and backtest, then sort by Sharpe ratio.
        // Bad trials will naturally get poor Sharpe ratios (<0.5) and be deprioritized.
        let _constraint_violated = false; // DISABLED: No early pruning based on training metrics
        let _violation_reason = String::new(); // Kept for future use if needed

        // Log diagnostic metrics for monitoring (not used for pruning)
        let hold_percentage = training_metrics
            .additional_metrics
            .get("hold_percentage")
            .copied()
            .unwrap_or(0.0);
        tracing::debug!(
            "Trial {} diagnostics: hold={:.1}%, grad_norm={:.2}, avg_q={:.2}",
            current_trial, hold_percentage, avg_gradient_norm, avg_q_value
        );

        // Extract action counts from metrics
        let buy_count = training_metrics
            .additional_metrics
            .get("buy_count")
            .copied()
            .unwrap_or(0.0);
        let sell_count = training_metrics
            .additional_metrics
            .get("sell_count")
            .copied()
            .unwrap_or(0.0);
        let hold_count = training_metrics
            .additional_metrics
            .get("hold_count")
            .copied()
            .unwrap_or(0.0);
        let total_actions = training_metrics
            .additional_metrics
            .get("total_actions")
            .copied()
            .unwrap_or(1.0); // Avoid division by zero

        // Calculate action percentages (0.0 to 1.0, not 0-100)
        let buy_action_pct = buy_count / total_actions;
        let sell_action_pct = sell_count / total_actions;
        let hold_action_pct = hold_count / total_actions;

        // Extract stability metrics
        // avg_gradient_norm is already extracted earlier (line ~918)
        // q_value_std is computed in build_final_metrics() from q_value_history std dev
        let q_value_std = match training_metrics.additional_metrics.get("q_value_std") {
            Some(&v) => v,
            None => {
                tracing::debug!("q_value_std missing from training metrics — stability penalty will be zero");
                0.0
            }
        };

        tracing::debug!("Reached backtest decision point");
        tracing::debug!("enable_backtest = {}", self.enable_backtest);

        // Restore best checkpoint before walk-forward evaluation.
        // Training may overfit in later epochs; the best per-epoch Sharpe checkpoint
        // (saved during training) gives a more accurate walk-forward evaluation.
        let best_ckpt_path = self.training_paths.checkpoints_dir()
            .join(format!("trial_{}_best.safetensors", current_trial));
        if best_ckpt_path.exists() {
            let best_epoch = internal_trainer.get_best_epoch();
            let agent_arc = internal_trainer.get_agent().clone();
            let bt_handle = self.runtime_handle.as_ref().ok_or_else(|| {
                MLError::ConfigError("runtime_handle is None".to_owned())
            })?;
            let mut agent_guard = bt_handle.block_on(agent_arc.write());
            let ckpt_str = best_ckpt_path.to_string_lossy().to_string();
            match &mut *agent_guard {
                crate::trainers::dqn::DQNAgentType::Standard(ref mut dqn) => {
                    if let Err(e) = dqn.load_from_safetensors(&ckpt_str) {
                        tracing::warn!("Failed to restore best checkpoint for walk-forward: {}", e);
                    } else {
                        info!("Restored best checkpoint (epoch {}) for walk-forward evaluation", best_epoch);
                    }
                }
                crate::trainers::dqn::DQNAgentType::RegimeConditional(ref mut regime) => {
                    // serialize_model() saves all 3 heads with prefixed tensor names.
                    // load_from_merged_safetensors() splits by prefix and loads each head.
                    // Falls back to trending-only for legacy single-head checkpoints.
                    if let Err(e) = regime.load_from_merged_safetensors(&ckpt_str) {
                        tracing::warn!("Failed to restore best checkpoint for walk-forward (RegimeConditional): {}", e);
                    } else {
                        info!("Restored best checkpoint (epoch {}) for walk-forward evaluation (all 3 regime heads)", best_epoch);
                    }
                }
            }
            drop(agent_guard);
        }

        // Run backtest if enabled
        let backtest_metrics = if self.enable_backtest {
            tracing::debug!("Backtest is enabled, starting backtest...");
            tracing::info!("Running backtest on validation data...");

            // Pre-extract close prices from validation data (drops borrow after this block)
            let val_close_prices: Vec<f64> = {
                let val_data = internal_trainer.get_val_data();
                tracing::debug!("Got {} validation samples for backtest", val_data.len());
                val_data
                    .iter()
                    .map(|(fv, target)| {
                        if target.len() >= 2 {
                            target[0]
                        } else {
                            fv[3]
                        }
                    })
                    .collect()
            };

            if val_close_prices.is_empty() {
                tracing::warn!("No validation data available for backtest");
                None
            } else {
                // ── Multi-window chunked batch inference backtest ───────────
                //
                // Split validation data into `window_count` non-overlapping
                // windows and run an independent backtest on each. The final
                // objective uses `mean(Sharpe) - 0.5 * std(Sharpe)` to penalize
                // inconsistency across windows, reducing overfit to one segment.
                //
                // Within each window the same chunked GPU inference strategy is
                // used: EVAL_CHUNK_SIZE bars per GPU forward pass → sequential
                // trade simulation on CPU → portfolio sync between chunks.
                const EVAL_CHUNK_SIZE: usize = 1024;
                // Cap window size to prevent extreme multiplicative compounding.
                // 10K bars ≈ 25 trading days of 1-minute data — enough for stable
                // Sharpe estimates without astronomical cumulative returns.
                // (Previous: total/3 ≈ 300K bars → (1.00002)^300K = billions %)
                const MAX_WINDOW_BARS: usize = 10_000;
                // Target window count for walk-forward cross-validation.
                // More windows = better statistical estimate but slower evaluation.
                const TARGET_WINDOWS: usize = 10;

                let device = internal_trainer.device().clone();

                let total_bars = val_close_prices.len();
                let window_size = (total_bars / 3).min(MAX_WINDOW_BARS);

                // Distribute windows EVENLY across the full validation set.
                // Old approach: 50% overlap stride → clustered at the start,
                // only 6% of data evaluated when window_count was capped.
                // New approach: stride = (total - window) / (N-1) so windows
                // span the entire time series for representative evaluation.
                let (stride, window_count) = if window_size == 0 || total_bars < window_size {
                    (1, 0)
                } else {
                    let available = total_bars - window_size;
                    // How many windows can we fit with 50% overlap?
                    let overlap_stride = (window_size / 2).max(1);
                    let overlap_windows = available / overlap_stride + 1;

                    if overlap_windows <= TARGET_WINDOWS {
                        // Few enough — use 50% overlap for maximum coverage
                        (overlap_stride, overlap_windows)
                    } else {
                        // Too many — redistribute evenly across the full dataset
                        let n = TARGET_WINDOWS;
                        let even_stride = if n <= 1 { available } else { available / (n - 1) };
                        (even_stride.max(1), n)
                    }
                };

                // Need at least 1 bar per window to produce meaningful metrics
                if window_size == 0 || window_count == 0 {
                    tracing::warn!(
                        "Validation data too small for multi-window backtest ({} bars, window_size={})",
                        total_bars, window_size,
                    );
                    None
                } else {
                    // Try GPU-accelerated backtest (CUDA only)
                    let gpu_result: Option<BacktestMetrics> = {
                        if device.is_cuda() {
                            match self.evaluate_gpu(
                                &internal_trainer,
                                &val_close_prices,
                                window_size,
                                stride,
                                &device,
                                params.max_position_absolute,
                            ) {
                                Ok(m) => {
                                    if m.is_some() {
                                        tracing::info!(
                                            "GPU backtest completed: {} windows x {} bars (stride={}, device={:?})",
                                            window_count, window_size, stride, device,
                                        );
                                    }
                                    m
                                }
                                Err(e) => {
                                    return Err(MLError::ModelError(format!(
                                        "GPU backtest FAILED (no CPU fallback): {e}"
                                    )));
                                }
                            }
                        } else {
                            None
                        }
                    };

                    if let Some(metrics) = gpu_result {
                        Some(metrics)
                    } else {
                        return Err(MLError::TrainingError(
                            "GPU backtest returned no metrics — GPU evaluation is mandatory".to_owned()
                        ));
                    }
                }
            }
        } else {
            None
        };

        let metrics = DQNMetrics {
            train_loss: training_metrics.loss,
            val_loss: internal_trainer.get_best_val_loss(), // Use best validation loss for hyperopt
            avg_q_value,
            final_epsilon: training_metrics
                .additional_metrics
                .get("final_epsilon")
                .copied()
                .unwrap_or(0.01),
            epochs_completed: training_metrics.epochs_trained as usize,
            avg_episode_reward,
            buy_action_pct,
            sell_action_pct,
            hold_action_pct,
            gradient_norm: avg_gradient_norm, // Already extracted earlier
            q_value_std,
            backtest_metrics,
        };

        info!("Training completed:");
        info!("  Final train loss: {:.6}", metrics.train_loss);
        info!(
            "  Best val loss: {:.6} at epoch {}",
            metrics.val_loss,
            internal_trainer.get_best_epoch()
        );
        info!("  Avg Q-value: {:.4}", metrics.avg_q_value);

        // Log P&L metrics (from avg_episode_reward which represents portfolio returns)
        // Note: Backtest metrics (Sharpe, win_rate, drawdown) are not yet implemented
        // For now, we log the composite reward which includes portfolio performance
        info!(
            "Trial {} P&L Proxy Metrics: avg_episode_reward={:.4} (composite reward including P&L)",
            current_trial, metrics.avg_episode_reward
        );

        // VERBOSE: If backtest metrics are available, log comprehensive evaluation metrics
        if let Some(ref backtest) = metrics.backtest_metrics {
            info!(
                "EVAL_METRICS: trial={}, sharpe={:.4}, win_rate={:.2}%, max_dd={:.2}%, \
                 total_return={:.2}%, trades={}, sortino={:.4}, calmar={:.4}, omega={:.4}",
                current_trial,
                backtest.sharpe_ratio,
                backtest.win_rate,
                backtest.max_drawdown_pct,
                backtest.total_return_pct,
                backtest.total_trades,
                backtest.sortino_ratio,
                backtest.calmar_ratio,
                backtest.omega_ratio
            );

            // VERBOSE: Log risk metrics
            info!(
                "RISK_METRICS: trial={}, var_95={:.4}, cvar_95={:.4}, beta={:.4}, alpha={:.4}, info_ratio={:.4}",
                current_trial,
                backtest.var_95,
                backtest.cvar_95,
                backtest.beta,
                backtest.alpha,
                backtest.information_ratio
            );
        }

        // NOTE: Final model save removed — walk-forward evaluation restores from
        // trial_{N}_best.safetensors (saved by the checkpoint callback during training).
        // The trial_{N}_model.safetensors was never used downstream, wasting ~200MB
        // disk I/O per trial.

        // END: Add trial completion logging
        let duration_secs = trial_start.elapsed().as_secs_f64();
        write_training_log_dqn(
            &self.training_paths.logs_dir(),
            &format!(
                "Training completed in {:.2}s: train_loss={:.6}, val_loss={:.6}, q_value={:.4}",
                duration_secs, metrics.train_loss, metrics.val_loss, metrics.avg_q_value
            ),
        )
        .ok();

        // CRITICAL FIX: Explicit memory cleanup to prevent OOM between trials
        // Drop training_metrics and sync CUDA to free GPU/RAM
        info!("Cleaning up resources...");
        drop(training_metrics);
        drop(internal_trainer); // MEMORY LEAK FIX: Explicitly drop trainer to free all resources

        // Force CUDA synchronization between trials.
        // NOTE: cudarc caches freed GPU allocations in a memory pool for reuse.
        // sysinfo may report this as a "leak" but it's intentional — next trial
        // reuses the cached blocks without expensive cudaMalloc calls.
        // Candle does not expose cudarc's pool flush, so we synchronize only.
        {
            let cuda_dev = MlDevice::cuda_if_available(0);
            if let Ok(stream) = cuda_dev.cuda_stream() {
                stream.synchronize()
                    .map_err(|e| MLError::TrainingError(format!("CUDA synchronize between trials FAILED — GPU in bad state: {e}")))?;
            }
        }
        info!("Resource cleanup complete");

        // MEMORY LEAK FIX: Monitor memory usage after trial
        sys.refresh_memory();
        let mem_after = sys.used_memory();
        let mem_available_after = sys.available_memory();
        let mem_delta = mem_after as i64 - mem_before as i64;

        tracing::info!(
            "MEMORY_PROFILE_END: Trial {}, used={}MB, delta={}MB, available={}MB",
            current_trial,
            mem_after / 1024 / 1024,
            mem_delta / 1024 / 1024,
            mem_available_after / 1024 / 1024
        );

        // MEMORY LEAK FIX: Warn if memory delta exceeds threshold
        const LEAK_THRESHOLD_MB: i64 = 100; // 100MB per trial
        if mem_delta > LEAK_THRESHOLD_MB * 1024 * 1024 {
            tracing::warn!(
                "MEMORY_LEAK: Trial {} leaked {}MB (threshold: {}MB)",
                current_trial,
                mem_delta / 1024 / 1024,
                LEAK_THRESHOLD_MB
            );
        }

        // MEMORY LEAK FIX: Warn if total available memory is critically low
        const LOW_MEMORY_THRESHOLD_MB: u64 = 500; // 500MB available
        if mem_available_after / 1024 / 1024 < LOW_MEMORY_THRESHOLD_MB {
            tracing::error!(
                "CRITICAL_LOW_MEMORY: Trial {} has only {}MB available (threshold: {}MB). Next trial may OOM!",
                current_trial,
                mem_available_after / 1024 / 1024,
                LOW_MEMORY_THRESHOLD_MB
            );
        }

        // Write trial result to JSON (ensure directory exists first)
        let trial_result = crate::hyperopt::traits::TrialResult {
            trial_num: current_trial,
            params: params.clone(), // Clone params since we need it later for best trial tracking
            objective: Self::extract_objective(&metrics),
            duration_secs,
            metrics: Self::extract_metrics(&metrics),
        };

        std::fs::create_dir_all(self.training_paths.hyperopt_dir()).ok();
        write_trial_result_dqn(&self.training_paths.hyperopt_dir(), &trial_result).ok();

        // VERBOSE: Comprehensive trial summary
        let objective_value = trial_result.objective;
        let best_sharpe = metrics.backtest_metrics.as_ref().map(|b| b.sharpe_ratio).unwrap_or(0.0);
        let best_win_rate = metrics.backtest_metrics.as_ref().map(|b| b.win_rate).unwrap_or(0.0);
        let best_max_dd = metrics.backtest_metrics.as_ref().map(|b| b.max_drawdown_pct).unwrap_or(0.0);
        let best_epoch = metrics.epochs_completed;

        info!(
            "TRIAL_SUMMARY: trial={}, objective={:.4}, sharpe={:.4}, win_rate={:.2}%, \
             max_dd={:.2}%, best_epoch={}, training_time={:.1}s",
            current_trial,
            objective_value,
            best_sharpe,
            best_win_rate,
            best_max_dd,
            best_epoch,
            duration_secs
        );

        // Check if this trial is the best so far and save to JSON (thread-safe via Mutex)
        if let Some(ref backtest) = metrics.backtest_metrics {
            let current_sharpe = backtest.sharpe_ratio;
            let mut best_guard = self.best_trial.lock().unwrap_or_else(|e| e.into_inner());
            let should_save = match &*best_guard {
                None => true, // First valid trial
                Some(best) => current_sharpe > best.sharpe, // Better Sharpe than previous best
            };

            if should_save {
                let trial_export = BestTrialExport {
                    trial_number: current_trial,
                    sharpe: current_sharpe,
                    win_rate: backtest.win_rate,
                    max_drawdown: backtest.max_drawdown_pct,
                    total_return: backtest.total_return_pct,
                    hyperparameters: trial_result.params.clone(),
                    timestamp: Utc::now().to_rfc3339(),
                    gradient_clip_norm: 10.0, // Production default
                };

                info!("NEW BEST TRIAL: #{} with Sharpe {:.4} (previous best: {:.4})",
                      current_trial, current_sharpe,
                      best_guard.as_ref().map(|b| b.sharpe).unwrap_or(f64::NEG_INFINITY));

                if let Err(e) = self.save_best_trial_json(&trial_export) {
                    tracing::warn!("Failed to save best trial JSON: {}", e);
                }

                *best_guard = Some(trial_export);
            }
        }

        Ok(metrics)
    }

    fn extract_objective(metrics: &Self::Metrics) -> f64 {
        // WAVE 9: Profitability-driven objective function
        //
        // Core Principle: "Nothing costs money" - optimize for PROFITABLE ACTIVE TRADING
        // 1. Infrastructure runs 24/7 → HOLD = costs money (idle servers)
        // 2. Negative Sharpe = costs money (losing trades)
        // 3. Solution: Optimize for positive Sharpe + high BUY/SELL activity
        //
        // The optimizer MINIMIZES this objective, so:
        // - Negative Sharpe (losing money) → HIGH objective (bad)
        // - Positive Sharpe (making money) → LOW objective (good)
        // - High HOLD% → HIGH penalty (bad)

        // Use BACKTEST action distribution when available, fall back to training
        let (raw_buy, raw_sell, raw_hold) = if let Some(bt) = &metrics.backtest_metrics {
            (bt.buy_action_pct, bt.sell_action_pct, bt.hold_action_pct)
        } else {
            (metrics.buy_action_pct, metrics.sell_action_pct, metrics.hold_action_pct)
        };
        let buy_pct = raw_buy * 100.0;
        let sell_pct = raw_sell * 100.0;
        let hold_pct = raw_hold * 100.0;

        info!(
            "Action distribution: BUY {:.1}%, SELL {:.1}%, HOLD {:.1}%",
            buy_pct, sell_pct, hold_pct
        );

        // Calculate action entropy for logging
        let action_distribution = [
            metrics.buy_action_pct,
            metrics.sell_action_pct,
            metrics.hold_action_pct,
        ];
        let total_actions = 1000; // Approximate for percentage display
        let action_counts: Vec<usize> = action_distribution
            .iter()
            .map(|&pct| (pct * total_actions as f64).round() as usize)
            .collect();
        let entropy = calculate_action_entropy(&*action_counts);

        info!(
            "Action entropy: {:.4} (max_entropy={:.4}, threshold=0.5)",
            entropy,
            (3.0_f64).log2()
        );

        // Stability penalty raw — used directly in objective (0.15 weight in WAVE 11)
        let stability_penalty_raw =
            calculate_stability_penalty(metrics.gradient_norm, metrics.q_value_std);

        // WAVE 11: Multi-Objective Composite Risk Metrics
        let objective_total = if let Some(backtest) = &metrics.backtest_metrics {
            // TRADE INSUFFICIENCY PENALTY: gives PSO gradient across degenerate plateau
            let trade_penalty = calculate_trade_insufficiency_penalty(backtest.total_trades);

            // ACTION DIVERSITY: greedy argmax legitimately converges to 1-2 actions
            // when the model is confident in a directional bias. Multi-window backtest
            // (3 independent windows, mean - 0.5*std) already penalizes lucky single-bucket
            // flukes. Diversity is a soft signal, not a hard gate.
            if backtest.total_trades < 10 {
                info!(
                    "DEGENERATE TRIAL: {} trades → trade_penalty={:.2} (objective={:.2})",
                    backtest.total_trades, trade_penalty, trade_penalty
                );
                trade_penalty
            } else {
                // Component 1: Multi-objective composite score (60% weight)
                //
                // tanh normalization: maps each ratio to [-1,1] to prevent scale
                // dominance. Divisors calibrated for 1-minute bar annualization
                // (sqrt(98280) ≈ 313.5), which produces Sharpe ~2-10 for good models.
                //
                // Divisor calibration (tanh has useful gradient in [-2, 2]):
                //   Sharpe  /5:  range [-5,15] → tanh [-1.0, 3.0] → [-0.76, 0.995]
                //   Sortino /8:  range [-5,25] → tanh [-0.6, 3.1] → [-0.54, 0.996]
                //   Calmar  /5:  range [0, 15]  → tanh [0, 3.0]   → [0.0,  0.995]
                //   Omega   /2:  range [0,  5]  → tanh [0, 2.5]   → [0.0,  0.987]
                let composite_score =
                    0.40 * (backtest.sharpe_ratio / 5.0).tanh() +
                    0.25 * (backtest.sortino_ratio / 8.0).tanh() +
                    0.20 * (backtest.calmar_ratio / 5.0).tanh() +
                    0.15 * (backtest.omega_ratio / 2.0).tanh();

                // Tail risk penalty: smooth ramp based on per-bar CVaR.
                //
                // CVaR is the mean of per-bar returns below the 5th percentile.
                // For 1-minute bars, typical CVaR is -0.001 to -0.003 (-0.1% to -0.3%/bar).
                // Threshold scaled from daily (0.05) to per-bar: 0.05 / sqrt(390) ≈ 0.003.
                //
                // Penalty ramp:
                //   CVaR = -0.002 → no penalty (normal)
                //   CVaR = -0.005 → penalty ≈ 2.8 (elevated)
                //   CVaR = -0.010 → penalty ≈ 9.8 (max, dangerous)
                let cvar_threshold = 0.003;
                let cvar_penalty = ((-backtest.cvar_95 - cvar_threshold).max(0.0) * 1400.0).min(10.0);

                // Component 2: HFT activity score (25% weight)
                let hft_activity = calculate_hft_activity_score_wave10(buy_pct, sell_pct, hold_pct);

                // Mild diversity bonus: 5/5 unique → 0.0, 1/5 → 0.8 penalty.
                // Soft signal for TPE, never dominates real backtest metrics.
                let mid_diversity_penalty = if backtest.unique_actions < 5 {
                    let ratio = backtest.unique_actions as f64 / 5.0;
                    0.8 * (1.0 - ratio) // 0.64 for 1 action, 0.32 for 3, 0.0 for 5
                } else {
                    0.0
                };

                // Combine: base objective + trade insufficiency + diversity penalty
                let base_objective =
                    -0.60 * composite_score + cvar_penalty + -0.05 * hft_activity + 0.15 * stability_penalty_raw;

                let objective = base_objective + trade_penalty + mid_diversity_penalty;

                info!(
                    "OBJECTIVE: {:.4} = base {:.4} + trade_penalty {:.4} + diversity_penalty {:.4} | trades={} unique={}/5 composite={:.4}",
                    objective, base_objective, trade_penalty, mid_diversity_penalty, backtest.total_trades, backtest.unique_actions, composite_score
                );

                debug!(
                    objective,
                    composite_score,
                    cvar_penalty,
                    hft_activity,
                    stability_penalty_raw,
                    trade_penalty,
                    "Multi-objective breakdown"
                );
                debug!(
                    backtest.win_rate,
                    backtest.total_return_pct,
                    backtest.total_trades,
                    backtest.max_drawdown_pct,
                    "Backtest details"
                );
                debug!(
                    backtest.var_95,
                    backtest.cvar_95,
                    backtest.beta,
                    backtest.alpha,
                    backtest.information_ratio
                );

                objective
            }
        } else {
            // No backtest metrics — training-only fallback.
            // In production hyperopt on GPU, upstream trial runner MUST produce
            // backtest_metrics. This path is for unit tests and edge cases.
            {
                debug!("extract_objective without backtest_metrics — using training-only fallback");
                let reward_component = normalize_reward(metrics.avg_episode_reward);
                let reward_weighted = 0.40 * reward_component;
                let min_epochs = 5;
                let completion_penalty = calculate_completion_penalty(
                    metrics.epochs_completed as u32,
                    min_epochs,
                    metrics.epochs_completed < (min_epochs as usize),
                );
                let hft_activity = calculate_hft_activity_score_wave10(buy_pct, sell_pct, hold_pct);
                reward_weighted + hft_activity + 0.15 * stability_penalty_raw + completion_penalty
            }
        };

        objective_total
    }

    fn extract_metrics(metrics: &Self::Metrics) -> Option<serde_json::Value> {
        let mut m = serde_json::json!({
            "avg_episode_reward": metrics.avg_episode_reward,
            "train_loss": metrics.train_loss,
            "val_loss": metrics.val_loss,
            "epochs_completed": metrics.epochs_completed,
            "buy_action_pct": metrics.buy_action_pct,
            "sell_action_pct": metrics.sell_action_pct,
            "hold_action_pct": metrics.hold_action_pct,
        });
        if let Some(ref bt) = metrics.backtest_metrics {
            m["backtest"] = serde_json::json!({
                "sharpe_ratio": bt.sharpe_ratio,
                "sortino_ratio": bt.sortino_ratio,
                "calmar_ratio": bt.calmar_ratio,
                "omega_ratio": bt.omega_ratio,
                "win_rate": bt.win_rate,
                "max_drawdown_pct": bt.max_drawdown_pct,
                "total_return_pct": bt.total_return_pct,
                "total_trades": bt.total_trades,
                "var_95": bt.var_95,
                "cvar_95": bt.cvar_95,
            });
        }
        Some(m)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_dqn_params_roundtrip() {
        // Roundtrip test for the 45D search space (Task 11: +4 new dims)
        // Only the tuned parameters roundtrip; fixed params get defaults from from_continuous
        let params = DQNParams {
            learning_rate: 3.37e-05,
            batch_size: 92,
            gamma: 0.92,
            buffer_size: 97_273,
            max_position_absolute: 2.5,
            huber_delta: 24.77,
            entropy_coefficient: 0.03, // C2: narrowed range [0.005, 0.05]
            transaction_cost_multiplier: 1.0,
            per_alpha: 0.6,
            per_beta_start: 0.4,
            dueling_hidden_dim: 128,
            n_steps: 3,
            tau: 0.007,
            num_atoms: 51,
            // v_range = 10/(1-0.92)*1.2 = 150, clamped [20,300] → ±150
            v_min: -(10.0_f64 / (1.0 - 0.92) * 1.2).clamp(20.0, 300.0),
            v_max: (10.0_f64 / (1.0 - 0.92) * 1.2).clamp(20.0, 300.0),
            noisy_sigma_init: 0.5,
            minimum_profit_factor: 1.5,
            warmup_ratio: 0.0,
            lr_decay_type: 2.0,
            dsr_eta: 0.02,
            weight_decay: 1e-4,
            kelly_fractional: 0.5,
            kelly_max_fraction: 0.25,
            kelly_min_trades: 20,
            volatility_window: 20,
            use_ensemble_uncertainty: false,
            ensemble_size: 5.0,
            beta_variance: 0.5, beta_disagreement: 0.5, beta_entropy: 0.2,
            curiosity_weight: 0.0,  // C2: fixed to 0.0
            td_error_clamp_max: 10.0,
            batch_diversity_cooldown: 50.0,
            sharpe_weight: 0.0,
            gae_lambda: 0.95,
            noisy_sigma_initial: 0.5,
            noisy_sigma_final: 0.3,
            use_spectral_norm: false,
            use_attention: false,
            use_residual: false,
            norm_type: 1.0,
            activation_type: 1.0,
            num_quantiles: 64,
            qr_kappa: 1.0,
            iqn_lambda: 0.5,
            spectral_norm_sigma_max: 3.0,
            hidden_dim_base: 512,
            noisy_epsilon_floor: 0.10,  // Fixed: 10% floor to prevent action collapse
            count_bonus_coefficient: 0.0,  // C2: fixed to 0.0
            cql_alpha: 0.05,
            eval_softmax_temp: 0.8,
            branch_hidden_dim: 128,
            gradient_accumulation_steps: 4,
            // C8: Composite reward weights
            w_dsr: 1.5,
            w_pnl: 0.5,
            w_dd: 2.0,
            w_idle: 0.05,
            dd_threshold: 0.04,
            loss_aversion: 2.0,
            time_decay_rate: 0.001,
            q_gap_threshold: 0.2,
            c51_warmup_epochs: 5,
            dt_pretrain_epochs: 0,
            // Task 11: New hyperopt-tunable parameters
            her_ratio: 0.3,
            curiosity_weight_tunable: 0.15,
            use_cvar_action_selection: 1.0,
            cvar_alpha: 0.08,
        };

        let continuous = params.to_continuous();
        assert_eq!(continuous.len(), 45, "to_continuous must return 45D vector");
        let recovered = DQNParams::from_continuous(&continuous).unwrap();

        // Tuned parameters must roundtrip exactly
        assert!((recovered.learning_rate - params.learning_rate).abs() < 1e-6);
        assert_eq!(recovered.batch_size, params.batch_size);
        assert!((recovered.gamma - params.gamma).abs() < 1e-6);
        assert_eq!(recovered.buffer_size, params.buffer_size);
        assert!((recovered.max_position_absolute - params.max_position_absolute).abs() < 1e-6);
        assert!((recovered.per_alpha - params.per_alpha).abs() < 1e-6);
        assert!((recovered.per_beta_start - params.per_beta_start).abs() < 1e-6);
        assert!((recovered.weight_decay - params.weight_decay).abs() < 1e-8);
        assert!((recovered.kelly_fractional - params.kelly_fractional).abs() < 1e-6);
        assert!((recovered.kelly_max_fraction - params.kelly_max_fraction).abs() < 1e-6);
        assert_eq!(recovered.volatility_window, params.volatility_window);
        assert!((recovered.tau - params.tau).abs() < 1e-6);
        assert_eq!(recovered.hidden_dim_base, params.hidden_dim_base);
        assert!((recovered.cql_alpha - params.cql_alpha).abs() < 1e-6);
        assert!((recovered.lr_decay_type - params.lr_decay_type).abs() < 1e-6);
        assert!((recovered.dsr_eta - params.dsr_eta).abs() < 1e-4);
        assert!((recovered.minimum_profit_factor - params.minimum_profit_factor).abs() < 1e-6);

        // C2: curiosity_weight, count_bonus_coefficient fixed; noisy_epsilon_floor fixed to 0.10
        assert!((recovered.curiosity_weight - 0.0).abs() < 1e-6);
        assert!((recovered.noisy_epsilon_floor - 0.10).abs() < 1e-6);
        assert!((recovered.count_bonus_coefficient - 0.0).abs() < 1e-6);

        // Other fixed parameters
        assert!((recovered.warmup_ratio - 0.0).abs() < 1e-6);
        assert!((recovered.eval_softmax_temp - 1.0).abs() < 1e-3);
        assert_eq!(recovered.kelly_min_trades, 20);
        assert!((recovered.ensemble_size - 5.0).abs() < 1e-6);
        assert!((recovered.td_error_clamp_max - 10.0).abs() < 1e-6);
        assert!((recovered.batch_diversity_cooldown - 50.0).abs() < 1e-6);
        assert!((recovered.norm_type - 1.0).abs() < 1e-6);
        assert!((recovered.activation_type - 1.0).abs() < 1e-6);
        assert!((recovered.qr_kappa - 1.0).abs() < 1e-6);

        // C6: use_branching roundtrips via index 11
        // C6: gradient_accumulation_steps roundtrips via index 29
        assert_eq!(recovered.gradient_accumulation_steps, params.gradient_accumulation_steps);

        // C8: Composite reward weights roundtrip (indices 31-38)
        assert!((recovered.w_dsr - params.w_dsr).abs() < 1e-6);
        assert!((recovered.w_pnl - params.w_pnl).abs() < 1e-6);
        assert!((recovered.w_dd - params.w_dd).abs() < 1e-6);
        assert!((recovered.w_idle - params.w_idle).abs() < 1e-6);
        assert!((recovered.dd_threshold - params.dd_threshold).abs() < 1e-6);
        assert!((recovered.loss_aversion - params.loss_aversion).abs() < 1e-6);
        assert!((recovered.time_decay_rate - params.time_decay_rate).abs() < 1e-6);

        // Task 11: New hyperopt parameters roundtrip (indices 41-44)
        assert!((recovered.her_ratio - params.her_ratio).abs() < 1e-6);
        assert!((recovered.curiosity_weight_tunable - params.curiosity_weight_tunable).abs() < 1e-6);
        assert!((recovered.use_cvar_action_selection - params.use_cvar_action_selection).abs() < 1e-6);
        assert!((recovered.cvar_alpha - params.cvar_alpha).abs() < 1e-6);
    }

    #[test]
    fn test_dqn_params_bounds() {
        let bounds = DQNParams::continuous_bounds();
        assert_eq!(bounds.len(), 45); // 45D: 39 base + spectral_norm_sigma_max + c51_warmup_epochs + her_ratio + curiosity_weight + use_cvar + cvar_alpha

        // Check log-scale bounds are reasonable
        assert!(bounds[0].0 < bounds[0].1); // learning_rate
        assert!(bounds[3].0 < bounds[3].1); // buffer_size
        assert!(bounds[5].0 < bounds[5].1); // huber_delta
        assert!(bounds[12].0 < bounds[12].1); // noisy_sigma_init
        assert!(bounds[16].0 < bounds[16].1); // weight_decay (log scale)
        assert!(bounds[20].0 < bounds[20].1); // tau (log scale, was 21)

        // Check learning rate range includes production default 1e-4
        let lr_min = bounds[0].0.exp();
        let lr_max = bounds[0].1.exp();
        assert!(lr_min <= 1e-4 && 1e-4 <= lr_max, "Learning rate range [{}, {}] must include production default 1e-4", lr_min, lr_max);
        assert!((lr_min - 1e-5).abs() < 1e-7, "Learning rate lower bound should be 1e-5, got {}", lr_min);
        assert!((lr_max - 3e-4).abs() < 1e-6, "Learning rate upper bound should be 3e-4, got {}", lr_max);

        // Check linear bounds
        assert_eq!(bounds[1], (64.0, 512.0));    // batch_size (capped)
        assert_eq!(bounds[2], (0.90, 0.99));      // gamma (widened)
        assert_eq!(bounds[4], (1.0, 4.0));        // max_position_absolute
        assert_eq!(bounds[6], (0.05, 0.5));        // entropy_coefficient (C1: 10x wider)
        assert_eq!(bounds[7], (0.5, 2.0));        // transaction_cost_multiplier
        assert_eq!(bounds[8], (0.4, 0.8));        // per_alpha
        assert_eq!(bounds[9], (0.2, 0.6));        // per_beta_start
        // Default phase is Fast — architecture dims are fixed to [phase_fast] TOML values.
        assert_eq!(bounds[10], (1.0, 1.0));       // v_range (FIXED — now computed from gamma)
        assert_eq!(bounds[11], (1.0, 1.0));       // use_branching (FIXED: always true)
        assert_eq!(bounds[13], (128.0, 128.0));   // dueling_hidden_dim (FIXED in Fast phase)
        assert_eq!(bounds[14], (3.0, 5.0));       // n_steps (searchable in all phases)
        assert_eq!(bounds[15], (101.0, 101.0));    // num_atoms (FIXED in Fast phase to 101)

        // Kelly risk parameters
        assert_eq!(bounds[17], (0.25, 0.75));     // kelly_fractional
        assert_eq!(bounds[18], (0.1, 0.5));       // kelly_max_fraction
        assert_eq!(bounds[19], (10.0, 30.0));     // volatility_window

        // C2: curiosity_weight fixed to 0.0, noisy_epsilon_floor fixed to 0.10, not in search space
        assert_eq!(bounds[21], (128.0, 128.0));   // hidden_dim_base (FIXED in Fast phase)
        assert_eq!(bounds[22], (0.0, 1.0));       // cql_alpha (full offline-RL range)

        // C5: branch_hidden_dim
        assert_eq!(bounds[28], (64.0, 64.0));     // branch_hidden_dim (FIXED in Fast phase)
        // C6: gradient_accumulation_steps (fixed at 1 for default GPUs)
        assert_eq!(bounds[29], (1.0, 1.0));       // gradient_accumulation_steps

        // C8: Composite reward weights (FIXED in Fast phase to defaults)
        assert_eq!(bounds[31], (1.0, 1.0));       // w_dsr (FIXED in Fast phase)
        assert_eq!(bounds[32], (0.3, 0.3));       // w_pnl (FIXED in Fast phase)
        assert_eq!(bounds[33], (1.0, 1.0));       // w_dd (FIXED in Fast phase)
        assert_eq!(bounds[34], (0.01, 0.01));     // w_idle (FIXED in Fast phase)
        assert_eq!(bounds[35], (0.01, 0.01));     // dd_threshold (FIXED in Fast phase)
        assert_eq!(bounds[36], (1.5, 1.5));       // loss_aversion (FIXED in Fast phase)
        assert_eq!(bounds[37], (0.0005, 0.0005)); // time_decay_rate (FIXED in Fast phase)
        assert_eq!(bounds[38], (0.1, 0.1));       // q_gap_threshold (FIXED in Fast phase)
    }

    #[test]
    fn test_param_names() {
        let names = DQNParams::param_names();
        assert_eq!(names.len(), 45); // 45D: 39 base + spectral_norm_sigma_max + c51_warmup_epochs + her_ratio + curiosity_weight + use_cvar + cvar_alpha
        assert_eq!(names[0], "learning_rate");
        assert_eq!(names[1], "batch_size");
        assert_eq!(names[2], "gamma");
        assert_eq!(names[3], "buffer_size");
        assert_eq!(names[4], "max_position_absolute");
        assert_eq!(names[5], "huber_delta");
        assert_eq!(names[6], "entropy_coefficient");
        assert_eq!(names[7], "transaction_cost_multiplier");
        assert_eq!(names[8], "per_alpha");
        assert_eq!(names[9], "per_beta_start");
        assert_eq!(names[10], "v_range");
        assert_eq!(names[11], "use_branching_fixed");
        assert_eq!(names[12], "noisy_sigma_init");
        assert_eq!(names[13], "dueling_hidden_dim");
        assert_eq!(names[14], "n_steps");
        assert_eq!(names[15], "num_atoms");
        assert_eq!(names[16], "weight_decay");
        assert_eq!(names[17], "kelly_fractional");
        assert_eq!(names[18], "kelly_max_fraction");
        assert_eq!(names[19], "volatility_window");
        assert_eq!(names[20], "tau");
        assert_eq!(names[21], "hidden_dim_base");
        assert_eq!(names[22], "cql_alpha");
        assert_eq!(names[23], "lr_decay_type");
        assert_eq!(names[24], "dsr_eta");
        assert_eq!(names[25], "minimum_profit_factor");
        assert_eq!(names[26], "count_bonus_coefficient");
        assert_eq!(names[27], "sharpe_weight");  // C4
        assert_eq!(names[28], "branch_hidden_dim");  // C5
        assert_eq!(names[29], "gradient_accumulation_steps");  // C6
        assert_eq!(names[39], "spectral_norm_sigma_max");  // 39
        assert_eq!(names[40], "c51_warmup_epochs");         // 40
        // Task 11: New hyperopt-tunable parameters
        assert_eq!(names[41], "her_ratio");                  // 41
        assert_eq!(names[42], "curiosity_weight");           // 42
        assert_eq!(names[43], "use_cvar_action_selection");  // 43
        assert_eq!(names[44], "cvar_alpha");                 // 44
    }

    #[test]
    fn test_per_params_always_enabled() {
        // Test that PER is always enabled with tunable alpha/beta parameters
        // 45D search space (Task 11: +4 new dims)
        let continuous = vec![
            3e-5_f64.ln(), 92.0, 0.92, 97_273_f64.ln(), 4.0, 24.77_f64.ln(), 0.03, 1.0,
            0.6, 0.4,              // 8-9: per_alpha, per_beta_start
            25.0, 25.0, 0.5_f64.ln(), // 10-12: v_range, v_range_mirror, noisy_sigma_init
            256.0, 3.0, 101.0,    // 13-15: dueling_hidden_dim, n_steps, num_atoms
            1e-4_f64.ln(),         // 16: weight_decay
            0.5, 0.25,            // 17-18: kelly_fractional, kelly_max_fraction
            20.0,                  // 19: volatility_window
            0.007_f64.ln(),        // 20: tau
            512.0,                 // 21: hidden_dim_base
            0.1,                   // 22: cql_alpha
            2.0,                   // 23: lr_decay_type
            0.02_f64.ln(),         // 24: dsr_eta (log scale)
            1.5,                   // 25: minimum_profit_factor
            0.1,                   // 26: count_bonus_coefficient
            0.3,                   // 27: sharpe_weight (C4)
            128.0,                 // 28: branch_hidden_dim (C5)
            1.0,                   // 29: gradient_accumulation_steps (C6)
            0.25,                  // 30: iqn_lambda (C7)
            // C8: Composite reward weights (indices 31-38)
            1.0,                   // 31: w_dsr
            0.3,                   // 32: w_pnl
            1.0,                   // 33: w_dd
            0.01,                  // 34: w_idle
            0.02,                  // 35: dd_threshold
            1.5,                   // 36: loss_aversion
            0.0005,                // 37: time_decay_rate
            0.1,                   // 38: q_gap_threshold
        ];

        let params = DQNParams::from_continuous(&continuous).unwrap();
        assert!((params.per_alpha - 0.6).abs() < 1e-6);
        assert!((params.per_beta_start - 0.4).abs() < 1e-6);
        assert!((params.warmup_ratio - 0.0).abs() < 1e-6);
        assert!((params.curiosity_weight - 0.0).abs() < 1e-6); // C2: fixed
        assert!((params.noisy_epsilon_floor - 0.10).abs() < 1e-6); // Fixed: 10% floor
        // Verify symmetric support — v_range is dynamic from gamma + reward_scale.
        // With gamma=0.92, reward_scale=10: v_range = 10 / (1-0.92) * 1.2 = 150
        assert!(params.v_min < 0.0, "v_min should be negative");
        assert!(params.v_max > 0.0, "v_max should be positive");
        assert!((params.v_min + params.v_max).abs() < 1e-6, "v_min/v_max should be symmetric");

        // Test PER parameter bounds (min values) -- 39D
        let continuous_min = vec![
            2e-5_f64.ln(), 64.0, 0.88, 50_000_f64.ln(), 1.0, 10.0_f64.ln(), 0.05, 0.5,
            0.4, 0.2,             // per_alpha min, per_beta_start min
            10.0, 10.0, 0.1_f64.ln(), // v_range min, v_range_mirror, noisy_sigma_init
            128.0, 3.0, 51.0,    // dueling_hidden_dim, n_steps (min=3), num_atoms
            1e-4_f64.ln(),         // weight_decay min
            0.25, 0.1,            // kelly_fractional, kelly_max_fraction
            10.0,                  // volatility_window
            0.005_f64.ln(),        // 20: tau min
            256.0,                 // 21: hidden_dim_base min
            0.0,                   // 22: cql_alpha min
            0.0,                   // 23: lr_decay_type (constant)
            0.001_f64.ln(),        // 24: dsr_eta min (log scale)
            1.1,                   // 25: minimum_profit_factor min
            0.0,                   // 26: count_bonus_coefficient min
            0.0,                   // 27: sharpe_weight min (C4)
            64.0,                  // 28: branch_hidden_dim min (C5)
            1.0,                   // 29: gradient_accumulation_steps min (C6)
            0.0,                   // 30: iqn_lambda min (C7)
            // C8: Composite reward weights min
            0.1,                   // 31: w_dsr min
            0.0,                   // 32: w_pnl min
            0.0,                   // 33: w_dd min
            0.0,                   // 34: w_idle min
            0.01,                  // 35: dd_threshold min
            1.0,                   // 36: loss_aversion min
            0.0001,                // 37: time_decay_rate min
            0.0,                   // 38: q_gap_threshold min
        ];
        let params_min = DQNParams::from_continuous(&continuous_min).unwrap();
        assert!((params_min.per_alpha - 0.4).abs() < 1e-6);
        assert!((params_min.per_beta_start - 0.2).abs() < 1e-6);
        // v_range now dynamic from gamma — just verify symmetric and positive
        assert!(params_min.v_min < 0.0, "v_min should be negative at min");
        assert!(params_min.v_max > 0.0, "v_max should be positive at min");
        assert!((params_min.v_min + params_min.v_max).abs() < 1e-6, "symmetric at min");

        // Test PER parameter bounds (max values) -- 39D
        let continuous_max = vec![
            8e-5_f64.ln(), 512.0, 0.99, 100_000_f64.ln(), 4.0, 40.0_f64.ln(), 0.5, 2.0,
            0.8, 0.6,             // per_alpha max, per_beta_start max
            50.0, 50.0, 1.0_f64.ln(), // v_range max, v_range_mirror, noisy_sigma_init
            512.0, 5.0, 201.0,   // dueling_hidden_dim, n_steps, num_atoms
            1e-2_f64.ln(),         // weight_decay max
            0.75, 0.5,            // kelly_fractional, kelly_max_fraction
            30.0,                  // volatility_window
            0.01_f64.ln(),         // 20: tau max
            1024.0,                // 21: hidden_dim_base max
            1.0,                   // 22: cql_alpha max
            2.0,                   // 23: lr_decay_type (cosine)
            0.05_f64.ln(),         // 24: dsr_eta max (log scale)
            2.0,                   // 25: minimum_profit_factor max
            0.3,                   // 26: count_bonus_coefficient max
            0.5,                   // 27: sharpe_weight max (C4)
            256.0,                 // 28: branch_hidden_dim max (C5)
            1.0,                   // 29: gradient_accumulation_steps max (C6)
            2.0,                   // 30: iqn_lambda max (C7)
            // C8: Composite reward weights max
            2.0,                   // 31: w_dsr max
            1.0,                   // 32: w_pnl max
            5.0,                   // 33: w_dd max
            0.1,                   // 34: w_idle max
            0.10,                  // 35: dd_threshold max
            3.0,                   // 36: loss_aversion max
            0.005,                 // 37: time_decay_rate max
            0.5,                   // 38: q_gap_threshold max
        ];
        let params_max = DQNParams::from_continuous(&continuous_max).unwrap();
        assert!((params_max.per_alpha - 0.8).abs() < 1e-6);
        assert!((params_max.per_beta_start - 0.6).abs() < 1e-6);
        // v_range dynamic from gamma — verify symmetric
        assert!(params_max.v_min < 0.0, "v_min should be negative at max");
        assert!(params_max.v_max > 0.0, "v_max should be positive at max");
        assert!((params_max.v_min + params_max.v_max).abs() < 1e-6, "symmetric at max");
    }

    #[test]
    fn test_validate_accepts_valid_params() {
        // Default params should pass all fundamental invariant checks
        let params = DQNParams::default();
        assert!(params.validate_for_hft_trendfollowing().is_ok());

        // Params with various buffer sizes should also pass
        // (strategy-specific constraints were removed in Wave 16V)
        let params_small_buffer = DQNParams {
            buffer_size: 20_000,
            ..DQNParams::default()
        };
        assert!(params_small_buffer.validate_for_hft_trendfollowing().is_ok());
    }

    #[test]
    fn test_validate_rejects_v_min_gte_v_max() {
        // v_min == v_max: zero-width distributional support
        let params_equal = DQNParams {
            v_min: 1.0,
            v_max: 1.0,
            ..DQNParams::default()
        };
        let err = params_equal.validate_for_hft_trendfollowing().unwrap_err();
        assert!(err.contains("v_min"), "error should mention v_min: {err}");

        // v_min > v_max: inverted distributional support
        let params_inverted = DQNParams {
            v_min: 100.0,
            v_max: -100.0,
            ..DQNParams::default()
        };
        assert!(params_inverted.validate_for_hft_trendfollowing().is_err());
    }

    #[test]
    fn test_validate_rejects_num_atoms_below_two() {
        let params = DQNParams {
            num_atoms: 1,
            ..DQNParams::default()
        };
        let err = params.validate_for_hft_trendfollowing().unwrap_err();
        assert!(err.contains("num_atoms"), "error should mention num_atoms: {err}");

        // Zero atoms
        let params_zero = DQNParams {
            num_atoms: 0,
            ..DQNParams::default()
        };
        assert!(params_zero.validate_for_hft_trendfollowing().is_err());
    }

    #[test]
    fn test_validate_rejects_zero_batch_size() {
        let params = DQNParams {
            batch_size: 0,
            ..DQNParams::default()
        };
        let err = params.validate_for_hft_trendfollowing().unwrap_err();
        assert!(err.contains("batch_size"), "error should mention batch_size: {err}");
    }

    #[test]
    fn test_validate_rejects_invalid_learning_rate() {
        // Zero LR
        let params_zero = DQNParams {
            learning_rate: 0.0,
            ..DQNParams::default()
        };
        assert!(params_zero.validate_for_hft_trendfollowing().is_err());

        // Negative LR
        let params_neg = DQNParams {
            learning_rate: -1e-4,
            ..DQNParams::default()
        };
        assert!(params_neg.validate_for_hft_trendfollowing().is_err());

        // LR too high (> 0.1)
        let params_high = DQNParams {
            learning_rate: 0.2,
            ..DQNParams::default()
        };
        let err = params_high.validate_for_hft_trendfollowing().unwrap_err();
        assert!(err.contains("learning_rate"), "error should mention learning_rate: {err}");

        // LR at upper bound (0.1) should pass
        let params_boundary = DQNParams {
            learning_rate: 0.1,
            ..DQNParams::default()
        };
        assert!(params_boundary.validate_for_hft_trendfollowing().is_ok());
    }

    #[test]
    fn test_validate_rejects_invalid_gamma() {
        // Zero gamma
        let params_zero = DQNParams {
            gamma: 0.0,
            ..DQNParams::default()
        };
        assert!(params_zero.validate_for_hft_trendfollowing().is_err());

        // Negative gamma
        let params_neg = DQNParams {
            gamma: -0.5,
            ..DQNParams::default()
        };
        assert!(params_neg.validate_for_hft_trendfollowing().is_err());

        // gamma > 1.0
        let params_over = DQNParams {
            gamma: 1.01,
            ..DQNParams::default()
        };
        let err = params_over.validate_for_hft_trendfollowing().unwrap_err();
        assert!(err.contains("gamma"), "error should mention gamma: {err}");

        // gamma at upper bound (1.0) should pass
        let params_one = DQNParams {
            gamma: 1.0,
            ..DQNParams::default()
        };
        assert!(params_one.validate_for_hft_trendfollowing().is_ok());
    }

    #[test]
    fn test_objective_function_maximizes_reward() {
        // Test that objective function uses normalization + clamping + HFT activity scoring
        // Note: Expected values updated for new multi-objective formula with HFT activity component

        // Scenario 1: Positive reward with good action distribution (30/30/40)
        let metrics_positive = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100, // >= min_epochs (5) → no completion penalty
            avg_episode_reward: 100.0, // Clamped: (100/10).clamp(-1, 1) = 1.0
            buy_action_pct: 0.3,
            sell_action_pct: 0.3,
            hold_action_pct: 0.4,
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let objective_positive = DQNTrainer::extract_objective(&metrics_positive);
        // Expected: 0.40 * 1.0 (reward) + ~1.0 (HFT activity, 60% BUY+SELL) + 0.0 (stability) + 0.0 (completion) ≈ 1.4
        assert!(
            objective_positive > 1.0,
            "Objective should be > 1.0 with positive reward + HFT activity: {}",
            objective_positive
        );

        // Scenario 2: Negative reward (clamped to -1.0)
        let metrics_negative = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100,
            avg_episode_reward: -50.0, // Clamped: (-50/10).clamp(-1, 1) = -1.0
            buy_action_pct: 0.3,
            sell_action_pct: 0.3,
            hold_action_pct: 0.4,
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let objective_negative = DQNTrainer::extract_objective(&metrics_negative);
        // Expected: 0.40 * -1.0 + ~1.0 (HFT activity) + 0.0 + 0.0 ≈ 0.6
        assert!(
            objective_negative > 0.0,
            "Negative reward should still have positive objective due to HFT activity: {}",
            objective_negative
        );

        // Scenario 3: Zero reward with good action distribution
        let metrics_zero = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100,
            avg_episode_reward: 0.0,
            buy_action_pct: 0.3,
            sell_action_pct: 0.3,
            hold_action_pct: 0.4,
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let objective_zero = DQNTrainer::extract_objective(&metrics_zero);
        // Expected: 0.0 (reward) + ~1.0 (HFT activity) + 0.0 + 0.0 ≈ 1.0
        assert!(
            objective_zero > 0.5,
            "Zero reward should still have positive objective due to HFT activity: {}",
            objective_zero
        );

        // Scenario 4: Verify higher reward gives higher objective (with same action distribution)
        let high_reward = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100,
            avg_episode_reward: 200.0, // Clamped: (200/10).clamp(-1, 1) = 1.0
            buy_action_pct: 0.3,
            sell_action_pct: 0.3,
            hold_action_pct: 0.4,
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let low_reward = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100,
            avg_episode_reward: 5.0, // (5/10).clamp(-1, 1) = 0.5
            buy_action_pct: 0.3,
            sell_action_pct: 0.3,
            hold_action_pct: 0.4,
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let obj_high = DQNTrainer::extract_objective(&high_reward);
        let obj_low = DQNTrainer::extract_objective(&low_reward);

        // Higher reward should give higher objective (HFT activity component is same)
        assert!(
            obj_high > obj_low,
            "Higher reward (1.0) should give higher objective than lower reward (0.5): {} > {}",
            obj_high,
            obj_low
        );

        // Scenario 5: Test HFT activity penalty for low BUY/SELL actions
        let low_activity = DQNMetrics {
            train_loss: 0.5,
            val_loss: 0.4,
            avg_q_value: 10.0,
            final_epsilon: 0.01,
            epochs_completed: 100,
            avg_episode_reward: 100.0,
            buy_action_pct: 0.05,  // Only 5% BUY (below 15% threshold)
            sell_action_pct: 0.05, // Only 5% SELL (below 15% threshold)
            hold_action_pct: 0.90, // 90% HOLD (passive behavior)
            gradient_norm: 2.0,
            q_value_std: 1.5,
            backtest_metrics: None,
        };
        let obj_low_activity = DQNTrainer::extract_objective(&low_activity);

        // Low activity should have lower objective than balanced distribution
        assert!(
            obj_low_activity < objective_positive,
            "Low BUY/SELL activity should be penalized: {} < {}",
            obj_low_activity,
            objective_positive
        );
    }

    #[test]
    fn test_qr_dqn_params_in_default() {
        let params = DQNParams::default();
        assert_eq!(params.num_quantiles, 32, "Default num_quantiles should be 32");
        assert!((params.qr_kappa - 1.0).abs() < f64::EPSILON, "Default kappa should be 1.0");
    }

    #[test]
    fn test_qr_dqn_roundtrip_continuous() {
        let params = DQNParams::default();
        let continuous = params.to_continuous();
        assert_eq!(continuous.len(), 45, "Should have 45 continuous dimensions");
        let roundtrip = DQNParams::from_continuous(&continuous).unwrap();
        // Default num_quantiles=32 (IQN default, used alongside C51)
        assert_eq!(roundtrip.num_quantiles, 32);
        assert!((roundtrip.qr_kappa - 1.0).abs() < 0.01);  // Fixed default
    }

    #[test]
    fn test_qr_dqn_activation_threshold() {
        let bounds = DQNParams::continuous_bounds();
        let mut params = vec![0.0_f64; 41]; // 41D search space
        // Fill with valid defaults
        params[0] = (1e-4_f64).ln();           // learning_rate
        params[1] = 128.0;                      // batch_size
        params[2] = 0.92;                       // gamma
        params[3] = (100_000.0_f64).ln();       // buffer_size
        params[4] = 3.0;                        // max_position_absolute
        params[5] = (25.0_f64).ln();            // huber_delta
        params[6] = 0.02;                       // entropy_coefficient (C2: narrowed)
        params[7] = 1.0;                        // transaction_cost_multiplier
        params[8] = 0.6;                        // per_alpha
        params[9] = 0.4;                        // per_beta_start
        // Fill remaining with midpoint of bounds
        for i in 10..41 {
            if i < bounds.len() {
                params[i] = (bounds[i].0 + bounds[i].1) / 2.0;
            }
        }

        // num_atoms index = 15: rounding is (x.round() / 50.0).round() * 50.0, clamped to [51, 201]
        // 51.0 -> (51/50).round()*50 = 1*50 = 50, clamped to 51
        params[15] = 51.0;
        let low_atoms = DQNParams::from_continuous(&params).unwrap();
        assert_eq!(low_atoms.num_atoms, 51, "51.0 rounds to 51 (clamped min)");
        assert_eq!(low_atoms.num_quantiles, 32, "IQN default quantiles");

        // 100.0 -> (100/50).round()*50 = 2*50 = 100 (at boundary, should still be C51)
        params[15] = 100.0;
        let at_boundary = DQNParams::from_continuous(&params).unwrap();
        assert_eq!(at_boundary.num_atoms, 100);

        // 151.0 -> 151 (below 200 threshold, still C51)
        params[15] = 151.0;
        let high_atoms = DQNParams::from_continuous(&params).unwrap();
        assert_eq!(high_atoms.num_atoms, 151);

        // 201.0 -> 201 (above 200, QR-DQN activates)
        params[15] = 201.0;
        let max_atoms = DQNParams::from_continuous(&params).unwrap();
        assert_eq!(max_atoms.num_atoms, 201);
        assert_eq!(max_atoms.num_quantiles, 32, "IQN quantile count is fixed at 32");
    }

    #[test]
    fn test_objective_mode_default_is_sharpe() {
        assert_eq!(ObjectiveMode::default(), ObjectiveMode::Sharpe);
    }

    #[test]
    fn test_objective_mode_variants() {
        let reward = ObjectiveMode::EpisodeReward;
        let sharpe = ObjectiveMode::Sharpe;
        assert_ne!(reward, sharpe);
        assert_eq!(reward, ObjectiveMode::EpisodeReward);
        assert_eq!(sharpe, ObjectiveMode::Sharpe);
    }

    #[test]
    fn test_batch_size_clamped_to_1024() {
        // batch_size is clamped to [64, 1024] in from_continuous — VRAM guard.
        // Even if PSO suggests 2048, from_continuous caps at 1024.
        let bounds = DQNParams::continuous_bounds();
        let mut params = vec![0.0_f64; 39]; // C8: 39D
        params[1] = 2048.0; // batch_size (index 1) — over the clamp
        // Fill other required params with valid defaults
        params[0] = (1e-4_f64).ln(); // learning_rate
        params[2] = 0.92; // gamma
        params[3] = (100_000.0_f64).ln(); // buffer_size
        params[4] = 3.0; // max_position_absolute
        params[5] = (25.0_f64).ln(); // huber_delta
        params[6] = 0.02; // entropy_coefficient
        params[7] = 0.5; // transaction_cost_multiplier
        params[8] = 0.6; // per_alpha
        params[9] = 0.4; // per_beta_start
        // Remaining params (indices 10-38) -- use midpoint of bounds
        for i in 10..39 {
            params[i] = (bounds[i].0 + bounds[i].1) / 2.0;
        }
        let result = DQNParams::from_continuous(&params).unwrap();
        assert_eq!(
            result.batch_size, 1024,
            "batch_size should be clamped to 1024 when PSO picks 2048",
        );
    }

    #[test]
    fn test_diversity_penalty_smooth() {
        // Smooth quadratic penalty — no cliffs
        let penalty_zero = calculate_diversity_penalty(&[1.0, 0.0, 0.0]);
        assert!(penalty_zero < -4.0, "Zero entropy should give strong penalty: {}", penalty_zero);

        let penalty_low = calculate_diversity_penalty(&[0.95, 0.03, 0.02]);
        assert!(penalty_low < -2.0 && penalty_low > penalty_zero, "Low entropy strong: {}", penalty_low);

        let penalty_mid = calculate_diversity_penalty(&[0.6, 0.2, 0.2]);
        assert!(penalty_mid > -1.0, "Medium entropy mild: {}", penalty_mid);

        let penalty_uniform = calculate_diversity_penalty(&[0.33, 0.33, 0.34]);
        assert!(penalty_uniform.abs() < 0.1, "Uniform ~0: {}", penalty_uniform);

        // Monotonic
        assert!(penalty_uniform > penalty_mid);
        assert!(penalty_mid > penalty_low);
        assert!(penalty_low > penalty_zero);
    }

    #[test]
    fn test_completion_penalty_smooth() {
        let p0 = calculate_completion_penalty(0, 10, true);
        assert!((p0 - 50.0).abs() < 0.1, "0 epochs = 50.0: {}", p0);

        let p5 = calculate_completion_penalty(5, 10, false);
        assert!((p5 - 25.0).abs() < 0.1, "5/10 = 25.0: {}", p5);

        let p10 = calculate_completion_penalty(10, 10, false);
        assert!(p10.abs() < 0.01, "Full = 0: {}", p10);

        let p15 = calculate_completion_penalty(15, 10, false);
        assert!(p15.abs() < 0.01, "Over min = 0: {}", p15);

        assert!(p0 > p5);
        assert!(p5 > p10);
    }

    #[test]
    fn test_trade_insufficiency_penalty_zero_trades() {
        let penalty = calculate_trade_insufficiency_penalty(0);
        assert!((penalty - 10.0).abs() < 1e-6, "0 trades = max penalty: {}", penalty);
    }

    #[test]
    fn test_trade_insufficiency_penalty_one_trade() {
        let penalty = calculate_trade_insufficiency_penalty(1);
        assert!(penalty > 9.0 && penalty < 10.0, "1 trade near max: {}", penalty);
    }

    #[test]
    fn test_trade_insufficiency_penalty_graduated() {
        let p0 = calculate_trade_insufficiency_penalty(0);
        let p1 = calculate_trade_insufficiency_penalty(1);
        let p5 = calculate_trade_insufficiency_penalty(5);
        let p50 = calculate_trade_insufficiency_penalty(50);
        let p100 = calculate_trade_insufficiency_penalty(100);
        let p500 = calculate_trade_insufficiency_penalty(500);

        // Strictly decreasing until MIN_VIABLE_TRADES=20, then 0.0
        assert!(p0 > p1, "0 > 1: {} > {}", p0, p1);
        assert!(p1 > p5, "1 > 5: {} > {}", p1, p5);
        assert!(p5 > p50, "5 > 50: {} > {}", p5, p50);
        // 50 and 100 both >= 20 → no penalty
        assert!((p50 - 0.0).abs() < 1e-6, "50+ = no penalty: {}", p50);
        assert!((p100 - 0.0).abs() < 1e-6, "100+ = no penalty: {}", p100);
        assert!((p500 - 0.0).abs() < 1e-6, "500 = no penalty: {}", p500);
    }

    #[test]
    fn test_trade_insufficiency_no_flat_plateau() {
        // The whole point: different trade counts -> different penalties
        let penalties: Vec<f64> = (0..20).map(|t| calculate_trade_insufficiency_penalty(t)).collect();
        for i in 0..19 {
            let curr = penalties.get(i).copied().unwrap_or(0.0);
            let next = penalties.get(i + 1).copied().unwrap_or(0.0);
            assert!(
                curr >= next,
                "penalty[{}]={} should be >= penalty[{}]={}",
                i, curr, i + 1, next
            );
        }
        // Verify no two adjacent values are the same in 0-9 range (no plateaus)
        for i in 0..9 {
            let curr = penalties.get(i).copied().unwrap_or(0.0);
            let next = penalties.get(i + 1).copied().unwrap_or(0.0);
            assert!(
                (curr - next).abs() > 0.01,
                "PLATEAU at trades={}: penalty[{}]={:.4} == penalty[{}]={:.4}",
                i, i, curr, i + 1, next
            );
        }
    }

    #[test]
    fn test_eval_softmax_temp_fixed_default() {
        // eval_softmax_temp removed from search space (backtest uses greedy argmax).
        // Verify from_continuous always sets it to fixed 1.0 regardless of input.
        let bounds = DQNParams::continuous_bounds();
        let mut vec = vec![0.0_f64; 39]; // C8: 39D
        for i in 0..38 {
            vec[i] = (bounds[i].0 + bounds[i].1) / 2.0;
        }
        let params = DQNParams::from_continuous(&vec).unwrap();
        assert!(
            (params.eval_softmax_temp - 1.0).abs() < f64::EPSILON,
            "eval_softmax_temp should be fixed to 1.0, got {}",
            params.eval_softmax_temp
        );
        // C2: verify exploration stacking params are fixed
        assert!((params.curiosity_weight - 0.0).abs() < f64::EPSILON, "curiosity_weight should be 0.0");
        assert!((params.noisy_epsilon_floor - 0.10).abs() < 1e-6, "noisy_epsilon_floor should be 0.10");
        // C3: count_bonus_coefficient is now tuned (index 26), so midpoint of (0.0, 0.3) = 0.15
        assert!(params.count_bonus_coefficient >= 0.0 && params.count_bonus_coefficient <= 0.3, "count_bonus_coefficient should be in [0.0, 0.3], got {}", params.count_bonus_coefficient);
    }

    #[test]
    fn test_low_diversity_adds_mild_penalty_not_hard_gate() {
        // With greedy argmax, mono-action policies are valid. The diversity penalty
        // should be a mild additive term (≤0.8), NOT a hard gate that overrides Sharpe.
        // Multi-window backtest (3 windows, mean - 0.5*std) handles phantom Sharpe.
        let metrics = DQNMetrics {
            train_loss: 0.1,
            val_loss: 0.05,
            avg_q_value: 0.5,
            final_epsilon: 0.0,
            epochs_completed: 8,
            avg_episode_reward: 100.0,
            buy_action_pct: 0.5,
            sell_action_pct: 0.5,
            hold_action_pct: 0.0,
            gradient_norm: 0.1,
            q_value_std: 0.1,
            backtest_metrics: Some(BacktestMetrics {
                sharpe_ratio: 3.0, // Realistic good Sharpe
                win_rate: 55.0,
                max_drawdown_pct: 5.0,
                total_return_pct: 12.0,
                total_trades: 200,
                sortino_ratio: 4.0,
                calmar_ratio: 2.4,
                var_95: -0.002,   // 5th percentile per-bar return (1-min data)
                cvar_95: -0.002,  // Mean of tail returns below VaR (1-min data)
                beta: 0.1,
                alpha: 0.05,
                information_ratio: 1.5,
                omega_ratio: 2.0,
                unique_actions: 1, // Mono-action greedy — valid!
                buy_action_pct: 0.0,
                sell_action_pct: 1.0,
                hold_action_pct: 0.0,
            }),
        };

        let objective = <DQNTrainer as HyperparameterOptimizable>::extract_objective(&metrics);
        // With good Sharpe and 200 trades, objective should be far below FALLBACK (44.6)
        // even with mono-action. The HFT activity penalty for 0% buy is modest compared
        // to the composite score benefit from Sharpe=3.0.
        assert!(
            objective < 5.0,
            "Good Sharpe with mono-action should NOT produce FALLBACK-level objective, got {:.4}",
            objective
        );
    }

    #[test]
    fn test_dqn_exposure_indices_produce_distinct_actions() {
        use crate::dqn::action_space::ExposureLevel;
        use crate::dqn::order_router::OrderRouter;

        // DQN outputs 0-4. Each must map to a distinct exposure level.
        let actions: Vec<_> = (0..5)
            .map(|idx| {
                let exposure = ExposureLevel::from_index(idx).expect("valid index 0-4");
                OrderRouter::route_default(exposure)
            })
            .collect();

        // All 5 exposures must be distinct
        let exposures: std::collections::HashSet<_> = actions.iter().map(|a| a.exposure).collect();
        assert_eq!(exposures.len(), 5, "DQN 5-action space must produce 5 distinct exposure levels");
    }
}
