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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<PerformanceMetrics> = Vec::with_capacity(WINDOW_COUNT);
|
||||
let mut all_unique_actions: std::collections::HashSet<usize> = 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 = <DQNTrainer as HyperparameterOptimizable>::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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user