Files
foxhunt/crates/ml/src/hyperopt/adapters/dqn.rs
jgrusewski 366832be44 refactor(data_source): default to mbp10 across smoke + localdev profiles
Smoke and localdev profiles previously used data_source = "ohlcv", divergent
from production (mbp10). Mismatch silently produced cache-key collisions in
the SHA256 hash path: smoke fxcache could not be loaded by production-shape
training without an explicit override. Local-dev fxcache regen also required
remembering to pass --data-source ohlcv to match.

Globalize mbp10 as the default everywhere it isn't deliberately overridden:
- config/training/dqn-smoketest.toml: data_source = "mbp10"
- config/training/dqn-localdev.toml: data_source = "mbp10"
- training_profile.rs: doc Default → "mbp10"
- trainers/dqn/config.rs: Default impl → "mbp10"
- hyperopt/adapters/dqn.rs: default → "mbp10"
- examples/precompute_features.rs: doc updated
- fxcache.rs / feature_cache.rs: discover_and_load + cache-key tests
  use "mbp10" arguments
- docs/dqn-wire-up-audit.md: new entry per Invariant 7

Documentation strings retained "ohlcv" only where they document the two
available choices (config.rs:946, training_profile.rs:83).

Local fxcache regenerated to v6 mbp10:
test_data/feature-cache/13c0b086a975cc7e2384377a2cd0e97738c9410292fcfecb5807c29bf885cb48.fxcache
(175874 bars, 55 MB, OFI_DIM=32). Stale v5 ohlcv fxcache untracked
from git index (already gitignored post-79578bbaf).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 22:35:47 +02:00

3660 lines
159 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![allow(unsafe_code)] // Raw pointer borrow splits for deterministic eval
//! DQN Hyperparameter Optimization Adapter (Two-Phase)
//!
//! This module provides a production-ready adapter for optimizing DQN
//! hyperparameters using the generic optimization framework. It implements:
//!
//! - 24D continuous parameter space with log-scale handling (consolidated from 46D)
//! - 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
//!
//! Minimizes a composite loss from walk-forward backtest metrics:
//! - 60% Risk-adjusted returns: Sharpe(40%) + Sortino(25%) + Calmar(20%) + Omega(15%)
//! - 15% Stability penalty (gradient explosion / Q-value divergence)
//! - 5% Activity score (penalizes degenerate hold-only models)
//! - CVaR tail risk penalty (soft ramp, capped at 3.0)
//! - Trade insufficiency penalty (< 50 trades → penalty)
//! - Action diversity penalty (< 9/9 unique exposures → 0.8 penalty)
//!
//! 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 gamma: {}", result.best_params.gamma);
//! println!("Best learning intensity: {}", result.best_params.learning_intensity);
//! println!("Best episode reward: {:.6}", -result.best_objective); // Negate to get actual reward
//! # Ok(())
//! # }
//! ```
use ml_core::device::MlDevice;
use anyhow::Context;
/// OFI dimension — mirrors `ml_core::state_layout::OFI_DIM`.
const OFI_DIM: usize = ml_core::state_layout::OFI_DIM;
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::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 exposure levels used during eval (out of 9: S100..L100)
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 (15D continuous, consolidated from 30D)
///
/// 4 breakout parameters are searched independently, 5 core family intensity
/// scalars control groups of related params, and 6 generalization family
/// intensity scalars (already existed). Fixed params come from TOML defaults.
///
/// ## 15D Layout:
/// - gamma, iqn_lambda, c51_warmup_epochs, c51_alpha_max (4 breakouts)
/// - learning_intensity, exploration_intensity, replay_intensity,
/// architecture_intensity, risk_intensity (5 core families)
/// - adversarial_intensity, regularization_intensity, augmentation_intensity,
/// loss_shaping_intensity, ensemble_intensity, causal_intensity (6 gen families)
///
/// ## Fixed at TOML defaults (removed from PSO):
/// batch_size, transaction_cost_multiplier, v_max, holding_cost_rate, and all
/// individual params now absorbed into the 5 core families.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct DQNParams {
// === 4 breakout parameters (searched independently) ===
/// Discount factor for future rewards [0.95, 0.999]
pub gamma: f64,
/// IQN dual-head lambda weight relative to C51 loss [0.0, 1.0]
pub iqn_lambda: f64,
/// C51 warmup epochs: MSE loss for first N epochs [3.0, 15.0]
pub c51_warmup_epochs: f64,
/// C51 alpha max: caps MSE→C51 blend to prevent gradient starvation [0.3, 0.9]
pub c51_alpha_max: f64,
// === 5 core family intensity scalars [0.0, 2.0] ===
/// Learning family: scales learning_rate, tau, weight_decay
pub learning_intensity: f64,
/// Exploration family: scales entropy_coefficient, noisy_sigma_init
pub exploration_intensity: f64,
/// Replay family: scales buffer_size, per_alpha, per_beta_start, n_steps
pub replay_intensity: f64,
/// Architecture family: scales hidden_dim_base, dueling_hidden_dim, num_atoms
pub architecture_intensity: f64,
/// Risk family: scales max_position, huber_delta, min_profit_factor, w_dd, dd_threshold
pub risk_intensity: f64,
// === 6 generalization family intensity scalars [0.0, 2.0] ===
/// Adversarial family intensity: scales saboteur perturbation, warmup, interval
pub adversarial_intensity: f64,
/// Regularization family intensity: scales dropout, spectral norm, stochastic depth, spectral decoupling
pub regularization_intensity: f64,
/// Data augmentation family intensity: scales feature masking, feature noise
pub augmentation_intensity: f64,
/// Loss shaping family intensity: scales CEA, order credit, risk efficiency, position entropy
pub loss_shaping_intensity: f64,
/// Ensemble family intensity: scales ensemble disagreement penalty
pub ensemble_intensity: f64,
/// Causal family intensity: scales causal weight, intervention interval
pub causal_intensity: f64,
}
impl Default for DQNParams {
fn default() -> Self {
Self {
gamma: 0.99,
iqn_lambda: 0.25,
c51_warmup_epochs: 5.0,
c51_alpha_max: 0.5,
// Core family intensities: 1.0 = neutral (no change to TOML defaults)
learning_intensity: 1.0,
exploration_intensity: 1.0,
replay_intensity: 1.0,
architecture_intensity: 1.0,
risk_intensity: 1.0,
// Generalization family intensities: 1.0 = neutral
adversarial_intensity: 1.0,
regularization_intensity: 1.0,
augmentation_intensity: 1.0,
loss_shaping_intensity: 1.0,
ensemble_intensity: 1.0,
causal_intensity: 1.0,
}
}
}
impl ParameterSpace for DQNParams {
fn continuous_bounds() -> Vec<(f64, f64)> {
// 15D search space (consolidated from 30D via family intensity grouping)
// 4 breakouts + 5 core families + 6 generalization families = 15D
//
// Phase system: certain dims are pinned to 1.0 (single-point bounds)
// depending on the active hyperopt phase.
use crate::training_profile::get_hyperopt_phase;
use crate::training_profile::HyperoptPhase;
let phase = get_hyperopt_phase();
let search = (0.0, 2.0); // searchable intensity range
let pinned = (1.0, 1.0); // fixed at neutral
let (learning, exploration, replay, architecture, risk) = match &phase {
// Fast (default): search ALL 15 dims — 15D is well within PSO's efficient range.
// Phased search was needed for the old 30D space; with families it's one pass.
HyperoptPhase::Fast => (search, search, search, search, search),
// Full: search architecture + risk + gen families, pin dynamics from Phase 1
HyperoptPhase::Full(_) => (pinned, pinned, pinned, search, search),
// Reward: pin everything except loss shaping + ensemble
HyperoptPhase::Reward(_) => (pinned, pinned, pinned, pinned, pinned),
// Risk: search risk, pin dynamics + architecture
HyperoptPhase::Risk(_) => (pinned, pinned, pinned, pinned, search),
};
let (adversarial, regularization, augmentation, loss_shaping, ensemble, causal) = match &phase {
HyperoptPhase::Fast => (search, search, search, search, search, search),
HyperoptPhase::Full(_) => (search, search, search, search, search, search),
HyperoptPhase::Reward(_) => (pinned, pinned, pinned, search, search, pinned),
HyperoptPhase::Risk(_) => (search, pinned, pinned, pinned, pinned, pinned),
};
vec![
(0.95, 0.999), // 0: gamma (breakout — always searchable)
(0.0, 1.0), // 1: iqn_lambda (breakout — always searchable)
(3.0, 15.0), // 2: c51_warmup_epochs (breakout — always searchable)
(0.3, 0.9), // 3: c51_alpha_max (breakout — always searchable)
learning, // 4: learning_intensity
exploration, // 5: exploration_intensity
replay, // 6: replay_intensity
architecture, // 7: architecture_intensity
risk, // 8: risk_intensity
adversarial, // 9: adversarial_intensity
regularization, // 10: regularization_intensity
augmentation, // 11: augmentation_intensity
loss_shaping, // 12: loss_shaping_intensity
ensemble, // 13: ensemble_intensity
causal, // 14: causal_intensity
]
}
fn from_continuous(x: &[f64]) -> Result<Self, MLError> {
if x.len() < 15 {
return Err(MLError::ConfigError(format!(
"Expected at least 15 continuous parameters, got {}", x.len()
)));
}
Ok(Self {
gamma: x[0].clamp(0.95, 0.999),
iqn_lambda: x[1].clamp(0.0, 1.0),
c51_warmup_epochs: x[2].clamp(3.0, 15.0),
c51_alpha_max: x[3].clamp(0.3, 0.9),
learning_intensity: x[4].clamp(0.0, 2.0),
exploration_intensity: x[5].clamp(0.0, 2.0),
replay_intensity: x[6].clamp(0.0, 2.0),
architecture_intensity: x[7].clamp(0.0, 2.0),
risk_intensity: x[8].clamp(0.0, 2.0),
adversarial_intensity: x[9].clamp(0.0, 2.0),
regularization_intensity: x[10].clamp(0.0, 2.0),
augmentation_intensity: x[11].clamp(0.0, 2.0),
loss_shaping_intensity: x[12].clamp(0.0, 2.0),
ensemble_intensity: x[13].clamp(0.0, 2.0),
causal_intensity: x[14].clamp(0.0, 2.0),
})
}
fn to_continuous(&self) -> Vec<f64> {
vec![
self.gamma, // 0
self.iqn_lambda, // 1
self.c51_warmup_epochs, // 2
self.c51_alpha_max, // 3
self.learning_intensity, // 4
self.exploration_intensity, // 5
self.replay_intensity, // 6
self.architecture_intensity, // 7
self.risk_intensity, // 8
self.adversarial_intensity, // 9
self.regularization_intensity, // 10
self.augmentation_intensity, // 11
self.loss_shaping_intensity, // 12
self.ensemble_intensity, // 13
self.causal_intensity, // 14
]
}
fn param_names() -> Vec<&'static str> {
vec![
"gamma", // 0
"iqn_lambda", // 1
"c51_warmup_epochs", // 2
"c51_alpha_max", // 3
"learning_intensity", // 4
"exploration_intensity", // 5
"replay_intensity", // 6
"architecture_intensity", // 7
"risk_intensity", // 8
"adversarial_intensity", // 9
"regularization_intensity", // 10
"augmentation_intensity", // 11
"loss_shaping_intensity", // 12
"ensemble_intensity", // 13
"causal_intensity", // 14
]
}
fn continuous_bounds_for(budget: &HardwareBudget) -> Vec<(f64, f64)> {
let mut bounds = Self::continuous_bounds();
let vram_mb = budget.gpu_memory_mb as f64;
// Architecture intensity (idx 7): cap upper bound on small GPUs to prevent
// scaling hidden_dim/atoms beyond VRAM capacity.
if vram_mb > 0.0 && vram_mb < 16_000.0 {
if let Some(b) = bounds.get_mut(7) {
// Small GPUs: cap architecture scaling to 1.5x (instead of 2.0x)
b.1 = b.1.min(1.5);
}
}
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.
///
/// With the 15D family-based search space, most individual params come from
/// TOML defaults and are validated at the DQNHyperparameters level. Here we
/// only check the 4 breakout params and intensity scalar ranges.
fn validate_for_hft_trendfollowing(&self) -> Result<(), String> {
if self.gamma <= 0.0 || self.gamma > 1.0 {
return Err(format!(
"gamma ({}) must be in (0, 1]",
self.gamma
));
}
if self.iqn_lambda < 0.0 || self.iqn_lambda > 2.0 {
return Err(format!(
"iqn_lambda ({}) must be in [0, 2]",
self.iqn_lambda
));
}
if self.c51_warmup_epochs < 0.0 || self.c51_warmup_epochs > 100.0 {
return Err(format!(
"c51_warmup_epochs ({}) must be in [0, 100]",
self.c51_warmup_epochs
));
}
if self.c51_alpha_max < 0.0 || self.c51_alpha_max > 1.0 {
return Err(format!(
"c51_alpha_max ({}) must be in [0, 1]",
self.c51_alpha_max
));
}
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`: canonical layout at `STATE_DIM` (see `ml_core::state_layout`) —
/// 42 market + `OFI_DIM` OFI + 16 MTF + 8 portfolio + 6 plan/ISV.
/// - Market features (42): OHLCV, technical indicators, price patterns, volume, time, statistical, ADX, CUSUM direction
/// - OFI features (`OFI_DIM`): 8 raw OFI + 8 deltas + book_aggression + log_duration
/// + ofi_acceleration + toxicity_gradient + 10 MicrostructureState snapshot slots
/// + order_count_imbalance + microprice_residual
/// - 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,
// Preprocessing is always active
/// 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 fxcache 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_fxcache: Option<Arc<crate::fxcache::FxCacheData>>,
/// Index separating training from validation bars in the fxcache.
/// Bars [0..preloaded_train_end) are training, [preloaded_train_end..bar_count) are validation.
preloaded_train_end: usize,
/// 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; OFI_DIM]]>>,
/// Trading instrument symbol (e.g. "ES.FUT") — used in cache key to prevent
/// cross-instrument collisions when multiple symbols share the same data directory.
symbol: String,
/// Data source mode (e.g. "mbp10", "mbp10") — used in cache key to prevent
/// collisions between different bar types built from the same files.
data_source: String,
/// TOML training profile name for per-trial hyperparameters (default: "dqn-production").
/// Smoketests use "dqn-smoketest" for small network sizes that fit RTX 3050.
training_profile: String,
}
/// 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
// Preprocessing is always active (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_fxcache: None, // No preloaded data by default
preloaded_train_end: 0, // Set during 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()
symbol: "ES.FUT".to_string(), // Default: ES futures
data_source: "mbp10".to_string(), // Default: MBP-10 imbalance bars
training_profile: "dqn-production".to_string(), // Default: production params
})
}
/// 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 (always active)
/// Default: window=50, clip_sigma=5.0
pub fn with_preprocessing(mut self, _enable: bool, window: i64, clip_sigma: f64) -> Self {
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 TOML training profile for per-trial hyperparameters.
/// Default: "dqn-production". Smoketests use "dqn-smoketest".
pub fn with_training_profile(mut self, profile: &str) -> Self {
self.training_profile = profile.to_string();
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> {
info!("Preloading training data ...");
let preload_start = std::time::Instant::now();
// ── Step 1: Discover fxcache directory ──────────────────────────────
let fxcache_dir = self.feature_cache_dir.clone().or_else(|| {
if let Ok(dir) = std::env::var("FOXHUNT_FEATURE_CACHE_DIR") {
let p = PathBuf::from(dir);
if p.exists() { return Some(p); }
}
// Walk up from dbn_data_dir to find sibling feature-cache/
let mut dir: &std::path::Path = &self.dbn_data_dir;
loop {
if let Some(parent) = dir.parent() {
let candidate = parent.join("feature-cache");
if candidate.exists() { return Some(candidate); }
if parent == dir { break; }
dir = parent;
} else {
break;
}
}
None
});
// ── Step 2: Try loading from fxcache ────────────────────────────────
let fxcache_data: Option<crate::fxcache::FxCacheData> = fxcache_dir.and_then(|cache_dir| {
// Load the first .fxcache file from the cache directory.
// The precompute step generates exactly one fxcache per dataset.
// Key-based lookup fails when MBP-10/trades paths differ between
// the precompute pod and the hyperopt pod (different PVC mounts).
let fxcache_path = std::fs::read_dir(&cache_dir).ok()?
.flatten()
.find(|e| e.path().extension().map_or(false, |ext| ext == "fxcache"))
.map(|e| e.path())?;
info!("Loading fxcache from {:?}", fxcache_path);
match crate::fxcache::load_fxcache(&fxcache_path) {
Ok(data) => {
info!("fxcache hit: {} bars from {:?}", data.bar_count, fxcache_path);
Some(data)
}
Err(e) => {
warn!("fxcache load failed: {e}");
None
}
}
});
let mut fxcache = if let Some(cached) = fxcache_data {
cached
} else {
// ── Step 3: Fall back to DBN loading + feature extraction ────────
info!("No fxcache found, falling back to DBN loading ...");
let data_path_str = self.dbn_data_dir.to_str().ok_or_else(|| {
MLError::ConfigError("Invalid UTF-8 in data path".to_owned())
})?;
let mut preload_hyperparams = DQNHyperparameters::default();
preload_hyperparams.mbp10_data_dir = self.mbp10_data_dir.clone().unwrap_or_default();
preload_hyperparams.trades_data_dir = self.trades_data_dir.clone().unwrap_or_default();
// Buffer size must be >= batch_size for GPU PER allocation.
// Preload doesn't train — just loads data — but the trainer
// constructor still creates a PER buffer.
preload_hyperparams.buffer_size = preload_hyperparams.batch_size.max(1024);
let mut loader = InternalDQNTrainer::new_with_device(preload_hyperparams, self.device.clone())
.map_err(|e| MLError::TrainingError(format!("Failed to create data loader: {e}")))?;
if let Some(cache_dir) = &self.feature_cache_dir {
loader = loader.with_feature_cache(cache_dir.clone());
}
let preload_handle = self.runtime_handle.as_ref().ok_or_else(|| {
MLError::ConfigError("BUG: runtime_handle is None".to_owned())
})?;
let (train_data, val_data) = preload_handle
.block_on(loader.load_training_data(data_path_str, &self.symbol))
.map_err(|e| MLError::TrainingError(format!("Failed to preload data: {e}")))?;
// Extract OFI if available
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()
});
// Build FxCacheData from the legacy (FeatureVector, Vec<f64>) format.
// FeatureVector = [f64; 42], Vec<f64> targets have variable length.
let total = train_data.len() + val_data.len();
let mut features = Vec::with_capacity(total);
let mut targets = Vec::with_capacity(total);
for (feat, tgt) in train_data.iter().chain(val_data.iter()) {
features.push(*feat);
let mut t = [0.0_f64; 6];
for (i, v) in tgt.iter().enumerate().take(6) {
t[i] = *v;
}
targets.push(t);
}
let has_ofi = ofi_features.is_some();
let ofi: Vec<[f64; OFI_DIM]> = if let Some(ref ofi_arc) = ofi_features {
ofi_arc.iter().cloned().collect()
} else {
vec![[0.0_f64; OFI_DIM]; total]
};
let timestamps = vec![0_i64; total];
self.preloaded_ofi_features = ofi_features;
crate::fxcache::FxCacheData {
timestamps,
features,
targets,
ofi,
cache_key: [0u8; 32],
bar_count: total,
has_ofi,
}
};
// ── Step 4: Apply max_bars truncation from training profile ────────
let profile = crate::training_profile::DqnTrainingProfile::load(&self.training_profile);
let mut hp_for_max_bars = DQNHyperparameters::conservative();
profile.apply_to(&mut hp_for_max_bars);
let max_bars = hp_for_max_bars.max_bars;
if max_bars > 0 && fxcache.bar_count > max_bars {
info!("max_bars={}: truncating preloaded {} → {} bars", max_bars, fxcache.bar_count, max_bars);
fxcache.features.truncate(max_bars);
fxcache.targets.truncate(max_bars);
fxcache.ofi.truncate(max_bars);
fxcache.timestamps.truncate(max_bars);
fxcache.bar_count = max_bars;
}
// ── Step 5: Store and compute train/val split ───────────────────────
let n = fxcache.bar_count;
let train_end = (n * 80) / 100; // 80% train, 20% val
// Extract OFI from fxcache (unconditional — model requires order flow data)
if self.preloaded_ofi_features.is_none() {
self.preloaded_ofi_features = Some(Arc::from(fxcache.ofi.as_slice()));
let ofi_nonzero = fxcache.ofi.iter().filter(|o| o.iter().any(|&v| v != 0.0)).count();
info!("OFI features from fxcache: {} bars x {} dims ({} non-zero)", fxcache.ofi.len(), OFI_DIM, ofi_nonzero);
}
let elapsed = preload_start.elapsed();
info!(
"Data preloaded: {} bars ({} train + {} val) in {:.1}s (cached for all trials)",
n, train_end, n - train_end, elapsed.as_secs_f64()
);
self.preloaded_fxcache = Some(Arc::new(fxcache));
self.preloaded_train_end = train_end;
Ok(())
}
/// Returns true if training data has been preloaded.
pub fn has_preloaded_data(&self) -> bool {
self.preloaded_fxcache.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; OFI_DIM]]>> {
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"))
};
// This fallback loader only fills the 8 raw OFI slots (ofi8). The
// remaining [8..OFI_DIM) slots stay zero — the canonical signal path
// is precompute_features, which fills every slot from full MBP-10
// history. This path is used when precompute has not been run.
load_ofi_features_parallel(&mbp10_dir, trades_dir.as_deref(), |dir| {
collect_dbn_files(dir)
}).map(|ofi8| {
let ofi_wide: Vec<[f64; OFI_DIM]> = ofi8.into_iter().map(|row| {
let mut wide = [0.0_f64; OFI_DIM];
wide[..8].copy_from_slice(&row);
wide
}).collect();
Arc::from(ofi_wide)
})
}
/// 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: &mut 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();
// The gather kernel places live portfolio at [feat_dim..feat_dim+8] and
// multi-timeframe features at [feat_dim+8..feat_dim+24].
// GpuBacktestEvaluator adds PORTFOLIO_AND_MTF_DIM=24 on top of feature_dim
// to reach the full state_dim: (62+24+7)&!7 = 88.
// feature_dim = 62 (42 market + 20 OFI). OFI always enabled.
let market_dim: usize = 42;
let feature_dim: usize = 62; // 42 market + 20 OFI
// OFI feature overlay: preloaded OFI covers ALL bars (train+val), left-aligned.
// Validation bars start at index `train_end` in the global OFI array.
let ofi_offset = self.preloaded_train_end;
let ofi_ref = self.preloaded_ofi_features.as_deref();
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();
// OFI features from preloaded MBP-10 data (unconditional).
let ofi_idx = ofi_offset + i;
let ofi_row = ofi_ref
.and_then(|o| o.get(ofi_idx))
.ok_or_else(|| MLError::TrainingError(format!(
"OFI index {} out of bounds — model requires order flow data", ofi_idx
)))?;
for &v in ofi_row.iter() {
fv_f32.push(v as f32);
}
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: (internal_trainer.hyperparams().tick_size
* internal_trainer.hyperparams().fill_spread_cost_frac) as f32, // points only, no contract_multiplier
initial_capital: self.initial_capital as f32,
contract_multiplier: internal_trainer.hyperparams().contract_multiplier as f32,
margin_pct: internal_trainer.hyperparams().margin_pct as f32,
max_leverage: internal_trainer.hyperparams().max_leverage as f32,
// OFI reorder in gather kernel: produces [market, portfolio, OFI, pad]
// directly, eliminating the Candle narrow+cat closure.
ofi_dim: OFI_DIM,
holding_cost_rate: internal_trainer.hyperparams().holding_cost_rate as f32,
churn_threshold: internal_trainer.hyperparams().churn_threshold_bars as f32,
churn_penalty_scale: internal_trainer.hyperparams().churn_penalty_scale as f32,
bars_per_day: internal_trainer.hyperparams().bars_per_day as f32,
trading_days_per_year: internal_trainer.hyperparams().trading_days_per_year as f32,
..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());
// Use GPU-resident weights from fused CUDA trainer (NOT Candle VarMap).
// The fused trainer's online_dueling/online_branching are updated by Adam
// each step. The Candle VarMap is NOT synced during fused training.
let is_branching = agent_guard.is_using_branching();
let network_dims = agent_guard.network_dims();
let (b0, b1, b2, b3) = agent_guard.branch_sizes();
drop(agent_guard); // Release read lock before accessing internal_trainer
internal_trainer.wait_restore_on_stream(evaluator.stream())?;
// Raw pointer split: extract weight pointers + config, then take &mut for QValueProvider.
let trainer_ptr = internal_trainer as *mut InternalDQNTrainer;
let (online_weights_ptr, branching_weights_ptr, dqn_cfg) = unsafe {
let t = &*trainer_ptr;
let (dueling, branching) = t.fused_online_weights()
.ok_or_else(|| MLError::ModelError(
"fused_ctx not available — cannot extract GPU weights for backtest".to_owned()
))?;
let hp = t.hyperparams();
#[allow(clippy::cast_possible_truncation)]
let 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 },
branch_3_size: if is_branching { b3 } else { b0 },
v_min: hp.v_min as f32,
v_max: hp.v_max as f32,
};
(dueling as *const _, branching as *const _, cfg)
};
let q_provider = unsafe { &mut *trainer_ptr }.fused_ctx_mut()
.ok_or_else(|| MLError::ConfigError("fused_ctx required for GPU evaluation".to_owned()))?;
let metrics = unsafe {
evaluator.evaluate_dqn_graphed(
&*online_weights_ptr,
Some(&*branching_weights_ptr),
&dqn_cfg,
Some(q_provider),
)
}?;
// 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_calmar = metrics.iter().map(|m| m.calmar 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).clamp(-100_000.0, 100_000.0),
max_drawdown_pct: (worst_dd * 100.0).clamp(0.0, 100.0),
sortino_ratio: mean_sortino,
calmar_ratio: mean_calmar,
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
/// 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; // ok: zero actions = zero entropy (not a stub)
}
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 (3 buckets: BUY/SELL/HOLD)
// 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 {
/// Minimum trades for non-degenerate trial (below this → full penalty).
const MIN_TRADES_DEGENERATE: usize = 10;
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 < MIN_TRADES_DEGENERATE {
5.0 + 5.0 * (1.0 - total_trades as f64 / MIN_TRADES_DEGENERATE as f64) // 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
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
);
info!("Training DQN with parameters (15D family search space):");
info!(" Gamma: {:.3}", params.gamma);
info!(" IQN lambda: {:.3}", params.iqn_lambda);
info!(" C51 warmup epochs: {:.1}", params.c51_warmup_epochs);
info!(" C51 alpha max: {:.2}", params.c51_alpha_max);
info!(" Learning intensity: {:.3}", params.learning_intensity);
info!(" Exploration intensity: {:.3}", params.exploration_intensity);
info!(" Replay intensity: {:.3}", params.replay_intensity);
info!(" Architecture intensity: {:.3}", params.architecture_intensity);
info!(" Risk intensity: {:.3}", params.risk_intensity);
info!(" Adversarial intensity: {:.3}", params.adversarial_intensity);
info!(" Regularization intensity: {:.3}", params.regularization_intensity);
info!(" Augmentation intensity: {:.3}", params.augmentation_intensity);
info!(" Loss shaping intensity: {:.3}", params.loss_shaping_intensity);
info!(" Ensemble intensity: {:.3}", params.ensemble_intensity);
info!(" Causal intensity: {:.3}", params.causal_intensity);
// 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: load the TOML profile FIRST so the estimate uses the
// correct hidden_dim and batch_size (smoketest=64, production=256).
let budget = HardwareBudget::detect();
let base_hp = {
let mut hp = DQNHyperparameters::conservative();
let profile = crate::training_profile::DqnTrainingProfile::load(&self.training_profile);
profile.apply_to(&mut hp);
hp
};
if budget.gpu_memory_mb > 0 {
let scaled_hidden = ((base_hp.hidden_dim_base.unwrap_or(256) as f64 * params.architecture_intensity) as usize).max(64);
let scaled_atoms = ((base_hp.num_atoms as f64 * params.architecture_intensity) as usize).max(11);
let scaled_buffer = (base_hp.buffer_size as f64 * params.replay_intensity).max(1000.0) as usize;
let vram_buffer = if budget.gpu_memory_mb <= 8192 { 0 } else { scaled_buffer.min(self.buffer_size_max) };
let aligned_state_dim: usize = 96; // (92+7)&!7 = 96, OFI always on
let vram_check = budget.trial_fits_vram(
scaled_hidden,
base_hp.batch_size,
aligned_state_dim,
9, // DQN actions (branching: 9 exposure head)
scaled_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())
};
// 15D family-based approach: load base hyperparams from TOML profile defaults,
// then override only the 4 breakouts and 11 intensities from the PSO vector.
// All individual params (learning_rate, buffer_size, etc.) come from TOML.
// Reuse base_hp from the VRAM gate (same profile, already loaded).
let mut hyperparams = base_hp;
// Override hyperopt-specific settings that differ from production defaults
hyperparams.epochs = self.epochs;
hyperparams.checkpoint_frequency = self.epochs + 1; // Best-only via callback
hyperparams.early_stopping_enabled = false; // Disable for hyperopt
hyperparams.min_epochs_before_stopping = self.epochs;
hyperparams.warmup_steps = 0; // CRITICAL: batch training path doesn't increment total_steps
// Preprocessing is always active
hyperparams.preprocessing_window = self.preprocessing_window;
hyperparams.preprocessing_clip_sigma = self.preprocessing_clip_sigma as f32;
hyperparams.target_update_mode = self.target_update_mode.clone();
hyperparams.target_update_frequency = self.target_update_frequency;
#[allow(clippy::cast_possible_truncation)]
{ hyperparams.initial_capital = self.initial_capital as f32; }
// Data paths: DQNTrainer fields are source of truth (not TOML profile).
// TOML provides training hyperparameters; the adapter knows the runtime
// environment (local test_data vs production PVC paths).
hyperparams.data_source = self.data_source.clone();
hyperparams.mbp10_data_dir = self.mbp10_data_dir.clone().unwrap_or_default();
hyperparams.trades_data_dir = self.trades_data_dir.clone().unwrap_or_default();
// AutoReplaySizer VRAM fraction
hyperparams.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) as f32
};
// Clamp buffer to [1024, max] — below 1024 the GPU PER segment tree fails.
hyperparams.buffer_size = hyperparams.buffer_size.max(1024).min(self.buffer_size_max);
// === 4 breakout parameters from PSO ===
// PSO search space is f64; cast to f32 at the hp boundary.
hyperparams.gamma = params.gamma as f32;
// Recompute v_min/v_max (reward_scale-derived, gamma-independent)
hyperparams.v_min = hyperparams.computed_v_min();
hyperparams.v_max = hyperparams.computed_v_max();
hyperparams.iqn_lambda = params.iqn_lambda as f32;
hyperparams.c51_warmup_epochs = params.c51_warmup_epochs.round() as usize;
hyperparams.c51_alpha_max = params.c51_alpha_max as f32;
// === 5 core family intensities from PSO ===
hyperparams.learning_intensity = params.learning_intensity;
hyperparams.exploration_intensity = params.exploration_intensity;
hyperparams.replay_intensity = params.replay_intensity;
hyperparams.architecture_intensity = params.architecture_intensity;
hyperparams.risk_intensity = params.risk_intensity;
// === 6 generalization family intensities from PSO ===
hyperparams.adversarial_intensity = params.adversarial_intensity;
hyperparams.regularization_intensity = params.regularization_intensity;
hyperparams.augmentation_intensity = params.augmentation_intensity;
hyperparams.loss_shaping_intensity = params.loss_shaping_intensity;
hyperparams.ensemble_intensity = params.ensemble_intensity;
hyperparams.causal_intensity = params.causal_intensity;
// Apply ALL family intensity scaling AFTER construction, BEFORE training.
// This multiplies per-parameter TOML defaults by their family scalar (1.0 = no change).
hyperparams.apply_family_scaling();
// Log reward params after scaling (loss_shaping_intensity has been applied above).
info!(" cea_weight: {:.3} (TOML base, scaled by loss_shaping_intensity={:.3})", hyperparams.cea_weight, params.loss_shaping_intensity);
// Derive max_position_absolute from max_leverage using median close price.
// This replaces the old hardcoded contract-count cap with a leverage-based
// position limit: max_pos = floor(capital * leverage / (price * multiplier)).
if let Some(ref fxcache) = self.preloaded_fxcache {
let n = fxcache.targets.len();
if n > 0 {
let mut closes: Vec<f64> = fxcache.targets.iter().map(|t| t[0]).collect();
closes.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median_close = closes[n / 2];
let computed = hyperparams.compute_max_position(median_close);
tracing::info!(
max_leverage = hyperparams.max_leverage,
median_close,
initial_capital = hyperparams.initial_capital,
contract_multiplier = hyperparams.contract_multiplier,
computed_max_position = computed,
old_max_position = hyperparams.max_position_absolute,
"Leverage-based position cap"
);
hyperparams.max_position_absolute = computed;
}
}
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.
// Reuse the same CUDA context across trials — fork a new stream only.
// Creating a fresh MlDevice per trial orphans cudaFreeAsync memory in the
// old stream's pool (new stream can't reclaim it → cumulative 2GB/trial leak).
// Forking reuses the context's async memory pool while isolating stream state.
let trial_dev = match &trial_device {
MlDevice::Cuda { context, stream } => {
let forked = stream.fork()
.map_err(|e| MLError::TrainingError(format!("Fork CUDA stream for trial: {e}")))?;
MlDevice::Cuda {
context: context.clone(),
stream: forked,
}
}
other => other.clone(),
};
let mut internal_trainer = InternalDQNTrainer::new_with_device(hyperparams.clone(), trial_dev)
.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)
let ofi = self.preloaded_ofi_features.as_ref().ok_or_else(|| {
MLError::TrainingError("OFI features missing — hyperopt requires preloaded OFI from fxcache".into())
})?;
internal_trainer.ofi_features = Some(Arc::clone(ofi));
info!("Injected preloaded OFI features: {} bars x 20 dims (Arc-shared)", ofi.len());
// Cache Arc reference for catch_unwind (Arc is UnwindSafe).
let fxcache = self.preloaded_fxcache.clone();
let train_end = self.preloaded_train_end;
// Wrap training in catch_unwind for CUDA OOM handling (NOT trainer creation)
let training_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let handle = self.runtime_handle.as_ref().ok_or_else(|| {
MLError::ConfigError("BUG: runtime_handle is None".to_owned())
})?;
let training_metrics = if let Some(ref fxcache) = fxcache {
let n = fxcache.bar_count;
let train_feat = &fxcache.features[..train_end];
let val_feat = &fxcache.features[train_end..n];
let train_targets = &fxcache.targets[..train_end];
let val_targets = &fxcache.targets[train_end..n];
// fxcache features are pre-normalized (z-score at precompute time)
info!("Training DQN with fxcache ({} train, {} val bars)",
train_end, n - train_end);
// Upload pre-normalized dataset to GPU, set ranges, train
handle.block_on(async {
internal_trainer.init_from_fxcache(
&fxcache.features, &fxcache.targets, &fxcache.ofi,
).await.map_err(|e| MLError::TrainingError(format!(
"init_from_fxcache failed: {e}"
)))?;
internal_trainer.set_training_range(0, train_end, train_end, n);
internal_trainer.set_val_data_from_slices(val_feat, val_targets, train_end);
internal_trainer.train_fold_from_slices(
train_feat, train_targets, checkpoint_callback,
).await.map_err(|e| MLError::TrainingError(format!(
"DQN training failed: {e}"
)))
})?
} else {
info!("Training DQN with DBN directory: {}", data_path_str);
handle.block_on(internal_trainer.train(data_path_str, &self.symbol, 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,
});
},
};
// 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();
// Restore best-epoch GPU weights via async DtoD copy.
// No Candle VarMap, no safetensors — pure GPU buffer restore.
if let Err(e) = internal_trainer.restore_best_gpu_params() {
tracing::warn!("Failed to restore best GPU params (falling back to last epoch): {e}");
} else {
info!("Restored best GPU params (epoch {}) for walk-forward evaluation — pure DtoD", best_epoch);
}
}
// Run backtest if enabled
let backtest_metrics = if self.enable_backtest {
tracing::info!(
val_data_len = internal_trainer.get_val_data().len(),
"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(
&mut internal_trainer,
&val_close_prices,
window_size,
stride,
&device,
f64::from(hyperparams.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: {e}"
)));
}
}
} else {
None
}
};
if let Some(metrics) = gpu_result {
Some(metrics)
} else {
return Err(MLError::TrainingError(
"GPU backtest returned no metrics — evaluation is mandatory".to_owned()
));
}
}
}
} else {
None
};
let actual_best_epoch = internal_trainer.get_best_epoch();
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={:.1}%, max_dd={:.2}%, \
total_return={:.2}%, trades={}, sortino={:.4}, calmar={:.4}, omega={:.4}",
current_trial,
backtest.sharpe_ratio,
backtest.win_rate * 100.0,
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
// Synchronize the trial device's stream to ensure all async frees complete.
// With forked streams sharing the same context, free_async returns memory
// to the context's pool — available for the next trial's allocations.
// If sync fails (CUDA_ERROR_ILLEGAL_ADDRESS from corrupted trial), drain
// the error and continue — the next trial creates a fresh stream.
if let MlDevice::Cuda { stream, .. } = &trial_device {
if let Err(e) = stream.synchronize() {
tracing::error!("CUDA sync between trials failed (draining): {e}");
// Drain context-level error to prevent cascade to next trial
let _ = stream.context().check_err();
}
}
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 * 100.0).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 = actual_best_epoch;
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,
(9.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.
const MIN_TRADES_DEGENERATE: usize = 10;
if backtest.total_trades < MIN_TRADES_DEGENERATE {
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 ≈ 0.25 (elevated)
// CVaR = -0.010 → penalty ≈ 0.75 (max, saturates at ~-0.033)
let cvar_threshold = 0.05 / (common::thresholds::time::BARS_PER_DAY).sqrt();
// Softer ramp: cap at 3.0 instead of 10.0 to prevent CVaR
// from dominating the objective for under-trained models.
// Linear: 0 at threshold, 3.0 at threshold + 0.03 (~3% per-bar tail loss).
let cvar_penalty = ((-backtest.cvar_95 - cvar_threshold).max(0.0) * 100.0).min(3.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: 9/9 unique → 0.0, 1/9 → 0.8 penalty.
// Soft signal for TPE, never dominates real backtest metrics.
let num_exposure_actions = 9.0_f64; // 9 exposure levels (S100..L100)
let mid_diversity_penalty = if (backtest.unique_actions as f64) < num_exposure_actions {
let ratio = backtest.unique_actions as f64 / num_exposure_actions;
0.8 * (1.0 - ratio)
} 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;
debug!(
"OBJECTIVE: {:.4} = base {:.4} + trade_penalty {:.4} + diversity {:.4} | Sharpe={:.2} Sortino={:.2} Calmar={:.2} Omega={:.2} CVaR={:.4} trades={} unique={}/9",
objective, base_objective, trade_penalty, mid_diversity_penalty,
backtest.sharpe_ratio, backtest.sortino_ratio, backtest.calmar_ratio, backtest.omega_ratio,
backtest.cvar_95, backtest.total_trades, backtest.unique_actions
);
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 {
// Backtest metrics MUST be present — GPU evaluator is mandatory.
// If we reach here, a bug prevented the evaluator from running.
// Return maximum penalty so the optimizer avoids this configuration
// and the error is visible in the trial log.
eprintln!("BUG: extract_objective called without backtest_metrics — returning 1e6 penalty");
1e6
};
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 15D family-based search space
let params = DQNParams {
gamma: 0.97,
iqn_lambda: 0.5,
c51_warmup_epochs: 7.0,
c51_alpha_max: 0.6,
learning_intensity: 1.2,
exploration_intensity: 0.8,
replay_intensity: 1.5,
architecture_intensity: 0.9,
risk_intensity: 1.1,
adversarial_intensity: 0.7,
regularization_intensity: 1.3,
augmentation_intensity: 0.6,
loss_shaping_intensity: 1.4,
ensemble_intensity: 0.5,
causal_intensity: 1.8,
};
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 15, "to_continuous must return 15D vector");
let recovered = DQNParams::from_continuous(&continuous).unwrap();
// All 15 parameters must roundtrip exactly
assert!((recovered.gamma - params.gamma).abs() < 1e-6);
assert!((recovered.iqn_lambda - params.iqn_lambda).abs() < 1e-6);
assert!((recovered.c51_warmup_epochs - params.c51_warmup_epochs).abs() < 1e-6);
assert!((recovered.c51_alpha_max - params.c51_alpha_max).abs() < 1e-6);
assert!((recovered.learning_intensity - params.learning_intensity).abs() < 1e-6);
assert!((recovered.exploration_intensity - params.exploration_intensity).abs() < 1e-6);
assert!((recovered.replay_intensity - params.replay_intensity).abs() < 1e-6);
assert!((recovered.architecture_intensity - params.architecture_intensity).abs() < 1e-6);
assert!((recovered.risk_intensity - params.risk_intensity).abs() < 1e-6);
assert!((recovered.adversarial_intensity - params.adversarial_intensity).abs() < 1e-6);
assert!((recovered.regularization_intensity - params.regularization_intensity).abs() < 1e-6);
assert!((recovered.augmentation_intensity - params.augmentation_intensity).abs() < 1e-6);
assert!((recovered.loss_shaping_intensity - params.loss_shaping_intensity).abs() < 1e-6);
assert!((recovered.ensemble_intensity - params.ensemble_intensity).abs() < 1e-6);
assert!((recovered.causal_intensity - params.causal_intensity).abs() < 1e-6);
}
#[test]
fn test_dqn_params_bounds() {
let bounds = DQNParams::continuous_bounds();
assert_eq!(bounds.len(), 15); // 15D family-based search space
// Check all bounds are valid ranges (lower < upper)
for (i, (lo, hi)) in bounds.iter().enumerate() {
assert!(lo < hi, "Bound {i}: lower ({lo}) must be < upper ({hi})");
}
// Gamma bounds
assert!((bounds[0].0 - 0.95).abs() < 1e-6);
assert!((bounds[0].1 - 0.999).abs() < 1e-6);
// IQN lambda bounds
assert!((bounds[1].0 - 0.0).abs() < 1e-6);
assert!((bounds[1].1 - 1.0).abs() < 1e-6);
// C51 warmup bounds
assert!((bounds[2].0 - 3.0).abs() < 1e-6);
assert!((bounds[2].1 - 15.0).abs() < 1e-6);
// C51 alpha max bounds
assert!((bounds[3].0 - 0.3).abs() < 1e-6);
assert!((bounds[3].1 - 0.9).abs() < 1e-6);
// All intensity bounds should be [0.0, 2.0]
for i in 4..15 {
assert!((bounds[i].0 - 0.0).abs() < 1e-6, "Intensity {i} lower bound should be 0.0");
assert!((bounds[i].1 - 2.0).abs() < 1e-6, "Intensity {i} upper bound should be 2.0");
}
}
#[test]
fn test_param_names() {
let names = DQNParams::param_names();
assert_eq!(names.len(), 15); // 15D family-based search space
assert_eq!(names[0], "gamma");
assert_eq!(names[1], "iqn_lambda");
assert_eq!(names[2], "c51_warmup_epochs");
assert_eq!(names[3], "c51_alpha_max");
assert_eq!(names[4], "learning_intensity");
assert_eq!(names[5], "exploration_intensity");
assert_eq!(names[6], "replay_intensity");
assert_eq!(names[7], "architecture_intensity");
assert_eq!(names[8], "risk_intensity");
assert_eq!(names[9], "adversarial_intensity");
assert_eq!(names[10], "regularization_intensity");
assert_eq!(names[11], "augmentation_intensity");
assert_eq!(names[12], "loss_shaping_intensity");
assert_eq!(names[13], "ensemble_intensity");
assert_eq!(names[14], "causal_intensity");
}
#[test]
fn test_15d_from_continuous_basic() {
// 15D search space
let continuous = vec![
0.97, // 0: gamma
0.25, // 1: iqn_lambda
5.0, // 2: c51_warmup_epochs
0.5, // 3: c51_alpha_max
1.0, // 4: learning_intensity
1.0, // 5: exploration_intensity
1.0, // 6: replay_intensity
1.0, // 7: architecture_intensity
1.0, // 8: risk_intensity
1.0, // 9: adversarial_intensity
1.0, // 10: regularization_intensity
1.0, // 11: augmentation_intensity
1.0, // 12: loss_shaping_intensity
1.0, // 13: ensemble_intensity
1.0, // 14: causal_intensity
];
let params = DQNParams::from_continuous(&continuous).unwrap();
assert!((params.gamma - 0.97).abs() < 1e-6);
assert!((params.iqn_lambda - 0.25).abs() < 1e-6);
assert!((params.c51_warmup_epochs - 5.0).abs() < 1e-6);
assert!((params.c51_alpha_max - 0.5).abs() < 1e-6);
assert!((params.learning_intensity - 1.0).abs() < 1e-6);
assert!((params.adversarial_intensity - 1.0).abs() < 1e-6);
// Test with min-length vector to verify error handling
let short = vec![0.97; 14]; // Only 14 elements
assert!(DQNParams::from_continuous(&short).is_err());
// Test clamping: values outside bounds get clamped
let extreme = vec![
0.5, // gamma: clamped to 0.95
5.0, // iqn_lambda: clamped to 1.0
1.0, // c51_warmup_epochs: clamped to 3.0
2.0, // c51_alpha_max: clamped to 0.9
3.0, 3.0, 3.0, 3.0, 3.0, // intensities: clamped to 2.0
3.0, 3.0, 3.0, 3.0, 3.0, 3.0,
];
let clamped = DQNParams::from_continuous(&extreme).unwrap();
assert!((clamped.gamma - 0.95).abs() < 1e-6, "gamma should be clamped to 0.95");
assert!((clamped.iqn_lambda - 1.0).abs() < 1e-6, "iqn_lambda should be clamped to 1.0");
assert!((clamped.c51_warmup_epochs - 3.0).abs() < 1e-6, "c51_warmup should be clamped to 3.0");
assert!((clamped.c51_alpha_max - 0.9).abs() < 1e-6, "c51_alpha_max should be clamped to 0.9");
assert!((clamped.learning_intensity - 2.0).abs() < 1e-6, "intensity should be clamped to 2.0");
// Max intensity values
let continuous_max = vec![
0.999, 1.0, 15.0, 0.9, // breakouts at max
2.0, 2.0, 2.0, 2.0, 2.0, // core families at max
2.0, 2.0, 2.0, 2.0, 2.0, 2.0, // gen families at max
];
let params_max = DQNParams::from_continuous(&continuous_max).unwrap();
assert!((params_max.gamma - 0.999).abs() < 1e-6);
assert!((params_max.learning_intensity - 2.0).abs() < 1e-6);
}
#[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());
}
#[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 extract_objective correctly uses backtest_metrics for the multi-objective formula.
// The optimizer MINIMIZES the objective, so:
// - Good Sharpe → negative objective (good)
// - Bad Sharpe → positive objective (bad)
//
// Formula (when backtest_metrics is Some and total_trades >= 10):
// composite = 0.40*tanh(sharpe/5) + 0.25*tanh(sortino/8) + 0.20*tanh(calmar/5) + 0.15*tanh(omega/2)
// cvar_thresh = 0.05 / sqrt(BARS_PER_DAY) (~0.00253 for 1-min bars)
// cvar_penalty = ((-cvar_95 - cvar_thresh).max(0) * 100).min(3.0)
// hft_activity = calculate_hft_activity_score_wave10(buy%, sell%, hold%)
// diversity = 0.8 * (1 - unique_actions/9) if unique_actions < 9, else 0
// base_obj = -0.60*composite + cvar_penalty - 0.05*hft_activity + 0.15*stability
// objective = base_obj + trade_penalty + diversity
//
// gradient_norm=2.0 (<50) and q_value_std=1.5 (<15) → stability_penalty_raw=0.0
// Scenario 1: Good Sharpe (2.0) with balanced action distribution (30/30/40).
// Approx: composite≈0.489, cvar_penalty=0 (cvar=-0.002 is above threshold),
// hft_activity=1.0, diversity≈0.178 (7/9 unique), trade_penalty=0
// → objective ≈ -0.60*0.489 + 0 - 0.05*1.0 + 0.178 ≈ -0.165 (negative = good)
let metrics_positive = 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.3,
sell_action_pct: 0.3,
hold_action_pct: 0.4,
gradient_norm: 2.0,
q_value_std: 1.5,
backtest_metrics: Some(BacktestMetrics {
sharpe_ratio: 2.0,
sortino_ratio: 3.0,
calmar_ratio: 5.0,
omega_ratio: 1.5,
win_rate: 0.55,
max_drawdown_pct: 15.0,
total_return_pct: 20.0,
total_trades: 500,
var_95: -0.0015,
cvar_95: -0.002,
beta: 0.0,
alpha: 0.0,
information_ratio: 0.0,
unique_actions: 7,
buy_action_pct: 0.3,
sell_action_pct: 0.3,
hold_action_pct: 0.4,
}),
};
let objective_positive = DQNTrainer::extract_objective(&metrics_positive);
// Good Sharpe → composite is positive → -0.60*composite pulls objective negative
assert!(
objective_positive < 0.0,
"Scenario 1 (Sharpe=2.0): good composite should give negative objective (optimizer minimizes), got: {}",
objective_positive
);
// Scenario 2: Bad Sharpe (-1.0) with balanced action distribution.
// Approx: composite≈-0.114, cvar_penalty≈0.747 (cvar=-0.01 below threshold),
// hft_activity=1.0, diversity≈0.356 (5/9 unique), trade_penalty=0
// → objective ≈ -0.60*(-0.114) + 0.747 - 0.05*1.0 + 0.356 ≈ 1.12 (positive = bad)
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,
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: Some(BacktestMetrics {
sharpe_ratio: -1.0,
sortino_ratio: -0.5,
calmar_ratio: -2.0,
omega_ratio: 0.8,
win_rate: 0.35,
max_drawdown_pct: 40.0,
total_return_pct: -15.0,
total_trades: 300,
var_95: -0.008,
cvar_95: -0.01,
beta: 0.0,
alpha: 0.0,
information_ratio: 0.0,
unique_actions: 5,
buy_action_pct: 0.3,
sell_action_pct: 0.3,
hold_action_pct: 0.4,
}),
};
let objective_negative = DQNTrainer::extract_objective(&metrics_negative);
// Bad Sharpe → composite is negative → -0.60*composite pushes objective positive
// plus CVaR penalty for deep tail loss
assert!(
objective_negative > 0.0,
"Scenario 2 (Sharpe=-1.0): bad composite + CVaR penalty should give positive objective, got: {}",
objective_negative
);
// Scenario 3: Near-zero Sharpe (0.1) — barely active model.
// Approx: composite≈0.104, cvar_penalty≈0.047 (cvar=-0.003 just below threshold),
// hft_activity=1.0, diversity≈0.267 (6/9 unique), trade_penalty=0
// → objective ≈ -0.60*0.104 + 0.047 - 0.05*1.0 + 0.267 ≈ 0.20
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: Some(BacktestMetrics {
sharpe_ratio: 0.1,
sortino_ratio: 0.2,
calmar_ratio: 0.5,
omega_ratio: 1.0,
win_rate: 0.50,
max_drawdown_pct: 10.0,
total_return_pct: 1.0,
total_trades: 200,
var_95: -0.002,
cvar_95: -0.003,
beta: 0.0,
alpha: 0.0,
information_ratio: 0.0,
unique_actions: 6,
buy_action_pct: 0.3,
sell_action_pct: 0.3,
hold_action_pct: 0.4,
}),
};
let objective_zero = DQNTrainer::extract_objective(&metrics_zero);
// Near-zero Sharpe → objective near zero (small positive due to diversity penalty)
assert!(
objective_zero > -0.5 && objective_zero < 1.0,
"Scenario 3 (Sharpe=0.1): near-zero Sharpe should give near-zero objective, got: {}",
objective_zero
);
// Scenario 4: Excellent Sharpe (3.0) — best model configuration.
// Using same CVaR as Scenario 1 (-0.002, above threshold) so CVaR penalty=0 for both.
// Approx: composite≈0.629, cvar_penalty=0 (cvar=-0.002 above threshold),
// hft_activity=1.0, diversity≈0.089 (8/9 unique), trade_penalty=0
// → base_obj ≈ -0.60*0.629 + 0 - 0.05*1.0 ≈ -0.427
// → objective ≈ -0.427 + 0 + 0.089 ≈ -0.338 (more negative than Scenario 1's ~-0.166)
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,
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: Some(BacktestMetrics {
sharpe_ratio: 3.0,
sortino_ratio: 4.0,
calmar_ratio: 8.0,
omega_ratio: 2.0,
win_rate: 0.60,
max_drawdown_pct: 20.0,
total_return_pct: 30.0,
total_trades: 400,
var_95: -0.0015,
cvar_95: -0.002, // Same as Scenario 1 so CVaR penalty=0 for fair comparison
beta: 0.0,
alpha: 0.0,
information_ratio: 0.0,
unique_actions: 8,
buy_action_pct: 0.3,
sell_action_pct: 0.3,
hold_action_pct: 0.4,
}),
};
let obj_high = DQNTrainer::extract_objective(&high_reward);
// Very good Sharpe (3.0) → composite is large positive → objective should be negative
assert!(
obj_high < 0.0,
"Scenario 4 (Sharpe=3.0): excellent composite should give negative objective, got: {}",
obj_high
);
// With same CVaR, Sharpe=3.0 should give lower (better) objective than Sharpe=2.0
assert!(
obj_high < objective_positive,
"Sharpe=3.0 should give lower (better) objective than Sharpe=2.0 (same CVaR): {} < {}",
obj_high,
objective_positive
);
// Scenario 5: Low trading activity (5% BUY, 5% SELL, 90% HOLD) with low diversity.
// unique_actions=2 → diversity_penalty = 0.8*(7/9) ≈ 0.622
// hft_activity = -5*(15-5)/15 = -3.333 (both < 15% threshold) → penalty
// Approx: composite≈0.222, cvar_penalty≈0.147, hft≈-3.333, diversity≈0.622, trade_penalty=0
// → base_obj ≈ -0.60*0.222 + 0.147 - 0.05*(-3.333) ≈ -0.133+0.147+0.167 = 0.181
// → objective ≈ 0.181 + 0 + 0.622 ≈ 0.803
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,
sell_action_pct: 0.05,
hold_action_pct: 0.90,
gradient_norm: 2.0,
q_value_std: 1.5,
backtest_metrics: Some(BacktestMetrics {
sharpe_ratio: 0.5,
sortino_ratio: 1.0,
calmar_ratio: 2.0,
omega_ratio: 1.1,
win_rate: 0.45,
max_drawdown_pct: 25.0,
total_return_pct: 5.0,
total_trades: 50,
var_95: -0.003,
cvar_95: -0.004,
beta: 0.0,
alpha: 0.0,
information_ratio: 0.0,
unique_actions: 2,
buy_action_pct: 0.05,
sell_action_pct: 0.05,
hold_action_pct: 0.90,
}),
};
let obj_low_activity = DQNTrainer::extract_objective(&low_activity);
// Low activity + low diversity should produce a worse (higher) objective than Scenario 1
assert!(
obj_low_activity > objective_positive,
"Scenario 5 (low activity + unique_actions=2) should have higher objective than Scenario 1: {} > {}",
obj_low_activity,
objective_positive
);
// Diversity penalty is visible: unique_actions=2 → penalty ≈ 0.622
assert!(
obj_low_activity > 0.0,
"Scenario 5: low activity + diversity penalty should give positive objective, got: {}",
obj_low_activity
);
}
#[test]
fn test_15d_defaults() {
let params = DQNParams::default();
assert!((params.gamma - 0.99).abs() < 1e-6, "Default gamma should be 0.99");
assert!((params.iqn_lambda - 0.25).abs() < 1e-6, "Default iqn_lambda should be 0.25");
assert!((params.c51_warmup_epochs - 5.0).abs() < 1e-6, "Default c51_warmup should be 5.0");
assert!((params.c51_alpha_max - 0.5).abs() < 1e-6, "Default c51_alpha_max should be 0.5");
assert!((params.learning_intensity - 1.0).abs() < 1e-6, "Default learning_intensity should be 1.0");
assert!((params.adversarial_intensity - 1.0).abs() < 1e-6, "Default adversarial_intensity should be 1.0");
}
#[test]
fn test_15d_default_roundtrip() {
let params = DQNParams::default();
let continuous = params.to_continuous();
assert_eq!(continuous.len(), 15, "Should have 15 continuous dimensions");
let roundtrip = DQNParams::from_continuous(&continuous).unwrap();
assert!((roundtrip.gamma - params.gamma).abs() < 1e-6);
assert!((roundtrip.iqn_lambda - params.iqn_lambda).abs() < 1e-6);
assert!((roundtrip.c51_warmup_epochs - params.c51_warmup_epochs).abs() < 1e-6);
assert!((roundtrip.c51_alpha_max - params.c51_alpha_max).abs() < 1e-6);
assert!((roundtrip.learning_intensity - params.learning_intensity).abs() < 1e-6);
}
#[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_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 3-bucket ~0: {}", penalty_uniform);
// Monotonic: uniform > mid > low > zero (less negative = better)
assert!(penalty_uniform > penalty_mid, "uniform > mid: {} > {}", penalty_uniform, penalty_mid);
assert!(penalty_mid > penalty_low, "mid > low: {} > {}", penalty_mid, penalty_low);
assert!(penalty_low > penalty_zero, "low > zero: {} > {}", penalty_low, penalty_zero);
}
#[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_15d_midpoint_from_continuous() {
// Verify from_continuous works with midpoint of all bounds
let bounds = DQNParams::continuous_bounds();
let dim = bounds.len();
assert_eq!(dim, 15, "Bounds should be 15D");
let mut vec = vec![0.0_f64; dim];
for i in 0..dim {
vec[i] = (bounds[i].0 + bounds[i].1) / 2.0;
}
let params = DQNParams::from_continuous(&vec).unwrap();
// Gamma midpoint should be ~0.9745
assert!(params.gamma > 0.95 && params.gamma < 0.999);
// All intensities should be ~1.0 (midpoint of [0.0, 2.0])
assert!((params.learning_intensity - 1.0).abs() < 1e-6);
assert!((params.exploration_intensity - 1.0).abs() < 1e-6);
assert!((params.replay_intensity - 1.0).abs() < 1e-6);
assert!((params.architecture_intensity - 1.0).abs() < 1e-6);
assert!((params.risk_intensity - 1.0).abs() < 1e-6);
}
#[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: 0.55,
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");
}
}