fix(dqn): fix OFI train/eval mismatch, enable DSR by default

- Walk-forward CPU backtest was calling feature_vector_to_state() without
  OFI index, producing zero OFI features during evaluation while training
  had real OFI — creating a silent train/eval feature mismatch
- Add convert_to_state_vec_with_ofi() public method on DQNTrainer
- Add ofi_val_offset field to track training data length for OFI indexing
- compute_validation_loss() now passes OFI index to validation states
- Hyperopt CPU eval path now uses convert_to_state_vec_with_ofi()
- Enable DSR (Differential Sharpe Ratio) by default in both config and
  GPU experience collector — aligns with hyperopt which always uses DSR
- Fix ensemble adapter test to explicitly disable branching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-12 22:31:02 +01:00
parent 2176815775
commit fa8acef9e9
5 changed files with 55 additions and 11 deletions

View File

@@ -176,7 +176,7 @@ impl Default for ExperienceCollectorConfig {
fill_spread_cost_frac: 0.50,
fill_spread_capture_frac: 0.50,
fill_simulation_enabled: false,
use_dsr: false,
use_dsr: true,
dsr_eta: 0.01,
n_steps: 1,
}

View File

@@ -249,6 +249,9 @@ mod tests {
state_dim: 56,
num_actions: 5,
hidden_dims: vec![64, 64],
// Disable branching for inference adapter tests — the adapter uses
// the plain dueling forward path, not the branching heads.
use_branching: false,
..Default::default()
}
}

View File

@@ -3180,6 +3180,11 @@ impl HyperparameterOptimizable for DQNTrainer {
let raw_state_dim: usize = if self.mbp10_data_dir.is_some() { 53 } else { 45 };
let state_dim = crate::dqn::mixed_precision::align_dim_for_tensor_cores(raw_state_dim, &device);
// OFI offset: validation bars start after training bars in the global OFI array.
// Without this, walk-forward evaluation gets zero OFI features (train/eval mismatch).
let ofi_offset = self.preloaded_training_data.as_ref().map_or(0, |d| d.len());
let ofi_enabled = self.mbp10_data_dir.is_some();
let total_bars = val_close_prices.len();
let window_size = total_bars / 3; // Same per-window size as before
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
@@ -3221,8 +3226,9 @@ impl HyperparameterOptimizable for DQNTrainer {
m
}
Err(e) => {
tracing::warn!("GPU backtest failed, falling back to CPU: {e}");
None
return Err(MLError::ModelError(format!(
"GPU backtest FAILED (no CPU fallback): {e}"
)));
}
}
} else {
@@ -3295,7 +3301,16 @@ impl HyperparameterOptimizable for DQNTrainer {
for (i, (feature_vec, _target)) in chunk.iter().enumerate() {
let close_price = val_close_prices[abs_start + i];
match internal_trainer.convert_to_state_vec(feature_vec, close_price) {
// OFI fix: inject real OFI features during eval to match training.
// Without this, eval gets zero OFI → train/eval feature mismatch.
let convert_result = if ofi_enabled {
internal_trainer.convert_to_state_vec_with_ofi(
feature_vec, close_price, ofi_offset + abs_start + i,
)
} else {
internal_trainer.convert_to_state_vec(feature_vec, close_price)
};
match convert_result {
Ok(sv) => {
flat_states.extend_from_slice(&sv);
// Zero-pad raw state (53/45) to aligned dim (56/48)

View File

@@ -1244,8 +1244,8 @@ impl DQNHyperparameters {
// BUG #7: Minimum profit factor for trade execution
minimum_profit_factor: 1.5, // Default: 50% margin above breakeven
// DSR: disabled by default (opt-in via hyperopt)
use_dsr: false,
// DSR: enabled by default — directly optimizes Sharpe ratio (Moody & Saffell 2001)
use_dsr: true,
dsr_eta: 0.01, // ~100-step effective EMA window
// OFI: disabled by default (requires MBP-10 data)

View File

@@ -257,10 +257,13 @@ pub struct DQNTrainer {
/// Multi-GPU configuration for data-parallel training (None = single GPU)
multi_gpu: Option<crate::cuda_pipeline::multi_gpu::MultiGpuConfig>,
/// Pre-computed OFI features per bar (indexed by training data position)
/// Pre-computed OFI features per bar (indexed by global bar position).
/// Populated during data loading when MBP-10 order book data is available.
/// Passed as `regime_features` in `TradingState::from_normalized()`.
pub(crate) ofi_features: Option<Vec<[f64; 8]>>,
/// Number of training bars (OFI offset for validation data).
/// val_data[i] corresponds to ofi_features[ofi_val_offset + i].
pub(crate) ofi_val_offset: usize,
// Phase C: Fill simulation and smart order routing
/// Fill simulator for order type-dependent execution modeling
@@ -976,6 +979,7 @@ impl DQNTrainer {
// OFI features: populated during data loading when MBP-10 data is available
ofi_features: None,
ofi_val_offset: 0,
// Phase C: Fill simulation and smart order routing
fill_simulator: FillSimulator::default(),
@@ -1071,6 +1075,7 @@ impl DQNTrainer {
);
// Store validation data for loss computation
self.ofi_val_offset = training_data.len();
self.val_data = val_data;
// Use the common training loop (Wave 12 Group 3 refactor)
@@ -1114,6 +1119,7 @@ impl DQNTrainer {
);
// Store validation data for loss computation
self.ofi_val_offset = training_data.len();
self.val_data = val_data;
// Use the common training loop (Wave 12 Group 3 refactor)
@@ -1140,6 +1146,7 @@ impl DQNTrainer {
val_data.len()
);
self.ofi_val_offset = training_data.len();
self.val_data = val_data;
self.train_with_data_full_loop(training_data, checkpoint_callback)
.await
@@ -1330,11 +1337,13 @@ impl DQNTrainer {
};
let close_price = rust_decimal::Decimal::try_from(current_close)
.unwrap_or(rust_decimal::Decimal::ZERO);
let state = self.feature_vector_to_state(feature_vec, Some(close_price))?;
// OFI fix: pass global OFI index so validation gets real OFI features
let ofi_idx = self.ofi_val_offset + i;
let state = self.feature_vector_to_state_with_ofi(feature_vec, Some(close_price), Some(ofi_idx))?;
let next_close_price =
rust_decimal::Decimal::try_from(next_close).unwrap_or(rust_decimal::Decimal::ZERO);
let next_state = self.feature_vector_to_state(feature_vec, Some(next_close_price))?;
let next_state = self.feature_vector_to_state_with_ofi(feature_vec, Some(next_close_price), Some(ofi_idx))?;
// P4: Write state vector directly into the flat buffer at the correct row offset.
// Raw dims (e.g. 45 or 53) are shorter than aligned (48 or 56); trailing
@@ -2337,8 +2346,9 @@ impl DQNTrainer {
true
}
Err(e) => {
warn!("GPU zero-roundtrip collection failed, falling back to CPU path: {e}");
false
return Err(MLError::ModelError(format!(
"GPU zero-roundtrip collection FAILED (no CPU fallback): {e}"
)));
}
}
} else {
@@ -5940,6 +5950,22 @@ impl DQNTrainer {
Ok(trading_state.to_vector())
}
/// Convert feature vector to flat state Vec<f32> with OFI features at the given index.
///
/// Same as `convert_to_state_vec` but injects OFI features from the preloaded
/// array at `ofi_index`, preventing train/eval feature mismatch.
pub fn convert_to_state_vec_with_ofi(
&self,
feature_vec: &FeatureVector,
close_price: f64,
ofi_index: usize,
) -> Result<Vec<f32>> {
let close = rust_decimal::Decimal::try_from(close_price)
.map_err(|e| CommonError::validation(&format!("Invalid close price: {}", e)))?;
let trading_state = self.feature_vector_to_state_with_ofi(feature_vec, Some(close), Some(ofi_index))?;
Ok(trading_state.to_vector())
}
/// Update portfolio tracker to reflect current position from backtest engine.
///
/// Called between chunks so the next chunk's portfolio features