From 8a87f302c0c5cc985eab9cf22a11d95269cf6223 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Sat, 7 Mar 2026 13:00:07 +0100 Subject: [PATCH] feat(ml): re-enable OFI features from MBP-10 order book data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire 8 OFI features (OFI L1/L5, depth imbalance, VPIN, Kyle's lambda, bid/ask slopes, trade imbalance) through the DQN training pipeline: - Add mbp10_data_dir config field to DQNHyperparameters - Dynamic state_dim: 43 (no OFI) or 51 (with OFI) based on config - Compute OFI per bar during data loading, store on trainer - Pass OFI features through regime_features slot in TradingState - Configurable MBP-10 path with recursive .dbn/.dbn.zst discovery - Add zstd auto-detection to DbnParser::parse_mbp10_file() - Add --mbp10-data-dir CLI flag to train_baseline_rl - Fix hardcoded [f64; 51] → FeatureVector51 ([f64; 40]) across examples, walk_forward, GPU memory profile, and test fixtures - Fix stale state_dim=51 in dqn_config_2025() and DQN tests 2747 tests pass, 0 failures. Co-Authored-By: Claude Opus 4.6 --- config/universe-es-mbp10-test.toml | 16 +++ .../src/providers/databento/dbn_parser.rs | 13 +- crates/ml/examples/evaluate_baseline.rs | 7 +- crates/ml/examples/evaluate_supervised.rs | 3 +- crates/ml/examples/train_baseline_rl.rs | 27 ++-- .../ml/examples/train_baseline_supervised.rs | 3 +- crates/ml/src/cuda_pipeline/mod.rs | 2 +- crates/ml/src/dqn/dqn.rs | 6 +- crates/ml/src/gpu/memory_profile.rs | 2 +- crates/ml/src/hyperopt/adapters/dqn.rs | 3 + crates/ml/src/trainers/dqn/config.rs | 11 +- crates/ml/src/trainers/dqn/data_loading.rs | 115 +++++++++++++----- crates/ml/src/trainers/dqn/trainer.rs | 39 +++++- crates/ml/src/walk_forward.rs | 4 +- crates/ml/tests/dqn_feature_cache_test.rs | 2 +- 15 files changed, 191 insertions(+), 62 deletions(-) create mode 100644 config/universe-es-mbp10-test.toml diff --git a/config/universe-es-mbp10-test.toml b/config/universe-es-mbp10-test.toml new file mode 100644 index 000000000..28d564519 --- /dev/null +++ b/config/universe-es-mbp10-test.toml @@ -0,0 +1,16 @@ +# ES.FUT MBP-10 — small test subset (1 quarter) +# For local OFI pipeline validation only. + +[universe] +name = "es-mbp10-test" +description = "ES.FUT MBP-10 test subset (Q2 2024)" +date_range_start = "2024-04-01" +date_range_end = "2024-07-01" +bar_size = "tick" +databento_dataset = "GLBX.MDP3" +databento_schema = "mbp-10" + +[[symbols]] +symbol = "ES.FUT" +exchange = "GLBX.MDP3" +asset_class = "Future" diff --git a/crates/data/src/providers/databento/dbn_parser.rs b/crates/data/src/providers/databento/dbn_parser.rs index 34f752ab6..b16a2f2b6 100644 --- a/crates/data/src/providers/databento/dbn_parser.rs +++ b/crates/data/src/providers/databento/dbn_parser.rs @@ -656,9 +656,16 @@ impl DbnParser { let path = path.as_ref(); info!("📖 Parsing MBP-10 file: {:?}", path); - // Open file and create DBN decoder - let file = File::open(path)?; // DataError::Io is automatically converted from std::io::Error - let reader = BufReader::new(file); + // Open file and create DBN decoder (auto-detect .dbn.zst vs .dbn) + let file = File::open(path)?; + let is_zstd = path.to_string_lossy().ends_with(".dbn.zst"); + let reader: Box = if is_zstd { + Box::new(zstd::Decoder::new(BufReader::new(file)).map_err(|e| { + DataError::InvalidFormat(format!("Failed to create zstd decoder: {}", e)) + })?) + } else { + Box::new(BufReader::new(file)) + }; let mut decoder = DbnDecoder::new(reader).map_err(|e| { DataError::InvalidFormat(format!("Failed to create DBN decoder: {}", e)) diff --git a/crates/ml/examples/evaluate_baseline.rs b/crates/ml/examples/evaluate_baseline.rs index 8d0c60889..04ee2b8d9 100644 --- a/crates/ml/examples/evaluate_baseline.rs +++ b/crates/ml/examples/evaluate_baseline.rs @@ -43,6 +43,7 @@ use tracing::{error, info, warn}; use common::metrics::{server as metrics_server, training_metrics as tm}; use ml::common::action::{ExposureLevel, FactoredAction, OrderType as ActionOrderType, Urgency}; use ml::dqn::{DQNConfig, OrderRouter, DQN}; +use ml::trainers::dqn::FeatureVector51; #[allow(unreachable_pub)] mod baseline_common; @@ -401,7 +402,7 @@ struct PortfolioState { /// the START of the chunk. Market features are per-bar. Returns a contiguous /// `Vec` of length `chunk_len * feature_dim`. fn build_chunk_states( - test_features: &[[f64; 51]], + test_features: &[FeatureVector51], test_bars: &[OHLCVBar], chunk_start: usize, chunk_end: usize, @@ -542,7 +543,7 @@ fn simulate_chunk_trades( #[allow(clippy::cognitive_complexity)] fn evaluate_dqn_fold( fold: usize, - test_features: &[[f64; 51]], + test_features: &[FeatureVector51], test_bars: &[OHLCVBar], models_dir: &Path, args: &Args, @@ -705,7 +706,7 @@ fn evaluate_dqn_fold( /// for sequential trade simulation. fn evaluate_ppo_fold( fold: usize, - test_features: &[[f64; 51]], + test_features: &[FeatureVector51], test_bars: &[OHLCVBar], models_dir: &Path, args: &Args, diff --git a/crates/ml/examples/evaluate_supervised.rs b/crates/ml/examples/evaluate_supervised.rs index 6285a98fb..90f94ea7c 100644 --- a/crates/ml/examples/evaluate_supervised.rs +++ b/crates/ml/examples/evaluate_supervised.rs @@ -32,6 +32,7 @@ use tracing::{error, info, warn}; use common::metrics::{server as metrics_server, training_metrics as tm}; use ml::features::extraction::extract_ml_features; +use ml::trainers::dqn::FeatureVector51; use ml::training::unified_trainer::UnifiedTrainable; use ml::types::OHLCVBar; use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig}; @@ -397,7 +398,7 @@ fn create_model( fn evaluate_fold( fold: usize, model_name: &str, - test_features: &[[f64; 51]], + test_features: &[FeatureVector51], test_bars: &[OHLCVBar], args: &Args, device: &Device, diff --git a/crates/ml/examples/train_baseline_rl.rs b/crates/ml/examples/train_baseline_rl.rs index d2c9b7585..f1f8d40d9 100644 --- a/crates/ml/examples/train_baseline_rl.rs +++ b/crates/ml/examples/train_baseline_rl.rs @@ -30,7 +30,7 @@ use tracing::{error, info, warn}; use ml::cuda_pipeline::DqnGpuData; use ml::prelude::Device; -use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer}; +use ml::trainers::dqn::{DQNHyperparameters, DQNTrainer, FeatureVector51}; use ml::trainers::ppo::{PpoHyperparameters, PpoTrainer}; #[allow(unreachable_pub)] @@ -140,6 +140,12 @@ struct Args { /// model for each param set, saving as `dqn_ensemble_{k}_fold_{fold}.safetensors`. #[arg(long, default_value_t = 1)] ensemble_top_k: usize, + + /// Optional path to MBP-10 order book data directory for OFI features. + /// When set, enables 8 OFI features (OFI L1/L5, depth imbalance, VPIN, etc.) + /// expanding state dimension from 43 to 51. + #[arg(long)] + mbp10_data_dir: Option, } // --------------------------------------------------------------------------- @@ -234,7 +240,7 @@ fn hp_bool(params: &Option, key: &str) -> Option { // --------------------------------------------------------------------------- /// Prepared fold data: (`train_features`, `val_features`, `train_bars`, `val_bars`) -type FoldData = (Vec<[f64; 51]>, Vec<[f64; 51]>, Vec, Vec); +type FoldData = (Vec, Vec, Vec, Vec); #[allow(clippy::cognitive_complexity)] /// Prepare a fold's data for training: extract features, normalize, align bars. @@ -302,14 +308,14 @@ fn prepare_fold_data( // --------------------------------------------------------------------------- /// Convert pre-aligned features and bars into the format expected by -/// `DQNTrainer::train_with_preloaded_data`: `Vec<([f64; 51], Vec)>`. +/// `DQNTrainer::train_with_preloaded_data`: `Vec<(FeatureVector51, Vec)>`. /// /// The `Vec` target contains OHLCV prices for the trainer's internal /// reward computation and portfolio simulation. fn features_to_trainer_format( - features: &[[f64; 51]], + features: &[FeatureVector51], bars: &[OHLCVBar], -) -> Vec<([f64; 51], Vec)> { +) -> Vec<(FeatureVector51, Vec)> { features .iter() .zip(bars.iter()) @@ -329,8 +335,8 @@ fn features_to_trainer_format( #[allow(clippy::cognitive_complexity, clippy::too_many_arguments)] fn train_dqn_fold( fold: usize, - train_features: &[[f64; 51]], - val_features: &[[f64; 51]], + train_features: &[FeatureVector51], + val_features: &[FeatureVector51], train_bars: &[OHLCVBar], val_bars: &[OHLCVBar], args: &Args, @@ -438,6 +444,7 @@ fn train_dqn_fold( weight_decay: hp_f64(hp, "weight_decay").unwrap_or(1e-4), kelly_fractional: hp_f64(hp, "kelly_fractional").unwrap_or(0.5), kelly_max_fraction: hp_f64(hp, "kelly_max_fraction").unwrap_or(0.25), + mbp10_data_dir: args.mbp10_data_dir.as_ref().map(|p| p.to_string_lossy().into_owned()), ..DQNHyperparameters::default() }; @@ -509,8 +516,8 @@ fn train_dqn_fold( #[allow(clippy::cognitive_complexity)] fn train_ppo_fold( fold: usize, - train_features: &[[f64; 51]], - val_features: &[[f64; 51]], + train_features: &[FeatureVector51], + val_features: &[FeatureVector51], train_bars: &[OHLCVBar], _val_bars: &[OHLCVBar], args: &Args, @@ -585,7 +592,7 @@ fn train_ppo_fold( None, // num_envs: standard single-env collection ).context("Failed to create PpoTrainer")?; - // Convert features from [f64; 51] to Vec> for the trainer + // Convert features from FeatureVector51 to Vec> for the trainer let market_data: Vec> = train_features .iter() .map(|feat| feat.iter().map(|&v| v as f32).collect()) diff --git a/crates/ml/examples/train_baseline_supervised.rs b/crates/ml/examples/train_baseline_supervised.rs index 3d0548512..33808532b 100644 --- a/crates/ml/examples/train_baseline_supervised.rs +++ b/crates/ml/examples/train_baseline_supervised.rs @@ -35,6 +35,7 @@ use serde_json::Value; use tracing::{error, info, warn}; use ml::features::extraction::extract_ml_features; +use ml::trainers::dqn::FeatureVector51; use ml::training::unified_trainer::UnifiedTrainable; use ml::types::OHLCVBar; use ml::walk_forward::{generate_walk_forward_windows, NormStats, WalkForwardConfig}; @@ -400,7 +401,7 @@ fn prepare_fold_data( /// Convert normalized features + bars into (input, target) tensor pairs. fn build_tensor_pairs( - norm_features: &[[f64; 51]], + norm_features: &[FeatureVector51], bars: &[OHLCVBar], bar_offset: usize, args: &Args, diff --git a/crates/ml/src/cuda_pipeline/mod.rs b/crates/ml/src/cuda_pipeline/mod.rs index 7862cda5b..36fd22fe7 100644 --- a/crates/ml/src/cuda_pipeline/mod.rs +++ b/crates/ml/src/cuda_pipeline/mod.rs @@ -93,7 +93,7 @@ pub struct DqnGpuData { impl DqnGpuData { /// Upload DQN training data to GPU as f32 tensors. /// - /// Converts `[f64; 51]` features and `Vec` targets to f32, + /// Converts `[f64; 40]` features and `Vec` targets to f32, /// flattens into contiguous arrays, and uploads once. pub fn upload( data: &[([f64; 40], Vec)], diff --git a/crates/ml/src/dqn/dqn.rs b/crates/ml/src/dqn/dqn.rs index 254e46999..c96c5156f 100644 --- a/crates/ml/src/dqn/dqn.rs +++ b/crates/ml/src/dqn/dqn.rs @@ -3755,16 +3755,16 @@ mod tests { let mut config = DQNConfig::emergency_safe_defaults(); config.min_replay_size = 4; config.batch_size = 4; - config.state_dim = 51; // Match production feature vector size (45 market + 6 portfolio) + config.state_dim = 43; // Match production feature vector size (40 market + 3 portfolio) let mut dqn = DQN::new(config)?; // Add enough experiences for i in 0..10 { let experience = Experience::new( - vec![i as f32 * 0.1; 51], + vec![i as f32 * 0.1; 43], (i % 3) as u8, i as f32, - vec![(i + 1) as f32 * 0.1; 51], + vec![(i + 1) as f32 * 0.1; 43], i == 9, ); dqn.store_experience(experience)?; diff --git a/crates/ml/src/gpu/memory_profile.rs b/crates/ml/src/gpu/memory_profile.rs index 9575f125d..f820b3287 100644 --- a/crates/ml/src/gpu/memory_profile.rs +++ b/crates/ml/src/gpu/memory_profile.rs @@ -95,7 +95,7 @@ pub mod estimates { activation_multiplier: 2.5, supports_checkpointing: false, default_seq_len: 128, - default_feature_dim: 51, + default_feature_dim: 43, }; pub const LIQUID: ModelMemoryEstimate = ModelMemoryEstimate { diff --git a/crates/ml/src/hyperopt/adapters/dqn.rs b/crates/ml/src/hyperopt/adapters/dqn.rs index 3856aeec0..b72eaf7ad 100644 --- a/crates/ml/src/hyperopt/adapters/dqn.rs +++ b/crates/ml/src/hyperopt/adapters/dqn.rs @@ -2497,6 +2497,9 @@ impl HyperparameterOptimizable for DQNTrainer { use_dsr: true, dsr_eta: params.dsr_eta, + // MBP-10 data directory for OFI features (None = no OFI in hyperopt) + mbp10_data_dir: None, + // Mixed precision: auto-detect from GPU hardware mixed_precision: { match crate::memory_optimization::auto_batch_size::detect_gpu_memory() { diff --git a/crates/ml/src/trainers/dqn/config.rs b/crates/ml/src/trainers/dqn/config.rs index 338a4521d..4870fbaf0 100644 --- a/crates/ml/src/trainers/dqn/config.rs +++ b/crates/ml/src/trainers/dqn/config.rs @@ -796,6 +796,12 @@ pub struct DQNHyperparameters { pub use_dsr: bool, /// DSR EMA decay rate (0.001=slow, 0.05=fast). Tunable in hyperopt. pub dsr_eta: f64, + + // OFI (Order Flow Imbalance) features from MBP-10 data + /// Directory containing MBP-10 .dbn.zst files for OFI feature extraction. + /// When Some, 8 OFI features are computed and appended to the state (state_dim += 8). + /// When None, no OFI features are used (backward compatible). + pub mbp10_data_dir: Option, } impl Default for DQNHyperparameters { @@ -994,6 +1000,9 @@ impl DQNHyperparameters { // DSR: disabled by default (opt-in via hyperopt) use_dsr: false, dsr_eta: 0.01, // ~100-step effective EMA window + + // OFI: disabled by default (requires MBP-10 data) + mbp10_data_dir: None, } } } @@ -1046,7 +1055,7 @@ use crate::dqn::dqn::DQNConfig; pub(crate) fn dqn_config_2025() -> DQNConfig { DQNConfig { // Network Architecture - state_dim: 51, // 45 market features + 6 portfolio features (Wave 23) + state_dim: 43, // 40 market features + 3 portfolio features num_actions: 5, // 5 exposure levels (Short100, Short50, Flat, Long50, Long100) hidden_dims: vec![512, 256, 128], // Sufficient capacity without overfitting (Trial 19) leaky_relu_alpha: 0.01, diff --git a/crates/ml/src/trainers/dqn/data_loading.rs b/crates/ml/src/trainers/dqn/data_loading.rs index 766eae114..9a7470d91 100644 --- a/crates/ml/src/trainers/dqn/data_loading.rs +++ b/crates/ml/src/trainers/dqn/data_loading.rs @@ -375,20 +375,36 @@ impl DQNTrainer { // WAVE 2-A2: Load MBP-10 snapshots for OFI calculation use data::providers::databento::dbn_parser::DbnParser; - let mbp10_dir = Path::new("test_data/mbp10"); - let mbp10_snapshots = if mbp10_dir.exists() { - info!("📊 Loading MBP-10 order book snapshots for OFI calculation..."); - // Load all .dbn files in the directory - let mut all_snapshots = Vec::new(); - if let Ok(entries) = std::fs::read_dir(mbp10_dir) { - let parser = DbnParser::new() - .context("Failed to create DBN parser for MBP-10 data")?; + let mbp10_dir_path = self.hyperparams.mbp10_data_dir.as_ref().map(Path::new); + let mbp10_snapshots = if let Some(mbp10_dir) = mbp10_dir_path { + if mbp10_dir.exists() { + info!("📊 Loading MBP-10 order book snapshots from {:?}...", mbp10_dir); + let mut all_snapshots = Vec::new(); + // Recursive file discovery (data may be in symbol subdirectories) + fn collect_dbn_files(dir: &Path, out: &mut Vec) { + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_dbn_files(&path, out); + } else { + let name = path.to_string_lossy(); + if name.ends_with(".dbn") || name.ends_with(".dbn.zst") { + out.push(path); + } + } + } + } + } + let mut dbn_files = Vec::new(); + collect_dbn_files(mbp10_dir, &mut dbn_files); + dbn_files.sort(); - for entry in entries.flatten() { - let path = entry.path(); - // Only load .dbn files (not .zst compressed files) - if path.extension().and_then(|s| s.to_str()) == Some("dbn") { - match parser.parse_mbp10_file(&path).await { + if !dbn_files.is_empty() { + let parser = DbnParser::new() + .context("Failed to create DBN parser for MBP-10 data")?; + for path in &dbn_files { + match parser.parse_mbp10_file(path).await { Ok(mut snaps) => { info!(" ✅ Loaded {} snapshots from {:?}", snaps.len(), path.file_name()); all_snapshots.append(&mut snaps); @@ -399,31 +415,73 @@ impl DQNTrainer { } } } - } - if all_snapshots.is_empty() { - warn!("âš ī¸ No MBP-10 snapshots loaded. OFI features will be zeros."); - None + if all_snapshots.is_empty() { + warn!("âš ī¸ No MBP-10 snapshots loaded. OFI features will be zeros."); + None + } else { + all_snapshots.sort_by_key(|s| s.timestamp); + info!("✅ Total MBP-10 snapshots loaded: {} (sorted by timestamp)", all_snapshots.len()); + Some(all_snapshots) + } } else { - // Sort snapshots by timestamp for efficient lookup - all_snapshots.sort_by_key(|s| s.timestamp); - info!("✅ Total MBP-10 snapshots loaded: {} (sorted by timestamp)", all_snapshots.len()); - Some(all_snapshots) + warn!("âš ī¸ MBP-10 directory not found at {:?}. OFI features disabled.", mbp10_dir); + None } } else { - warn!("âš ī¸ MBP-10 directory not found at {:?}. OFI features will be zeros.", mbp10_dir); + info!("â„šī¸ No MBP-10 data directory configured. OFI features disabled."); None }; - // Extract 54-feature vectors (technical indicators, OFI, time, statistical features) - info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)..."); + // Extract 40-feature vectors (technical indicators, time, statistical features) + info!("Extracting 40-feature vectors from OHLCV bars..."); let feature_vectors = self.extract_full_features(&all_ohlcv_bars, mbp10_snapshots.as_deref())?; info!( - "Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))", + "Extracted {} feature vectors (40 dimensions: technical indicators, time, statistical features)", feature_vectors.len() ); + // Compute OFI features from MBP-10 snapshots (8 features per bar) + if let Some(ref snapshots) = mbp10_snapshots { + use crate::features::ofi_calculator::OFICalculator; + use crate::features::mbp10_loader::get_snapshots_for_timestamp; + + info!("📊 Computing OFI features from {} MBP-10 snapshots...", snapshots.len()); + let mut ofi_calculator = OFICalculator::new(); + let mut ofi_per_bar = Vec::with_capacity(feature_vectors.len()); + + const WARMUP: usize = 50; + for i in 0..feature_vectors.len() { + let bar = &all_ohlcv_bars[i + WARMUP]; + let bar_ts = bar.timestamp.timestamp_nanos_opt().unwrap_or(0) as u64; + + // Find the nearest MBP-10 snapshot for this bar's timestamp + let window = get_snapshots_for_timestamp(snapshots, bar_ts, 1); + if let Some(snap) = window.first() { + match ofi_calculator.calculate(snap) { + Ok(features) => { + let arr = features.to_array(); + if features.is_valid() { + ofi_per_bar.push(arr); + } else { + ofi_per_bar.push([0.0; 8]); + } + } + Err(_) => ofi_per_bar.push([0.0; 8]), + } + } else { + ofi_per_bar.push([0.0; 8]); + } + } + + let non_zero = ofi_per_bar.iter().filter(|f| f.iter().any(|&v| v != 0.0)).count(); + info!("✅ OFI features computed: {} total, {} non-zero ({:.1}%)", + ofi_per_bar.len(), non_zero, + if ofi_per_bar.is_empty() { 0.0 } else { non_zero as f64 / ofi_per_bar.len() as f64 * 100.0 }); + self.ofi_features = Some(ofi_per_bar); + } + // Create training data pairs (features, target) // Target: [preprocessed_current, preprocessed_next, raw_current, raw_next] // WAVE 3 BUG FIX: Include raw prices for triple barrier tracker (needs actual market prices in cents) @@ -547,13 +605,12 @@ impl DQNTrainer { all_ohlcv_bars.sort_by_key(|bar| bar.timestamp); debug!("Bars sorted successfully"); - // Extract 54-feature vectors (technical indicators, Proxy OFI, time, statistical features) - // Note: DBN loader does not load MBP-10 data, so OFI features will be zeros - info!("Extracting 51-feature vectors from OHLCV bars (51-feature architecture)..."); + // Extract 40-feature vectors (technical indicators, time, statistical features) + info!("Extracting 40-feature vectors from OHLCV bars..."); let feature_vectors = self.extract_full_features(&all_ohlcv_bars, None)?; info!( - "Extracted {} feature vectors (51 dimensions: technical indicators, time, statistical features (Proxy OFI removed))", + "Extracted {} feature vectors (40 dimensions: technical indicators, time, statistical features)", feature_vectors.len() ); diff --git a/crates/ml/src/trainers/dqn/trainer.rs b/crates/ml/src/trainers/dqn/trainer.rs index da783a556..795be56c5 100644 --- a/crates/ml/src/trainers/dqn/trainer.rs +++ b/crates/ml/src/trainers/dqn/trainer.rs @@ -234,6 +234,11 @@ pub struct DQNTrainer { /// Multi-GPU configuration for data-parallel training (None = single GPU) multi_gpu: Option, + + /// Pre-computed OFI features per bar (indexed by training data 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>, } impl std::fmt::Debug for DQNTrainer { @@ -383,8 +388,11 @@ impl DQNTrainer { // Create DQN configuration // 40-feature architecture: OHLCV, technical, patterns, volume, time, statistical // Portfolio features (3) are populated via PortfolioTracker → 43 total state_dim + // With MBP-10 OFI features: +8 regime features → 51 total + let ofi_enabled = hyperparams.mbp10_data_dir.is_some(); + let state_dim = if ofi_enabled { 51 } else { 43 }; let config = DQNConfig { - state_dim: 43, // 43-feature vectors: 40 market features + 3 portfolio + state_dim, num_actions: 5, // 5 exposure levels (Short100, Short50, Flat, Long50, Long100) hidden_dims, learning_rate: hyperparams.learning_rate, @@ -863,6 +871,9 @@ impl DQNTrainer { // Multi-GPU: auto-detected data parallelism multi_gpu, + + // OFI features: populated during data loading when MBP-10 data is available + ofi_features: None, }) } @@ -1890,7 +1901,7 @@ impl DQNTrainer { }; let close_price = rust_decimal::Decimal::try_from(current_close) .unwrap_or(rust_decimal::Decimal::ZERO); - self.feature_vector_to_state(&training_data[i].0, Some(close_price)) + self.feature_vector_to_state_with_ofi(&training_data[i].0, Some(close_price), Some(i)) }) .collect(); (Some(bt), cpu_states?) @@ -1907,7 +1918,7 @@ impl DQNTrainer { }; let close_price = rust_decimal::Decimal::try_from(current_close) .unwrap_or(rust_decimal::Decimal::ZERO); - self.feature_vector_to_state(&training_data[i].0, Some(close_price)) + self.feature_vector_to_state_with_ofi(&training_data[i].0, Some(close_price), Some(i)) }) .collect(); (None, cpu_states?) @@ -1989,9 +2000,10 @@ impl DQNTrainer { let mut next_state = if i + 1 < training_data.len() { let next_close_price = rust_decimal::Decimal::try_from(next_close) .unwrap_or(rust_decimal::Decimal::ZERO); - self.feature_vector_to_state( + self.feature_vector_to_state_with_ofi( &training_data[i + 1].0, Some(next_close_price), + Some(i + 1), )? } else { state.clone() @@ -3263,6 +3275,15 @@ impl DQNTrainer { &self, feature_vec: &FeatureVector51, close_price: Option, + ) -> Result { + self.feature_vector_to_state_with_ofi(feature_vec, close_price, None) + } + + fn feature_vector_to_state_with_ofi( + &self, + feature_vec: &FeatureVector51, + close_price: Option, + ofi_index: Option, ) -> Result { // States are pre-normalized during data loading let normalized_features: Vec = feature_vec.iter().map(|&v| v as f32).collect(); @@ -3300,8 +3321,14 @@ impl DQNTrainer { vec![0.0, 0.0, 0.0] // Fallback if no price provided }; - // 43-FEATURE ARCHITECTURE: No regime features (removed for feature reduction) - let regime_features: Vec = vec![]; + // OFI regime features: 8 features from MBP-10 order book data (or empty if unavailable) + let regime_features: Vec = if let (Some(ofi), Some(idx)) = (&self.ofi_features, ofi_index) { + ofi.get(idx) + .map(|f| f.iter().map(|&v| v as f32).collect()) + .unwrap_or_default() + } else { + vec![] + }; // Use from_normalized() to preserve sign information Ok(TradingState::from_normalized( diff --git a/crates/ml/src/walk_forward.rs b/crates/ml/src/walk_forward.rs index 4777a4d71..c9ce3816f 100644 --- a/crates/ml/src/walk_forward.rs +++ b/crates/ml/src/walk_forward.rs @@ -213,8 +213,8 @@ pub struct NormStats { pub std: Vec, } -/// Number of features in a standard feature vector. -const FEATURE_DIM: usize = 51; +/// Number of features in a standard feature vector (40 market features). +const FEATURE_DIM: usize = 40; /// Minimum standard deviation to prevent division by zero. const MIN_STD: f64 = 1e-8; diff --git a/crates/ml/tests/dqn_feature_cache_test.rs b/crates/ml/tests/dqn_feature_cache_test.rs index bb1770690..4ab6b9644 100644 --- a/crates/ml/tests/dqn_feature_cache_test.rs +++ b/crates/ml/tests/dqn_feature_cache_test.rs @@ -129,7 +129,7 @@ async fn load_features_from_cache( _parquet_path: &Path, _mbp10_dir: &Path, _warmup_period: usize, -) -> Result)>, Vec<([f64; 51], Vec)>)>> { +) -> Result)>, Vec<([f64; 40], Vec)>)>> { // NOTE: This is a stub that will fail until implemented // Implementation should follow design doc section 4 (Phase 2)