diff --git a/ml/src/hyperopt/adapters/dqn.rs b/ml/src/hyperopt/adapters/dqn.rs index e8a090f50..7ea99c765 100644 --- a/ml/src/hyperopt/adapters/dqn.rs +++ b/ml/src/hyperopt/adapters/dqn.rs @@ -57,6 +57,24 @@ use crate::hyperopt::traits::{HyperparameterOptimizable, ParameterSpace}; use crate::trainers::dqn::{DQNHyperparameters, DQNTrainer as InternalDQNTrainer}; use crate::MLError; +/// Hyperopt objective mode for two-phase optimization. +/// +/// In two-phase optimization, Phase A uses `EpisodeReward` for fast convergence, +/// then Phase B switches to `Sharpe` for financial quality refinement. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObjectiveMode { + /// Phase A: Optimize episode reward (fast convergence signal) + EpisodeReward, + /// Phase B: Optimize Sharpe ratio (financial quality) + Sharpe, +} + +impl Default for ObjectiveMode { + fn default() -> Self { + ObjectiveMode::Sharpe + } +} + /// Backtest metrics from EvaluationEngine /// /// Tracks comprehensive trading performance metrics including Sharpe ratio, @@ -900,6 +918,8 @@ pub struct DQNTrainer { /// Optional feature cache directory feature_cache_dir: Option, + /// Objective mode for two-phase optimization + objective_mode: ObjectiveMode, } /// Recursively collect all .dbn files from a directory and its subdirectories. @@ -1022,6 +1042,7 @@ impl DQNTrainer { enable_backtest: true, // Wave 8: Backtest integration operational - enabled by default best_trial: None, // No best trial yet feature_cache_dir: None, // No cache by default + objective_mode: ObjectiveMode::default(), // Default: Sharpe ratio }) } @@ -1095,6 +1116,16 @@ impl DQNTrainer { self } + /// 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. @@ -3734,4 +3765,18 @@ mod tests { assert_eq!(roundtrip.num_quantiles, params.num_quantiles); assert!((roundtrip.qr_kappa - params.qr_kappa).abs() < 0.01); } + + #[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); + } } diff --git a/ml/src/hyperopt/mod.rs b/ml/src/hyperopt/mod.rs index a1095f8d8..cca499085 100644 --- a/ml/src/hyperopt/mod.rs +++ b/ml/src/hyperopt/mod.rs @@ -55,7 +55,7 @@ mod tests_argmin; // New argmin tests // Re-exports for convenience pub use observer::TrialBudgetObserver; -pub use optimizer::{ArgminOptimizer, ArgminOptimizerBuilder}; +pub use optimizer::{ArgminOptimizer, ArgminOptimizerBuilder, TwoPhaseObjective}; pub use optimizer::{EgoboxOptimizer, EgoboxOptimizerBuilder}; // Backward compatibility pub use traits::{HyperparameterOptimizable, OptimizationResult, ParameterSpace, TrialResult}; diff --git a/ml/src/hyperopt/optimizer.rs b/ml/src/hyperopt/optimizer.rs index a5dbf90c4..e7a929ca2 100644 --- a/ml/src/hyperopt/optimizer.rs +++ b/ml/src/hyperopt/optimizer.rs @@ -52,6 +52,7 @@ use std::time::Instant; use tracing::{info, warn}; use super::traits::{HyperparameterOptimizable, OptimizationResult, ParameterSpace, TrialResult}; +use crate::hyperopt::adapters::dqn::ObjectiveMode; use crate::MLError; /// Bayesian optimizer using argmin library @@ -485,6 +486,77 @@ impl ArgminOptimizer { Ok(objective) } + + /// Run two-phase optimization: episode reward then Sharpe ratio. + /// + /// Phase A: `max_trials/2` trials optimizing for fast convergence (episode reward). + /// Phase B: remaining trials optimizing for financial quality (Sharpe ratio), + /// seeded from Phase A's best parameter configuration. + /// + /// This reuses the existing `optimize()` method for each phase. The objective + /// mode is set on the model before each phase via `TwoPhaseObjective`. + /// + /// # Current Limitations + /// + /// Full two-phase with Phase B seeding requires `M: Clone`. Currently runs + /// Phase A only and returns its result. Phase B will be enabled once the + /// model types implement `Clone`. + pub fn optimize_two_phase(&self, mut model: M) -> Result> + where + M: HyperparameterOptimizable + TwoPhaseObjective + Send, + M::Params: ParameterSpace + Send, + { + let phase_a_trials = self.max_trials / 2; + let _phase_b_trials = self.max_trials - phase_a_trials; + + info!("======= Two-Phase Optimization ======="); + info!("Phase A: {} trials optimizing episode reward", phase_a_trials); + info!("Phase B: {} trials optimizing Sharpe ratio (pending Clone support)", _phase_b_trials); + + // Phase A: Episode Reward + model.set_objective_mode(ObjectiveMode::EpisodeReward); + let phase_a_optimizer = ArgminOptimizer::with_trials( + phase_a_trials, + self.n_initial.min(phase_a_trials - 1).max(1), + ); + let phase_a_result = phase_a_optimizer.optimize(model)?; + + info!( + "Phase A complete: best_objective={:.6}, evaluated {} trials", + phase_a_result.best_objective, + phase_a_result.all_trials.len() + ); + + // TODO: Phase B requires M: Clone to reconstruct model from Phase A result. + // Once DQNTrainer implements Clone, Phase B will: + // 1. Extract the model back (currently consumed by optimize()) + // 2. Set objective mode to Sharpe + // 3. Seed Phase B with top-3 parameter configs from Phase A + // 4. Run remaining trials with Sharpe-based objective + + info!( + "======= Two-Phase Complete: best_objective={:.6} =======", + phase_a_result.best_objective + ); + + Ok(phase_a_result) + } +} + +/// Trait for models supporting two-phase objective switching. +/// +/// Used by [`ArgminOptimizer::optimize_two_phase()`] to switch between +/// episode reward (fast convergence) and Sharpe ratio (financial quality). +/// +/// This trait is intentionally separate from [`HyperparameterOptimizable`] +/// because `extract_objective` is a static method that cannot access instance +/// state. Two-phase optimization instead sets the objective mode on the model +/// instance before each phase. +pub trait TwoPhaseObjective { + /// Set the objective mode for the current optimization phase. + fn set_objective_mode(&mut self, mode: ObjectiveMode); + /// Get the current objective mode. + fn objective_mode(&self) -> ObjectiveMode; } /// Cost function wrapper for argmin @@ -914,4 +986,52 @@ mod tests { "Debug output should NOT show raw log value -11.x" ); } + + #[test] + fn test_two_phase_optimizer_config() { + let optimizer = ArgminOptimizer::with_trials(30, 5); + assert_eq!(optimizer.max_trials, 30); + // Two-phase would run 15 + 15 trials + let phase_a = optimizer.max_trials / 2; + let phase_b = optimizer.max_trials - phase_a; + assert_eq!(phase_a, 15); + assert_eq!(phase_b, 15); + } + + #[test] + fn test_two_phase_optimizer_odd_trials() { + let optimizer = ArgminOptimizer::with_trials(31, 5); + // Odd total: 15 + 16 trials + let phase_a = optimizer.max_trials / 2; + let phase_b = optimizer.max_trials - phase_a; + assert_eq!(phase_a, 15); + assert_eq!(phase_b, 16); + } + + #[test] + fn test_two_phase_objective_trait_object_safety() { + // Verify TwoPhaseObjective can be used with the ObjectiveMode enum + use super::TwoPhaseObjective; + + struct MockModel { + mode: ObjectiveMode, + } + + impl TwoPhaseObjective for MockModel { + fn set_objective_mode(&mut self, mode: ObjectiveMode) { + self.mode = mode; + } + fn objective_mode(&self) -> ObjectiveMode { + self.mode + } + } + + let mut model = MockModel { + mode: ObjectiveMode::Sharpe, + }; + assert_eq!(model.objective_mode(), ObjectiveMode::Sharpe); + + model.set_objective_mode(ObjectiveMode::EpisodeReward); + assert_eq!(model.objective_mode(), ObjectiveMode::EpisodeReward); + } }