feat: min_hold_bars in hyperopt search space [2, 20]

Integer parameter (rounded from continuous). Lets PSO discover
optimal hold duration for ES futures regime characteristics.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-25 23:26:42 +01:00
parent 64925a21ff
commit 745bd262cb

View File

@@ -3,7 +3,7 @@
//! 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
//! - 46D 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)
@@ -144,7 +144,7 @@ const TRIAL_VRAM_MB: f64 = 7000.0;
/// Corrected: was 0.02 (20KB) — 100x too high
const MB_PER_SAMPLE: f64 = 0.0005;
/// DQN hyperparameter space (45D continuous)
/// DQN hyperparameter space (46D continuous)
///
/// Fixed params (not in search space):
/// - noisy_epsilon_floor: 0.10 (minimum random exploration to prevent action collapse)
@@ -406,6 +406,10 @@ pub struct DQNParams {
/// CVaR alpha confidence level (0.01-0.20). Lower = more risk-averse.
/// 0.05 = optimize for worst 5% outcomes.
pub cvar_alpha: f64,
/// Minimum bars to hold a position before allowing exit or reversal (integer, [2, 20]).
/// Lets PSO discover optimal hold duration for ES futures regime characteristics.
pub min_hold_bars: usize,
}
impl Default for DQNParams {
@@ -498,13 +502,14 @@ impl Default for DQNParams {
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
min_hold_bars: 5, // Default: 5 bars minimum hold
}
}
}
impl ParameterSpace for DQNParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
// 45D search space (Task 11: +4 dims at indices 41-44)
// 46D search space (Task 11: +4 dims at indices 41-44, Task 4: +1 dim at index 45)
// 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.
@@ -558,6 +563,7 @@ impl ParameterSpace for DQNParams {
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));
let mhb = b("min_hold_bars", (2.0, 20.0));
// Two-phase hyperopt: fix architecture OR dynamics params to single-point bounds.
// Single-point bounds (v, v) make the optimizer trivially converge on those
@@ -692,6 +698,7 @@ impl ParameterSpace for DQNParams {
cwt, // 42: curiosity_weight_tunable
ucvar, // 43: use_cvar_action_selection
cva, // 44: cvar_alpha
mhb, // 45: min_hold_bars [2, 20]
];
// Phase FULL: fix all non-architecture dims to Phase 1 best values.
@@ -742,6 +749,8 @@ impl ParameterSpace for DQNParams {
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);
// min_hold_bars (index 45): integer, rounded from continuous [2, 20]
let min_hold_bars = (x.get(45).copied().unwrap_or(5.0).round() as usize).clamp(2, 20);
// === 26 TUNED parameters (from search space) ===
// C2: Removed curiosity_weight and noisy_epsilon_floor (exploration stacking)
@@ -931,13 +940,14 @@ impl ParameterSpace for DQNParams {
curiosity_weight_tunable,
use_cvar_action_selection,
cvar_alpha,
min_hold_bars, // Index 45: min_hold_bars [2, 20]
};
Ok(params)
}
fn to_continuous(&self) -> Vec<f64> {
// 45D search space (Task 11: +4 dims at indices 41-44)
// 46D search space (Task 11: +4 dims at indices 41-44, Task 4: +1 dim at index 45)
vec![
self.learning_rate.ln(), // 0
self.batch_size as f64, // 1
@@ -986,11 +996,12 @@ impl ParameterSpace for DQNParams {
self.curiosity_weight_tunable, // 42
self.use_cvar_action_selection, // 43
self.cvar_alpha, // 44
self.min_hold_bars as f64, // 45
]
}
fn param_names() -> Vec<&'static str> {
// 45 tuned parameters (Task 11: +4 new dims at indices 41-44)
// 46 tuned parameters (Task 11: +4 new dims at indices 41-44, Task 4: +1 dim at index 45)
vec![
"learning_rate", // 0
"batch_size", // 1
@@ -1039,6 +1050,7 @@ impl ParameterSpace for DQNParams {
"curiosity_weight", // 42
"use_cvar_action_selection", // 43
"cvar_alpha", // 44
"min_hold_bars", // 45
]
}
@@ -2635,7 +2647,7 @@ impl HyperparameterOptimizable for DQNTrainer {
// 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!("Training DQN with parameters (46D search space):");
info!(" Learning rate: {:.6}", params.learning_rate);
info!(" Batch size: {}", params.batch_size);
info!(" Gamma: {:.3}", params.gamma);
@@ -3071,6 +3083,8 @@ impl HyperparameterOptimizable for DQNTrainer {
// 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,
// Task 4: min_hold_bars wired from hyperopt search space (index 45)
min_hold_bars: params.min_hold_bars,
..Default::default()
};
@@ -3863,7 +3877,7 @@ mod tests {
#[test]
fn test_dqn_params_roundtrip() {
// Roundtrip test for the 45D search space (Task 11: +4 new dims)
// Roundtrip test for the 46D search space (Task 11: +4 new dims, Task 4: +1 dim)
// Only the tuned parameters roundtrip; fixed params get defaults from from_continuous
let params = DQNParams {
learning_rate: 3.37e-05,
@@ -3935,10 +3949,12 @@ mod tests {
curiosity_weight_tunable: 0.15,
use_cvar_action_selection: 1.0,
cvar_alpha: 0.08,
// Task 4: min_hold_bars in search space
min_hold_bars: 8,
};
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 45, "to_continuous must return 45D vector");
assert_eq!(continuous.len(), 46, "to_continuous must return 46D vector");
let recovered = DQNParams::from_continuous(&continuous).unwrap();
// Tuned parameters must roundtrip exactly
@@ -3994,12 +4010,14 @@ mod tests {
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);
// Task 4: min_hold_bars roundtrip (index 45)
assert_eq!(recovered.min_hold_bars, params.min_hold_bars);
}
#[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
assert_eq!(bounds.len(), 46); // 46D: 39 base + spectral_norm_sigma_max + c51_warmup_epochs + her_ratio + curiosity_weight + use_cvar + cvar_alpha + min_hold_bars
// Check log-scale bounds are reasonable
assert!(bounds[0].0 < bounds[0].1); // learning_rate
@@ -4059,7 +4077,7 @@ mod tests {
#[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.len(), 46); // 46D: 39 base + spectral_norm_sigma_max + c51_warmup_epochs + her_ratio + curiosity_weight + use_cvar + cvar_alpha + min_hold_bars
assert_eq!(names[0], "learning_rate");
assert_eq!(names[1], "batch_size");
assert_eq!(names[2], "gamma");
@@ -4097,12 +4115,13 @@ mod tests {
assert_eq!(names[42], "curiosity_weight"); // 42
assert_eq!(names[43], "use_cvar_action_selection"); // 43
assert_eq!(names[44], "cvar_alpha"); // 44
assert_eq!(names[45], "min_hold_bars"); // 45
}
#[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)
// 46D search space (Task 11: +4 new dims, Task 4: +1 min_hold_bars dim)
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
@@ -4499,7 +4518,7 @@ mod tests {
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");
assert_eq!(continuous.len(), 46, "Should have 46 continuous dimensions");
let roundtrip = DQNParams::from_continuous(&continuous).unwrap();
// Default num_quantiles=32 (IQN default, used alongside C51)
assert_eq!(roundtrip.num_quantiles, 32);