diff --git a/crates/ml-features/src/mbp10_loader.rs b/crates/ml-features/src/mbp10_loader.rs index de1ff6641..e9bbe29bc 100644 --- a/crates/ml-features/src/mbp10_loader.rs +++ b/crates/ml-features/src/mbp10_loader.rs @@ -343,6 +343,14 @@ pub struct Mbp10Trade { pub timestamp: DateTime, /// True if buyer-initiated (side == 'B'), false otherwise pub is_buy: bool, + /// Databento `instrument_id` for the contract this trade belongs to. + /// Used by `mbp10_to_imbalance_bars` to filter to the front-month + /// (highest-volume) contract per file, mirroring the per-file pattern + /// in `precompute_features.rs:354`. Populated from `mbp10.hd.instrument_id` + /// in `extract_trades_from_dbn_file`; left as 0 in the snapshot-diff + /// path (`extract_trades_from_snapshots`) where the source records + /// don't carry instrument IDs (mostly used by tests). + pub instrument_id: u32, } /// Extract trade ticks from MBP-10 snapshots using consecutive snapshot diffs. @@ -384,6 +392,7 @@ pub fn extract_trades_from_snapshots(snapshots: &[Mbp10Snapshot]) -> Vec Result, volume, timestamp: dt, is_buy, + instrument_id: mbp10.hd.instrument_id, }); } } @@ -485,6 +495,31 @@ pub fn extract_trades_from_dbn_file(file_path: &Path) -> Result, Ok(trades) } +/// Filter `Mbp10Trade`s to keep only the front-month (highest cumulative +/// volume) contract. Mirrors `trades_loader::filter_front_month` for +/// `DbnTrade` — same logic on `Mbp10Trade.instrument_id`. Apply per-file +/// because each quarterly file has a different front-month contract. +fn filter_front_month_mbp10(trades: &[Mbp10Trade]) -> Vec { + if trades.is_empty() { + return Vec::new(); + } + let mut volume_by_id: std::collections::HashMap = + std::collections::HashMap::new(); + for trade in trades { + *volume_by_id.entry(trade.instrument_id).or_insert(0) += trade.volume as u64; + } + let front_month_id = volume_by_id + .into_iter() + .max_by_key(|&(_, vol)| vol) + .map(|(id, _)| id) + .unwrap_or(0); + trades + .iter() + .filter(|t| t.instrument_id == front_month_id) + .cloned() + .collect() +} + /// Recursively collect all .dbn and .dbn.zst files from a directory. fn collect_dbn_files(dir: &Path) -> Vec { let mut files = Vec::new(); @@ -628,17 +663,27 @@ pub fn mbp10_to_imbalance_bars( dbn_files.len() ); - // Collect all trades from all files + // Collect trades from all files, filtering to the front-month (highest- + // volume) contract per file. Without per-file filtering the resulting + // bar stream interleaves contract months — when the dominant ES contract + // rolls over (ESZ24 → ESH25 → ESM25), price jumps appear at every + // rollover, failing the `precompute_features.rs:371-376` price-continuity + // gate. Mirror the per-file pattern in `precompute_features.rs:347-365` + // (uses `filter_front_month` on `DbnTrade` slices); same logic, applied + // to `Mbp10Trade.instrument_id`. let mut all_trades: Vec = Vec::new(); for file_path in &dbn_files { match extract_trades_from_dbn_file(file_path) { Ok(trades) => { - tracing::debug!( - " {} -> {} trades", + let raw_count = trades.len(); + let filtered = filter_front_month_mbp10(&trades); + tracing::info!( + " {} -> {} trades, {} front-month", file_path.file_name().unwrap_or_default().to_string_lossy(), - trades.len() + raw_count, + filtered.len() ); - all_trades.extend(trades); + all_trades.extend(filtered); } Err(e) => { tracing::warn!(