Files
foxhunt/docs/plans/2026-03-07-dqn-hyperopt-overhaul-v2.md
jgrusewski a35a564f45 fix(ml): DQN hyperopt overhaul — DSR reward, dead features, C51/noisy/network fixes
22-task overhaul (6 phases) for DQN training quality:
- Differential Sharpe Ratio (DSR) reward replacing raw PnL
- Remove 11 dead features (3 regime + 8 OFI): state_dim 54→43, feature_dim 51→40
- C51 v_min/v_max aligned to DSR Q-value range (±25.0)
- IQN batch path fix — consistent network for train+inference
- RMSNorm in distributional-dueling network
- Noisy layer sigma_init wired through config
- Rainbow config unified with DSR/C51/noisy defaults
- All dimension constants, comments, CUDA buffers updated
- 2747 lib tests passing, 0 failures, 0 warnings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 12:08:39 +01:00

32 KiB
Raw Blame History

DQN Hyperopt Overhaul v2 — Full Root Cause Fix

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Fix all 19 root causes preventing DQN hyperopt from achieving positive Sharpe ratio.

Architecture: Replace double-normalized reward pipeline with Differential Sharpe Ratio (DSR), fix broken IQN training path, add RMSNorm to distributional-dueling network, remove dead features, tighten hyperopt search space from 26D to ~22D.

Tech Stack: Rust, Candle ML framework, PSO hyperopt, OHLCV futures data


Phase 1: Reward Function (7 tasks)

Task 1.1: Implement Differential Sharpe Ratio struct

Files:

  • Modify: crates/ml/src/dqn/reward.rs:170-199 (RewardConfig)
  • Modify: crates/ml/src/dqn/reward.rs:399-410 (RewardFunction struct)

Step 1: Add DSR struct and config fields

Add before RewardConfig (around line 165):

/// Differential Sharpe Ratio (Moody & Saffell, 2001)
///
/// Computes the incremental change in Sharpe ratio per step.
/// Replaces both EMA reward normalizer and risk-adjusted division.
/// Produces dense, well-scaled, risk-adjusted reward per step.
pub struct DifferentialSharpeRatio {
    ema_return: f64,      // A_t: EMA of returns
    ema_return_sq: f64,   // B_t: EMA of squared returns
    eta: f64,             // Decay rate (0.01 = ~100-step window)
    initialized: bool,
}

impl DifferentialSharpeRatio {
    pub fn new(eta: f64) -> Self {
        Self {
            ema_return: 0.0,
            ema_return_sq: 0.0,
            eta: eta.clamp(0.0001, 0.1),
            initialized: false,
        }
    }

    /// Compute differential Sharpe ratio for a new return observation.
    ///
    /// Formula (Moody & Saffell 2001):
    ///   DSR_t = (B_{t-1} * r_t - 0.5 * A_{t-1} * (r_t^2 - B_{t-1})) / (B_{t-1} - A_{t-1}^2)^{3/2}
    ///
    /// Returns 0.0 on first call (no prior statistics).
    pub fn step(&mut self, r_t: f64) -> f64 {
        if !self.initialized {
            self.ema_return = r_t;
            self.ema_return_sq = r_t * r_t + 1e-8; // Small epsilon for numerical stability
            self.initialized = true;
            return 0.0;
        }

        let a = self.ema_return;      // A_{t-1}
        let b = self.ema_return_sq;   // B_{t-1}
        let r_sq = r_t * r_t;

        // Variance of returns: Var = E[r^2] - E[r]^2
        let variance = b - a * a;

        let dsr = if variance > 1e-12 {
            let numer = b * r_t - 0.5 * a * (r_sq - b);
            let denom = variance.powf(1.5);
            (numer / denom).clamp(-5.0, 5.0) // Bound extreme DSR values
        } else {
            // Insufficient variance — return scaled raw return as fallback
            r_t * 100.0 // Scale raw PnL (~0.0002) to ~0.02 range
        };

        // Update EMAs AFTER computing DSR (use previous stats for current step)
        self.ema_return = self.eta * r_t + (1.0 - self.eta) * a;
        self.ema_return_sq = self.eta * r_sq + (1.0 - self.eta) * b;

        dsr
    }

    /// Reset state between episodes/epochs for stationarity.
    pub fn reset(&mut self) {
        self.ema_return = 0.0;
        self.ema_return_sq = 0.0;
        self.initialized = false;
    }
}

Step 2: Add config fields to RewardConfig (lines 170-199)

Add two fields after sharpe_window (line 198):

    /// Use Differential Sharpe Ratio instead of EMA normalizer + risk-adjusted division
    pub use_dsr: bool,
    /// DSR EMA decay rate (0.001=slow/1000-step, 0.05=fast/20-step). Tunable in hyperopt.
    pub dsr_eta: f64,

Step 3: Update RewardConfig::default() (lines 201-220)

Add to default impl after sharpe_window: 20:

            use_dsr: false,     // Backward compat: off by default
            dsr_eta: 0.01,      // ~100-step effective window

Step 4: Add DSR field to RewardFunction struct (lines 399-410)

Add field:

    dsr: Option<DifferentialSharpeRatio>,

Step 5: Initialize DSR in RewardFunction constructor (line 433)

After normalizer initialization:

        let dsr = config.use_dsr.then(|| DifferentialSharpeRatio::new(config.dsr_eta));

Step 6: Add DSR to RewardConfigBuilder

Add builder method (after enable_normalization builder at line 294):

    pub fn use_dsr(mut self, enable: bool) -> Self {
        self.use_dsr = Some(enable);
        self
    }

    pub fn dsr_eta(mut self, eta: f64) -> Self {
        self.dsr_eta = Some(eta);
        self
    }

And add fields to builder struct + build():

    use_dsr: Option<bool>,
    dsr_eta: Option<f64>,

In build():

            use_dsr: self.use_dsr.unwrap_or(false),
            dsr_eta: self.dsr_eta.unwrap_or(0.01),

Step 7: Add reset_dsr() method to RewardFunction

    pub fn reset_dsr(&mut self) {
        if let Some(ref mut dsr) = self.dsr {
            dsr.reset();
        }
    }

Step 8: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- reward 2>&1 | tail -5

Expected: All existing reward tests pass.

Step 9: Add DSR unit tests

Add at bottom of reward.rs tests module:

    #[test]
    fn test_dsr_basic() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);
        // First step returns 0 (no prior stats)
        assert_eq!(dsr.step(0.001), 0.0);
        // Positive return after positive → positive DSR
        let d1 = dsr.step(0.002);
        assert!(d1 > 0.0, "Positive return should give positive DSR: {}", d1);
        // Negative return → negative DSR
        let d2 = dsr.step(-0.003);
        assert!(d2 < 0.0, "Negative return should give negative DSR: {}", d2);
    }

    #[test]
    fn test_dsr_bounded() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);
        dsr.step(0.001);
        // Even extreme returns should be clamped
        let d = dsr.step(100.0);
        assert!(d <= 5.0 && d >= -5.0, "DSR should be clamped: {}", d);
    }

    #[test]
    fn test_dsr_reset() {
        let mut dsr = DifferentialSharpeRatio::new(0.01);
        dsr.step(0.001);
        dsr.step(0.002);
        dsr.reset();
        // After reset, first step returns 0 again
        assert_eq!(dsr.step(0.001), 0.0);
    }

    #[test]
    fn test_dsr_config_roundtrip() {
        let config = RewardConfig::builder()
            .use_dsr(true)
            .dsr_eta(0.02)
            .build();
        assert!(config.use_dsr);
        assert!((config.dsr_eta - 0.02).abs() < 1e-10);
    }

Step 10: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- dsr 2>&1 | tail -10

Expected: 4 new tests pass.

Step 11: Commit

git add crates/ml/src/dqn/reward.rs
git commit -m "feat(ml): add Differential Sharpe Ratio (DSR) struct and config"

Task 1.2: Wire DSR into calculate_reward()

Files:

  • Modify: crates/ml/src/dqn/reward.rs:485-615 (calculate_reward method)

Step 1: Modify calculate_reward to use DSR when enabled

Replace the section from line 524 (diversity bonus) through line 603 (normalizer) with:

        // Calculate diversity bonus (in-training regularization)
        let entropy = calculate_entropy(recent_actions);
        let entropy_threshold = Decimal::try_from(0.3).unwrap_or(Decimal::ZERO);
        let diversity_bonus = if entropy < entropy_threshold {
            // DSR mode: scale penalty proportional to trading signal (~0.0005)
            // Legacy mode: -0.1 (500x larger — known to dominate reward signal)
            if self.config.use_dsr {
                Decimal::try_from(-0.0005).unwrap_or(Decimal::ZERO)
            } else {
                self.config.diversity_weight
            }
        } else {
            Decimal::ZERO
        };

        let pnl_base_reward = base_reward + diversity_bonus;

        let final_reward_f64: f64 = if self.config.use_dsr {
            // DSR path: skip Sharpe blend and EMA normalizer
            // Pass raw PnL + diversity through DSR transform
            let raw_pnl: f64 = pnl_base_reward.try_into()
                .map_err(|e| MLError::InvalidInput(format!("Reward conversion failed: {}", e)))?;

            if let Some(ref mut dsr) = self.dsr {
                dsr.step(raw_pnl)
            } else {
                // Fallback: scale raw PnL to reasonable range
                raw_pnl * 100.0
            }
        } else {
            // Legacy path: Sharpe blend + optional EMA normalization (unchanged)
            let pnl_normalized_f64: f64 = ((next_state.portfolio_features.get(0).unwrap_or(&100000.0)
                - current_state.portfolio_features.get(0).unwrap_or(&100000.0))
                / current_state.portfolio_features.get(0).unwrap_or(&100000.0)) as f64;

            self.returns_buffer.push_back(pnl_normalized_f64);
            if self.returns_buffer.len() > self.config.sharpe_window {
                self.returns_buffer.pop_front();
            }

            let sharpe_component = if self.returns_buffer.len() > 1 {
                let mean: f64 = self.returns_buffer.iter().sum::<f64>() / self.returns_buffer.len() as f64;
                let variance: f64 = self.returns_buffer.iter()
                    .map(|r| (r - mean).powi(2))
                    .sum::<f64>() / self.returns_buffer.len() as f64;
                let std_dev = variance.sqrt();
                if std_dev > 1e-8 { mean / std_dev } else { 0.0 }
            } else {
                0.0
            };
            let sharpe_decimal = Decimal::try_from(sharpe_component).unwrap_or(Decimal::ZERO);

            let sharpe_weight = self.config.sharpe_weight;
            let pnl_weight_adjusted = Decimal::ONE - sharpe_weight;
            let final_reward = pnl_base_reward * pnl_weight_adjusted + sharpe_decimal * sharpe_weight;

            let final_reward_f64: f64 = final_reward.try_into()
                .map_err(|e| MLError::InvalidInput(format!("Reward conversion failed: {}", e)))?;

            // Apply EMA normalization if enabled
            if let Some(normalizer) = &mut self.normalizer {
                let norm = normalizer.normalize(final_reward_f64);
                normalizer.update(final_reward_f64);
                norm.clamp(-3.0, 3.0)
            } else {
                final_reward_f64
            }
        };

Step 2: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- reward 2>&1 | tail -5

Step 3: Commit

git add crates/ml/src/dqn/reward.rs
git commit -m "feat(ml): wire DSR into calculate_reward, scale diversity penalty"

Task 1.3: Disable risk-adjusted division when DSR active

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:2234 (risk-adjusted call)
  • Modify: crates/ml/src/trainers/dqn/trainer.rs:508 (RewardConfig construction)

Step 1: Update RewardConfig construction in trainer (line 508)

Change the RewardConfig construction to enable DSR when hyperparams say so. Add use_dsr and dsr_eta fields to DQNHyperparameters first.

In crates/ml/src/trainers/dqn/config.rs, add to DQNHyperparameters:

    pub use_dsr: bool,
    pub dsr_eta: f64,

And in its default:

            use_dsr: false,
            dsr_eta: 0.01,

Then in trainer.rs around line 508, update the RewardConfig construction:

            use_dsr: self.hyperparams.use_dsr,
            dsr_eta: self.hyperparams.dsr_eta,

Step 2: Skip risk-adjusted reward when DSR active (line 2234)

Replace line 2234:

                    // When DSR is active, it already handles risk-adjustment.
                    // Legacy path: divide by PnL volatility (Sharpe-like normalization)
                    let risk_adjusted_reward = if self.hyperparams.use_dsr {
                        barrier_scaled_reward // DSR output is already risk-adjusted
                    } else {
                        self.calculate_risk_adjusted_reward(barrier_scaled_reward)
                    };

Step 3: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -5

Step 4: Commit

git add crates/ml/src/trainers/dqn/config.rs crates/ml/src/trainers/dqn/trainer.rs
git commit -m "feat(ml): skip risk-adjusted division when DSR active"

Task 1.4: Increase experience reward precision

Files:

  • Modify: crates/ml/src/dqn/experience.rs:31,43

Step 1: Change scale factor

At line 31 (or wherever Experience::new stores reward):

// Old: reward: (reward * 10000.0) as i32,
// New: 100x more precision — DSR values ~±2.0 map to ±2_000_000 (fits i32)
reward: (reward * 1_000_000.0) as i32,

At line 43 (or reward_f32() method):

// Old: self.reward as f32 / 10000.0
self.reward as f32 / 1_000_000.0

Step 2: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- experience 2>&1 | tail -5

Step 3: Commit

git add crates/ml/src/dqn/experience.rs
git commit -m "fix(ml): increase experience reward precision from 10000 to 1000000"

Task 1.5: Reset portfolio tracker between epochs

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:1511-1546

Step 1: Add portfolio reset at epoch boundary

Around line 1503 (where self.pnl_history.clear() already exists), add:

            self.pnl_history.clear();

            // Reset portfolio tracker to initial capital for reward stationarity.
            // Without this, portfolio compounding creates non-stationary rewards
            // (same action produces 5x different reward depending on epoch),
            // corrupting replay buffer and preventing Q-value convergence.
            self.portfolio_tracker.reset();

Also add DSR reset at the same location:

            // Reset DSR EMA state for clean epoch
            self.reward_fn.reset_dsr();

And reset the returns_buffer in the reward function. Add a method:

In reward.rs, add to RewardFunction:

    /// Reset per-episode/epoch state (returns buffer + DSR)
    pub fn reset_epoch_state(&mut self) {
        self.returns_buffer.clear();
        self.reset_dsr();
        // Reset normalizer running stats to prevent drift
        if let Some(ref mut normalizer) = self.normalizer {
            *normalizer = RewardNormalizer::new();
        }
    }

Then in trainer.rs at epoch boundary, call self.reward_fn.reset_epoch_state() instead of just reset_dsr().

Step 2: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -5

Step 3: Commit

git add crates/ml/src/trainers/dqn/trainer.rs crates/ml/src/dqn/reward.rs
git commit -m "fix(ml): reset portfolio, DSR, and reward stats between epochs"

Task 1.6: Reset DSR at episode boundaries

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:2080,2342

At barrier exit (line 2080) and time/data boundary (line 2342), add:

            self.reward_fn.reset_dsr();

This ensures DSR doesn't carry state across independent trading episodes.

Step: Commit

git add crates/ml/src/trainers/dqn/trainer.rs
git commit -m "fix(ml): reset DSR at episode boundaries (barrier exit, time boundary)"

Task 1.7: Phase 1 integration test

Step 1: Run full ml test suite

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -10

Expected: 2742+ tests pass, 0 failures.

Step 2: Verify compile with check

SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -5

Expected: 0 errors, 0 warnings.


Phase 2: Training Steps & Exploration (5 tasks)

Task 2.1: Wire noisy_sigma_init to NoisyLinear

Files:

  • Modify: crates/ml/src/dqn/noisy_layers.rs:59-80
  • Modify: crates/ml/src/dqn/dqn.rs (pass sigma_init to NoisyLinear)

Step 1: Add sigma_init parameter to NoisyLinear::new()

Change NoisyLinear::new() signature to accept sigma_init: f64:

At line 80, replace:

// Old:
let sigma_init = 0.5 / (in_features as f64).sqrt();
// New: use caller-provided sigma, fall back to Fortunato et al. default

Update the constructor to accept and use the parameter. Also update all call sites in dqn.rs where NoisyLinear::new() is called, passing self.config.noisy_sigma_init.

Step 2: Add epsilon floor back for NoisyNets

In crates/ml/src/dqn/dqn.rs around line 1460-1461, change:

// Old: self.config.noisy_epsilon_floor  (was 0.0)
// New: small epsilon floor ensures exploration in early training
// when NoisyNet sigma hasn't annealed enough
let effective_epsilon = if self.config.use_noisy_nets {
    self.config.noisy_epsilon_floor.max(0.02) // Minimum 2% random
} else {
    self.epsilon
};

Step 3: Commit

git add crates/ml/src/dqn/noisy_layers.rs crates/ml/src/dqn/dqn.rs
git commit -m "fix(ml): wire noisy_sigma_init to NoisyLinear, add 2% epsilon floor"

Task 2.2: Increase epochs, cap batch_size, fix min_replay_size

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:463,504,2310

Step 1: Change hyperopt bounds

Line 463 — batch_size:

// Old: (64.0, 4096.0)
(64.0, 512.0),                  // 1: batch_size (capped: 4096 → too few gradient steps)

Line 504 — min_epochs_before_stopping:

// Remove from search space entirely (early stopping disabled in hyperopt)
// This dimension will be removed in Task 2.3

Step 2: Change epochs parameter

Find where the hyperopt adapter constructs the trainer (look for epochs parameter passed to DqnHyperoptAdapter::new() or similar). Change from 8 to 30.

This is passed by the caller — likely in the hyperopt binary or workflow template. Find with:

grep -rn "DqnHyperoptAdapter::new\|epochs.*=.*8" crates/ml/src/hyperopt/

Update the caller to pass epochs: 30.

Step 3: Fix min_replay_size

Around line 2310 where min_replay_size is set:

// Old: min_replay_size: params.batch_size * 2,
min_replay_size: (params.batch_size * 50).min(params.buffer_size / 2),

Step 4: Fix PER beta annealing

Find where per_beta_annealing_steps or similar is set. Replace hardcoded epochs * 1000 with:

// Old: per_beta_annealing_steps: epochs * 1000,
// New: match actual gradient steps
per_beta_annealing_steps: (training_data_len / params.batch_size) * epochs,

If training_data_len isn't available at this point, use a conservative estimate:

per_beta_annealing_steps: epochs * 2000, // ~2000 steps/epoch at batch_size=64 with 130k bars

Step 5: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): cap batch_size=512, epochs=30, min_replay=50x, fix PER annealing"

Task 2.3: Remove wasted hyperopt dimensions

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:460-506 (continuous_bounds)
  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:509-654 (from_continuous)
  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:655-720 (to_continuous, param_names)

Step 1: Remove dimensions and add DSR eta

Remove from search space:

  • Index 24: min_epochs_before_stopping (early stopping disabled)
  • sharpe_weight (DSR replaces — if it's separate from the bounds array, just fix to 0.0)

Add to search space:

  • dsr_eta: log scale (0.001_f64.ln(), 0.05_f64.ln()) — replaces removed dimension

Update continuous_bounds(), from_continuous(), to_continuous(), and param_names() to match. The index numbers for all subsequent parameters shift — be careful with the mapping.

Step 2: Enable DSR in hyperopt

In from_continuous() where hyperparameters are assembled (~line 2350):

            use_dsr: true,  // Always enabled for hyperopt
            dsr_eta: params.dsr_eta,
            enable_risk_adjusted_rewards: false,  // DSR handles risk-adjustment

And in the RewardConfig construction in the hyperopt adapter:

            enable_normalization: false,  // DSR replaces EMA normalizer
            use_dsr: true,
            dsr_eta: params.dsr_eta,

Step 3: Update DQNParams struct

Add dsr_eta: f64 field to DQNParams struct and its default.

Step 4: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- hyperopt 2>&1 | tail -10

Fix any test that hardcodes the 26D dimension count.

Step 5: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "feat(ml): add DSR eta to hyperopt, remove wasted dimensions (26D→~24D)"

Task 2.4: Widen gamma range

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:464
// Old: (0.88, 0.96)
(0.88, 0.99),                  // 2: gamma (widened: allow longer-horizon strategies)

Step: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): widen gamma hyperopt range to [0.88, 0.99]"

Task 2.5: Phase 2 integration test

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -10
SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -5

Phase 3: Fix IQN/QR-DQN & C51 (4 tasks)

Task 3.1: Fix IQN training path

Files:

  • Modify: crates/ml/src/dqn/dqn.rs:2316+ (compute_loss_internal)
  • Modify: crates/ml/src/dqn/dqn.rs:258-262 (defaults)

The bug: use_iqn=true creates a separate iqn_network (QuantileNetwork). The training loss (compute_loss_internal) trains this network. But batch_greedy_actions() and the backtest evaluation path in the hyperopt adapter may use the dist_dueling_network instead.

Step 1: Audit the inference path

Read dqn.rs around lines 1481, 1634, 1775 to verify which network is used for action selection when use_iqn=true. Read batch_greedy_actions to confirm it also checks use_iqn.

Step 2: Fix — ensure consistent network usage

If batch_greedy_actions does NOT check use_iqn and always uses dist_dueling_network, add the IQN branch:

// In batch_greedy_actions():
if self.config.use_iqn && self.iqn_network.is_some() {
    // Use IQN network for Q-value estimation
    let iqn_net = self.iqn_network.as_ref().unwrap();
    // ... compute Q-values via IQN
} else {
    // Use dist_dueling_network (C51 path)
    // ... existing code
}

Step 3: Enable IQN by default when num_atoms > 100

Change the default at line 258-259:

// Old: use_iqn: false,
// IQN: Now fixed — same network used for training and inference
use_iqn: false, // Still default false; hyperopt enables via num_atoms > 100

The hyperopt already enables it at trainer.rs:454. Just verify the training path works.

Step 4: Add/update IQN integration test

Check existing test at trainer.rs:3811 (test_iqn_training_step). Ensure it verifies that:

  • IQN loss decreases over multiple steps
  • Action selection uses the IQN network (not dist_dueling)
  • Target network update works for IQN

Step 5: Commit

git add crates/ml/src/dqn/dqn.rs
git commit -m "fix(ml): fix IQN training path — consistent network for train+inference"

Task 3.2: Fix C51 v_min/v_max for DSR rewards

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:474-475
  • Modify: crates/ml/src/dqn/dqn.rs:238-239

Step 1: Update hyperopt bounds

DSR rewards are typically ±2.0. With gamma=0.92, Q-values ≈ ±25.

// Old: (-2.0, -0.5),  // 10: v_min
//      (0.5, 2.0),    // 11: v_max
(-30.0, -5.0),         // 10: v_min (DSR Q-values ≈ ±25)
(5.0, 30.0),           // 11: v_max

Step 2: Update DQNConfig defaults

// Old: v_min: -10.0, v_max: 10.0
v_min: -25.0,
v_max: 25.0,

Step 3: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs crates/ml/src/dqn/dqn.rs
git commit -m "fix(ml): align C51 v_min/v_max with DSR Q-value range (±25)"

Task 3.3: Cap CQL alpha

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:500
// Old: (0.0, 0.5)     // 22: cql_alpha
(0.0, 0.1),            // 22: cql_alpha (capped: 0.5 adds ~0.8 loss, crushes 5-action Q-diffs)

Step: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): cap CQL alpha at 0.1 to prevent Q-value crushing"

Task 3.4: Phase 3 integration test

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -10

Phase 4: Network Architecture (4 tasks)

Task 4.1: Add RMSNorm to distributional-dueling network

Files:

  • Modify: crates/ml/src/dqn/distributional_dueling.rs:159-170 (shared layers)
  • Read: crates/ml/src/dqn/rmsnorm.rs (existing RMSNorm impl)

Step 1: Import RMSNorm

Add import at top of distributional_dueling.rs:

use super::rmsnorm::RMSNorm;

Step 2: Add RMSNorm after each hidden layer in shared backbone

In the constructor where shared layers are built (around lines 159-170), add RMSNorm after each Linear+LeakyReLU:

// For each shared layer:
// linear → LeakyReLU → RMSNorm
let rmsnorm = RMSNorm::new(layer_dim, vb.pp(format!("shared_rmsnorm_{}", i)))?;

Store the RMSNorm layers in the struct and apply them in forward().

Step 3: Add RMSNorm to value/advantage stream hidden layers

Same pattern for the value and advantage stream hidden layers.

Step 4: Run tests

SQLX_OFFLINE=true cargo test -p ml --lib -- distributional 2>&1 | tail -10

Step 5: Commit

git add crates/ml/src/dqn/distributional_dueling.rs
git commit -m "feat(ml): add RMSNorm to distributional-dueling shared backbone and streams"

Task 4.2: Constant-width hidden layers

Files:

  • Modify: crates/ml/src/trainers/dqn/trainer.rs:288-305
  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs (hidden_dim_base bounds)

Step 1: Change hidden_dims construction

Find lines 288-305 where hidden_dims is built as [base, base/2, base/4]:

// Old: vec![base, base / 2, base / 4]
// New: constant-width (no tapering, no silently-discarded 64-wide layer)
vec![base, base]

Step 2: Narrow hidden_dim_base range

In hyperopt bounds (index 21):

// Old: (256.0, 4096.0)
(256.0, 1024.0),               // 21: hidden_dim_base (narrowed: 4096 can't converge in 30k steps)

Step 3: Commit

git add crates/ml/src/trainers/dqn/trainer.rs crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): constant-width [base,base] hidden layers, narrow range to [256,1024]"

Task 4.3: Default n_steps=3

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:478
// Old: (1.0, 5.0)    // 14: n_steps
(3.0, 5.0),            // 14: n_steps (raised minimum: n=3 is Rainbow standard, 3rd most impactful)

Step: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): raise n_steps minimum from 1 to 3 (Rainbow standard)"

Task 4.4: Phase 4 integration test

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -10
SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -5

Phase 5: State Representation (4 tasks)

Task 5.1: Remove dead features

Files:

  • Modify: crates/ml/src/features/extraction.rs:545 (regime features)
  • Modify: crates/ml/src/features/extraction.rs:274 (OFI features)
  • Modify: crates/ml/src/dqn/dqn.rs:205 (state_dim default)
  • Modify: all files referencing state_dim: 54 or feature indices 40-50

Step 1: Remove regime features (indices 40-42)

In extract_regime_features_v2(), instead of writing 3 zeros, make it a no-op (don't write to output, reduce feature count by 3).

Step 2: Remove OFI features (indices 43-50)

In the OFI extraction, instead of zero-padding 8 features, skip them entirely when MBP-10 data is unavailable (which is always during hyperopt).

Step 3: Update state_dim

// Old: state_dim: 54
state_dim: 43, // 51 market - 3 regime - 8 OFI + 3 portfolio

Update all hardcoded references to 54.

Step 4: Fix normalize_with_skip indices

In crates/ml/src/trainers/dqn/statistics.rs, the skip indices [125, 126, 127] are remnants of the old 140-feature architecture. Update to match the new 43-feature layout or remove entirely (all features should be z-scored).

Step 5: Run tests — expect some failures from dimension changes

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | grep -E "FAIL|test result"

Fix any tests that hardcode 54-dim or specific feature indices.

Step 6: Commit

git add crates/ml/src/features/extraction.rs crates/ml/src/dqn/dqn.rs crates/ml/src/trainers/dqn/statistics.rs
git commit -m "fix(ml): remove 11 dead features (regime+OFI), state_dim 54→43"

Task 5.2: Fix volume normalization

Files:

  • Modify: crates/ml/src/features/extraction.rs:584 (volume)
// Old: safe_normalize(bar.volume, 0.0, 1_000_000.0)
// New: log-transform volume before normalizing (futures volume can be 10M+)
safe_normalize(bar.volume.max(1.0).ln(), 0.0, 18.0)  // ln(1)=0, ln(10M)≈16.1

Step: Commit

git add crates/ml/src/features/extraction.rs
git commit -m "fix(ml): log-transform volume normalization for futures scale"

Task 5.3: Fix ATR normalization

Files:

  • Modify: crates/ml/src/features/extraction.rs:319 (ATR)
// Old: safe_normalize(indicators.last_atr, 0.0, 100.0)
// New: log-transform ATR (futures ATR ranges 0.5 to 5000+)
safe_normalize(indicators.last_atr.max(0.001).ln(), -7.0, 9.0)  // ln(0.001)≈-6.9, ln(5000)≈8.5

Step: Commit

git add crates/ml/src/features/extraction.rs
git commit -m "fix(ml): log-transform ATR normalization for futures scale"

Task 5.4: Center portfolio value feature

Files:

  • Modify: crates/ml/src/dqn/portfolio_tracker.rs:162
// Old: portfolio_value / self.initial_capital  (centered on 1.0)
// New: center on 0.0 for consistency with z-scored market features
(portfolio_value / self.initial_capital) - 1.0

Step: Commit

git add crates/ml/src/dqn/portfolio_tracker.rs
git commit -m "fix(ml): center portfolio value feature at 0.0 (was 1.0)"

Phase 6: Hyperopt Objective (2 tasks)

Task 6.1: Increase Sharpe weight in composite objective

Files:

  • Modify: crates/ml/src/hyperopt/adapters/dqn.rs:3380-3383 (tanh composite)

Find the tanh composite score lines and change weights:

// Old: 0.4 * sortino + 0.3 * calmar + 0.2 * sharpe + 0.1 * omega
// New: Sharpe-primary (it's what we actually care about)
let composite_score = 0.40 * (adjusted_sharpe / 2.0).tanh()
    + 0.25 * (sortino / 3.0).tanh()
    + 0.20 * (calmar / 5.0).tanh()
    + 0.15 * (omega / 2.0).tanh();

This makes Sharpe effective weight = 0.6 × 0.4 × S/2 = 12% (up from 6%).

Step: Commit

git add crates/ml/src/hyperopt/adapters/dqn.rs
git commit -m "fix(ml): increase Sharpe weight in hyperopt objective (6%→12% effective)"

Task 6.2: Final integration test and commit

Step 1: Run full test suite

SQLX_OFFLINE=true cargo test -p ml --lib 2>&1 | tail -10

Expected: All tests pass (adjusted for state_dim changes).

Step 2: Run clippy

SQLX_OFFLINE=true cargo clippy --workspace 2>&1 | tail -10

Expected: 0 errors, 0 warnings.

Step 3: Run workspace check

SQLX_OFFLINE=true cargo check --workspace 2>&1 | tail -5

Step 4: Final commit

git add -A
git commit -m "feat(ml): DQN hyperopt overhaul v2 — complete 6-phase fix

Phase 1: DSR reward function (replaces double-normalized pipeline)
Phase 2: Training steps (30 epochs, batch≤512, wired noisy_sigma)
Phase 3: Fixed IQN path, C51 v_min/v_max, capped CQL alpha
Phase 4: RMSNorm in dist-dueling, constant-width layers, n_steps≥3
Phase 5: Removed 11 dead features, log-scaled volume/ATR
Phase 6: Sharpe-primary objective weighting

Fixes 19 root causes identified by 6-agent deep audit.
Reduces hyperopt search space from 26D to ~24D.
Expected: Sharpe >> 0 on next hyperopt run."

Summary: All Changes by File

File Changes
dqn/reward.rs DSR struct, config fields, wired into calculate_reward, diversity scaled, reset methods
dqn/experience.rs Precision 10000→1000000
dqn/noisy_layers.rs Wire sigma_init parameter
dqn/dqn.rs Fix IQN path, v_min/v_max defaults, state_dim 54→43, epsilon floor 0.02
dqn/distributional_dueling.rs Add RMSNorm to shared backbone + streams
dqn/portfolio_tracker.rs Center portfolio value at 0.0
trainers/dqn/trainer.rs Skip risk-adjusted when DSR, reset portfolio/DSR per epoch, constant-width hidden layers, use_dsr config
trainers/dqn/config.rs Add use_dsr, dsr_eta to DQNHyperparameters
hyperopt/adapters/dqn.rs DSR eta tunable, remove wasted dims, batch≤512, n_steps≥3, CQL≤0.1, v_min/v_max scaled, gamma≤0.99, hidden_dim≤1024, Sharpe-primary objective, enable DSR+disable normalizers
features/extraction.rs Remove regime+OFI features, log-transform volume/ATR
trainers/dqn/statistics.rs Fix stale skip indices

Post-Implementation

  1. Push to main
  2. Wait for Argo CI (compile-services + compile-training)
  3. Submit hyperopt v4 workflow: 20 trials × 30 epochs
  4. Monitor via Prometheus: foxhunt_hyperopt_best_objective, foxhunt_training_epoch_sharpe