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>
3660 lines
159 KiB
Rust
3660 lines
159 KiB
Rust
#![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");
|
||
}
|
||
}
|