fix(ml): improve DQN backtest Sharpe — greedy eval, exposure-scaled reward, cost alignment

A: Switch backtest eval from Gumbel softmax to greedy argmax (batch_greedy_actions)
   so hyperopt Sharpe reflects the agent's actual learned policy, not noisy sampling.

C: Disable reward normalization (enable_normalization=false). EMA normalizer with
   ±3.0 clipping was flattening the reward landscape, preventing the agent from
   distinguishing large winners from scratch trades.

D: Wire tx_cost_bps (0.1 bps for IBKR ES) through to EvaluationEngine via
   new_with_fee_rate(). Previously hardcoded at 15 bps (150x mismatch with actual
   commission costs), massively penalizing every trade in backtest.

E: Scale PnL reward by agent's target exposure in calculate_pnl_reward().
   Previously, a Short100 action received POSITIVE reward when market went up
   (pct_return ignored position direction). Now: reward = pct_return × exposure.

2735 tests pass, 0 clippy warnings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-06 20:25:42 +01:00
parent 1950ba009d
commit ba269d7ce7
3 changed files with 37 additions and 14 deletions

View File

@@ -208,7 +208,7 @@ impl Default for RewardConfig {
movement_threshold: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), // 1% matches data distribution
hold_penalty_weight: Decimal::try_from(0.01).unwrap_or(Decimal::ZERO), // 1% default penalty
diversity_weight: Decimal::try_from(-0.1).unwrap_or(Decimal::ZERO), // -0.1 default (100x stronger than hold_reward)
enable_normalization: true, // Fix #3: Enable normalization (gradient stability)
enable_normalization: false, // Disabled: clipping destroys economic signal magnitude
use_percentage_pnl: true, // Bug #17 fix: scale-invariant percentage returns
circuit_breaker_config: CircuitBreakerConfig::default(),
triple_barrier_profit_bonus: Decimal::try_from(0.5).unwrap_or(Decimal::ZERO), // 50% bonus for hitting profit target
@@ -502,8 +502,8 @@ impl RewardFunction {
let base_reward = match legacy_action {
TradingAction::Buy | TradingAction::Sell => {
// Calculate P&L-based reward
let pnl_reward = self.calculate_pnl_reward(current_state, next_state)?;
// Calculate P&L-based reward scaled by the agent's target exposure
let pnl_reward = self.calculate_pnl_reward(current_state, next_state, action.target_exposure())?;
// Calculate risk penalty
let risk_penalty = self.calculate_risk_penalty(next_state);
@@ -682,6 +682,7 @@ impl RewardFunction {
&self,
current_state: &TradingState,
next_state: &TradingState,
target_exposure: f64,
) -> Result<Decimal, MLError> {
// Validate portfolio_features length (defensive check)
if current_state.portfolio_features.len() < 1 {
@@ -724,13 +725,15 @@ impl RewardFunction {
);
}
// BUG #40 FIX: Remove 100x PnL scaling for C51 compatibility
// Root cause: 100x multiplier pushes rewards outside C51's V-range [-2, 2]
// This causes Q-value explosions to ±60,000 in distributional RL
// Solution: Use raw percentage returns (±0.02) which stay within bounds
// C51 atoms are calibrated for [-2, 2], and normalization handles any scaling
// Expected: Q-values converge to reward scale, not the other way around
pct_return
// Scale PnL by agent's target exposure so the reward reflects the
// agent's intended position:
// Long100 (+1.0): full market return
// Short100 (-1.0): inverted market return (profit when market drops)
// Flat (0.0): zero PnL (no skin in the game)
// Long50/Short50 (±0.5): half-size position
let exposure_decimal = Decimal::try_from(target_exposure)
.unwrap_or(Decimal::ONE);
pct_return * exposure_decimal
}
} else {
// Absolute dollar change (original implementation)

View File

@@ -64,6 +64,8 @@ pub struct EvaluationEngine {
pub exposure_entry_price: f32,
/// Bar index where current exposure was first entered
pub exposure_entry_bar: usize,
/// Override fee rate for all trades (None = use action's transaction_cost())
fee_rate_override: Option<f64>,
}
impl EvaluationEngine {
@@ -82,6 +84,22 @@ impl EvaluationEngine {
current_exposure: 0.0,
exposure_entry_price: 0.0,
exposure_entry_bar: 0,
fee_rate_override: None,
}
}
/// Create evaluation engine with explicit fee rate override (in decimal, e.g. 0.00001 for 0.1 bps)
pub fn new_with_fee_rate(initial_capital: f32, kelly_fraction: f64, fee_rate: f64) -> Self {
Self {
current_position: None,
trades: Vec::new(),
initial_capital,
action_counts: [0, 0, 0],
kelly_fraction,
current_exposure: 0.0,
exposure_entry_price: 0.0,
exposure_entry_bar: 0,
fee_rate_override: Some(fee_rate),
}
}
@@ -221,7 +239,7 @@ impl EvaluationEngine {
}
// Record trade for the exposure change
let fee_rate = action.transaction_cost(); // 0.0015 Market, 0.0005 Limit, 0.001 IoC
let fee_rate = self.fee_rate_override.unwrap_or_else(|| action.transaction_cost());
// PnL from the portion being closed (if reducing or reversing)
let closing_size = if self.current_exposure.abs() > EPSILON
@@ -289,7 +307,8 @@ impl EvaluationEngine {
};
let size = self.current_exposure.abs() * self.kelly_fraction;
let gross_pnl = price_diff * direction_sign * size as f32;
let tx_cost = bar.close.abs() as f64 * size * 0.0015; // Market order for forced close
let close_fee = self.fee_rate_override.unwrap_or(0.0015);
let tx_cost = bar.close.abs() as f64 * size * close_fee;
self.trades.push(Trade {
entry_bar_idx: self.exposure_entry_bar,

View File

@@ -2871,7 +2871,8 @@ impl HyperparameterOptimizable for DQNTrainer {
// Reset portfolio state for this window
internal_trainer.set_portfolio_for_backtest(0.0, 0.0, 0.0);
let mut engine = EvaluationEngine::new_with_kelly(eval_capital, kelly_fraction);
let backtest_fee_rate = self.tx_cost_bps * 0.0001; // bps → decimal
let mut engine = EvaluationEngine::new_with_fee_rate(eval_capital, kelly_fraction, backtest_fee_rate);
let num_chunks = win_len.div_ceil(EVAL_CHUNK_SIZE);
let mut ohlcv_bars = Vec::with_capacity(win_len);
@@ -2922,7 +2923,7 @@ impl HyperparameterOptimizable for DQNTrainer {
})?;
let action_indices =
agent_guard.batch_hierarchical_softmax_actions(&batch_tensor, params.eval_softmax_temp)?;
agent_guard.batch_greedy_actions(&batch_tensor)?;
// 3. Sequential trade simulation on CPU
for (i, &action_idx) in action_indices.iter().enumerate() {