Files
foxhunt/crates/data/src/providers/databento/dbn_parser.rs
jgrusewski 20aa345a7c fix(loader): soft-validate FrontMonth symbol when no streaming SymbolMapping
Databento historical bulk DBN files emit symbol mappings only in the metadata
header (date-range form), not as in-stream SymbolMappingMsg records. The
strict 'no mapping = error' branch from the previous commit blocked the
front-month smoke (alpha-perception-vstrr) at Q1 2024 where the dominant id
5602 has no streaming mapping entry.

Volume-leader detection itself doesn't depend on the symbol; the regex check
was defense-in-depth. Log-warn on symbol-mismatch and proceed with id=metadata-only
when no streaming mapping exists. Calendar-spread anomalies still surface in
logs without blocking training.
2026-05-22 18:08:36 +02:00

1369 lines
50 KiB
Rust

//! Production-Ready DBN (Databento Binary) Format Parser
//!
//! High-performance, zero-copy parser for Databento's binary format with SIMD optimizations
//! for ultra-low latency HFT market data processing. Targets <1μs processing per tick.
//!
//! ## Performance Features
//!
//! - **Zero-Copy Deserialization**: Direct memory mapping with minimal allocations
//! - **SIMD Optimizations**: Vectorized processing for batch operations
//! - **Lock-Free Processing**: Atomic operations for concurrent access
//! - **Hardware Timestamps**: RDTSC-based timing for latency measurement
//! - **Memory Prefetching**: Cache-optimized data access patterns
//!
//! ## DBN Format Support
//!
//! - **Trade Messages**: Fast trade tick processing with price/size/conditions
//! - **Quote Messages**: L1 BBO with bid/ask spreads
//! - **Order Book**: L2/L3 depth with incremental updates
//! - **Statistics**: OHLCV bars and session statistics
//! - **Status Messages**: Market status and trading halts
use crate::error::{DataError, Result};
use crate::providers::databento::mbp10::{Mbp10Snapshot, OrderBookAction};
use common::{OrderSide, Price};
use dbn::decode::{DbnDecoder, DbnMetadata, DecodeRecordRef};
use dbn::RecordRefEnum;
use num_traits::{FromPrimitive, ToPrimitive};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use std::io::Cursor;
use std::path::Path;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use tracing::{debug, error, info, instrument, warn};
use trading_engine::{
events::event_types::{EventLevel, SystemEventType, TradingEvent},
events::EventProcessor,
lockfree::{ring_buffer::LockFreeRingBuffer, HftMessage},
simd::{SafeSimdDispatcher, SimdMarketDataOps},
timing::HardwareTimestamp,
};
/// DBN message header - optimized for zero-copy parsing
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
pub struct DbnMessageHeader {
/// Message length in bytes
pub length: u16,
/// Record type identifier
pub rtype: u8,
/// Publisher ID
pub publisher_id: u8,
/// Instrument ID
pub instrument_id: u32,
/// Timestamp (nanoseconds since Unix epoch)
pub ts_event: u64,
}
/// DBN trade message - zero-copy optimized
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
pub struct DbnTradeMessage {
/// Message header with timestamp and symbol
pub header: DbnMessageHeader,
/// Trade price (scaled integer)
pub price: i64,
/// Trade size
pub size: u32,
/// Trade action (A=Add, C=Cancel, M=Modify, T=Trade, F=Fill)
pub action: u8,
/// Trade side (A=Ask, B=Bid, N=None)
pub side: u8,
/// Trade flags
pub flags: u16,
/// Depth level
pub depth: u8,
/// Sequence number
pub sequence: u32,
/// Reserved padding
pub padding: u8,
}
/// DBN quote message - L1 BBO data
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
pub struct DbnQuoteMessage {
/// Message header with timestamp and symbol
pub header: DbnMessageHeader,
/// Bid price (scaled integer)
pub bid_px: i64,
/// Ask price (scaled integer)
pub ask_px: i64,
/// Bid size
pub bid_sz: u32,
/// Ask size
pub ask_sz: u32,
/// Bid count
pub bid_ct: u8,
/// Ask count
pub ask_ct: u8,
/// Flags
pub flags: u16,
/// Sequence number
pub sequence: u32,
/// Reserved padding
pub padding: [u8; 2],
}
/// DBN order book message - L2/L3 depth
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
pub struct DbnOrderBookMessage {
/// Message header with timestamp and symbol
pub header: DbnMessageHeader,
/// Order ID
pub order_id: u64,
/// Price (scaled integer)
pub price: i64,
/// Size
pub size: u32,
/// Flags
pub flags: u16,
/// Channel ID
pub channel_id: u8,
/// Order count
pub order_count: u8,
/// Action (A=Add, C=Cancel, M=Modify, T=Trade, F=Fill)
pub action: u8,
/// Side (A=Ask, B=Bid)
pub side: u8,
/// Sequence number
pub sequence: u32,
/// Reserved padding
pub padding: [u8; 2],
}
/// DBN OHLCV bar message
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
pub struct DbnOhlcvMessage {
/// Message header with timestamp and symbol
pub header: DbnMessageHeader,
/// Open price (scaled integer)
pub open: i64,
/// High price (scaled integer)
pub high: i64,
/// Low price (scaled integer)
pub low: i64,
/// Close price (scaled integer)
pub close: i64,
/// Volume
pub volume: u64,
}
/// DBN message types
#[repr(u8)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DbnMessageType {
/// Trade message (tick data)
Trade = 0x54, // 'T'
/// Quote message (BBO)
Quote = 0x51, // 'Q'
/// Order book message (depth)
OrderBook = 0x4F, // 'O'
/// OHLCV bar message
Ohlcv = 0x42, // 'B' (Bar)
/// Status message
Status = 0x53, // 'S'
/// Error message
Error = 0x45, // 'E'
}
impl From<u8> for DbnMessageType {
fn from(value: u8) -> Self {
match value {
0x54 => Self::Trade,
0x51 => Self::Quote,
0x4F => Self::OrderBook,
0x42 => Self::Ohlcv,
0x53 => Self::Status,
0x45 => Self::Error,
_ => Self::Error, // Default to error for unknown types
}
}
}
/// High-performance DBN parser with SIMD optimizations
pub struct DbnParser {
/// SIMD dispatcher for optimal performance
simd_dispatcher: SafeSimdDispatcher,
/// Market data operations
simd_ops: Option<SimdMarketDataOps>,
/// Performance metrics
metrics: Arc<DbnParserMetrics>,
/// Symbol mapping for instrument IDs
symbol_map: Arc<std::sync::RwLock<std::collections::HashMap<u32, String>>>,
/// Price scaling factors per instrument
price_scales: Arc<std::sync::RwLock<std::collections::HashMap<u32, i32>>>,
/// Event processor for integration
event_processor: Option<Arc<EventProcessor>>,
/// Lock-free message buffer for high-frequency processing
message_buffer: Arc<LockFreeRingBuffer<HftMessage>>,
}
impl DbnParser {
/// Create new high-performance DBN parser
pub fn new() -> Result<Self> {
let simd_dispatcher = SafeSimdDispatcher::new();
let simd_ops = simd_dispatcher.create_market_data_ops().ok();
if simd_ops.is_none() {
warn!("AVX2 not available - falling back to scalar processing");
} else {
debug!("DBN parser initialized with AVX2 SIMD optimizations");
}
let message_buffer = Arc::new(LockFreeRingBuffer::new(0x8000).map_err(|e| {
DataError::Initialization(format!("Failed to create message buffer: {}", e))
})?);
Ok(Self {
simd_dispatcher,
simd_ops,
metrics: Arc::new(DbnParserMetrics::new()),
symbol_map: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
price_scales: Arc::new(std::sync::RwLock::new(std::collections::HashMap::new())),
event_processor: None,
message_buffer,
})
}
/// Set event processor for integration with core event system
pub fn set_event_processor(&mut self, processor: Arc<EventProcessor>) {
self.event_processor = Some(processor);
}
/// Update symbol mapping for instrument IDs
pub fn update_symbol_map(&self, mapping: std::collections::HashMap<u32, String>) {
let mut symbol_map = self
.symbol_map
.write()
.unwrap_or_else(|e| e.into_inner());
symbol_map.extend(mapping);
debug!("Updated symbol map with {} instruments", symbol_map.len());
}
/// Update price scaling factors
pub fn update_price_scales(&self, scales: std::collections::HashMap<u32, i32>) {
let mut price_scales = self
.price_scales
.write()
.unwrap_or_else(|e| e.into_inner());
price_scales.extend(scales);
debug!(
"Updated price scales for {} instruments",
price_scales.len()
);
}
/// Parse DBN binary data using official dbn crate decoder
///
/// Replaces custom binary parsing with production-ready decoder that correctly
/// handles DataBento's binary format. Preserves HFT features (SIMD, metrics, timestamps).
#[instrument(skip(self, data), level = "trace")]
pub fn parse_batch(&self, data: &[u8]) -> Result<Vec<ProcessedMessage>> {
let start_time = HardwareTimestamp::now();
let mut messages = Vec::new();
// Pre-allocate for common batch sizes
messages.reserve(1000);
// Create official DBN decoder
let cursor = Cursor::new(data);
let mut decoder = DbnDecoder::new(cursor)
.map_err(|e| DataError::InvalidFormat(format!("DBN decode error: {}", e)))?;
// Read metadata for symbol mapping
let metadata = decoder.metadata();
let symbol = metadata
.symbols
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
debug!(
"DBN metadata: dataset={:?}, schema={:?}, symbol={}",
metadata.dataset, metadata.schema, symbol
);
// Decode all records
let mut record_count = 0;
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
record_count += 1;
self.metrics.increment_messages_parsed();
// Convert RecordRef to RecordRefEnum for pattern matching
let record_enum = record.as_enum().map_err(|e| {
DataError::InvalidFormat(format!("Failed to convert record to enum: {}", e))
})?;
// Parse based on record type
match self.parse_dbn_record(record_enum, &symbol)? {
Some(msg) => messages.push(msg),
None => {
// Unknown message type - skip
self.metrics.increment_unknown_messages();
},
}
},
Ok(None) => {
// End of stream
break;
},
Err(e) => {
return Err(DataError::InvalidFormat(format!(
"Failed to decode record {}: {}",
record_count, e
)));
},
}
}
// SIMD batch processing for trade and quote messages
if messages.len() >= 4 && self.simd_ops.is_some() {
self.simd_batch_process(&mut messages)?;
}
let parse_time = HardwareTimestamp::now();
let latency_ns = parse_time.latency_ns(&start_time);
self.metrics.record_parse_latency(latency_ns);
// Check if we met the <1μs per tick target
if messages.len() > 0 {
let per_tick_latency = latency_ns / messages.len() as u64;
if per_tick_latency > 1000 {
warn!(
"Parse latency {}ns/tick exceeds 1μs target",
per_tick_latency
);
}
self.metrics.record_per_tick_latency(per_tick_latency);
}
debug!("Parsed {} messages from DBN data", messages.len());
Ok(messages)
}
/// Parse official dbn RecordRefEnum into ProcessedMessage
///
/// Handles all DBN record types: OHLCV, Trade, MBP-1 (quotes), MBP-10 (order book).
/// Preserves existing ProcessedMessage format for compatibility with trading_engine.
fn parse_dbn_record(
&self,
record: RecordRefEnum<'_>,
symbol: &str,
) -> Result<Option<ProcessedMessage>> {
match record {
RecordRefEnum::Ohlcv(ohlcv) => {
// OHLCV bars - primary data type for backtesting
let timestamp = HardwareTimestamp::from_nanos(ohlcv.hd.ts_event);
// Prices are i64 scaled by 1e-9 per DBN specification
let open_f64 = ohlcv.open as f64 * 1e-9;
let high_f64 = ohlcv.high as f64 * 1e-9;
let low_f64 = ohlcv.low as f64 * 1e-9;
let close_f64 = ohlcv.close as f64 * 1e-9;
// Use absolute values for Price type (futures data can have negative values)
let open = Price::from_f64(open_f64.abs())?;
let high = Price::from_f64(high_f64.abs())?;
let low = Price::from_f64(low_f64.abs())?;
let close = Price::from_f64(close_f64.abs())?;
let volume = Decimal::from(ohlcv.volume);
self.metrics.increment_bars_processed();
Ok(Some(ProcessedMessage::Ohlcv {
symbol: symbol.to_string(),
timestamp,
open,
high,
low,
close,
volume,
}))
},
RecordRefEnum::Trade(trade) => {
// Trade ticks
let timestamp = HardwareTimestamp::from_nanos(trade.hd.ts_event);
// Prices are i64 scaled by 1e-9 per DBN specification
let price_f64 = trade.price as f64 * 1e-9;
let price = Price::from_f64(price_f64.abs())?;
let size = Decimal::from(trade.size);
// Determine side from trade action/flags (c_char is i8)
let side = if trade.side == b'B' as i8 {
OrderSide::Buy
} else if trade.side == b'A' as i8 {
OrderSide::Sell
} else {
OrderSide::Buy // Default
};
self.metrics.increment_trades_processed();
Ok(Some(ProcessedMessage::Trade {
symbol: symbol.to_string(),
timestamp,
price,
size,
side,
trade_id: None,
conditions: vec![],
}))
},
RecordRefEnum::Mbp1(mbp) => {
// MBP-1 (Market By Price Level 1) - BBO quotes
let timestamp = HardwareTimestamp::from_nanos(mbp.hd.ts_event);
// Mbp1Msg has price/size/side, not separate bid/ask fields
let price_f64 = mbp.price as f64 * 1e-9;
let price = Price::from_f64(price_f64.abs())?;
let size = Decimal::from(mbp.size);
// Determine bid/ask from side field (c_char is i8)
let (bid, ask, bid_size, ask_size) = if mbp.side == b'B' as i8 {
// Bid side
(Some(price), None, Some(size), None)
} else if mbp.side == b'A' as i8 {
// Ask side
(None, Some(price), None, Some(size))
} else {
// Unknown side - treat as bid
(Some(price), None, Some(size), None)
};
self.metrics.increment_quotes_processed();
Ok(Some(ProcessedMessage::Quote {
symbol: symbol.to_string(),
timestamp,
bid,
ask,
bid_size,
ask_size,
exchange: Some("UNKNOWN".to_string()),
}))
},
RecordRefEnum::Mbp10(mbp10) => {
// MBP-10 (Market By Price Level 2) - order book update event
// Note: Mbp10Msg is a single update event (like Mbp1), not a full 10-level snapshot
let timestamp = HardwareTimestamp::from_nanos(mbp10.hd.ts_event);
let price_f64 = mbp10.price as f64 * 1e-9;
let price = Price::from_f64(price_f64.abs())?;
let size = Decimal::from(mbp10.size);
// Determine bid/ask from side field (c_char is i8)
let (bid, ask, bid_size, ask_size) = if mbp10.side == b'B' as i8 {
// Bid side
(Some(price), None, Some(size), None)
} else if mbp10.side == b'A' as i8 {
// Ask side
(None, Some(price), None, Some(size))
} else {
// Unknown side - treat as bid
(Some(price), None, Some(size), None)
};
self.metrics.increment_orderbook_processed();
Ok(Some(ProcessedMessage::Quote {
symbol: symbol.to_string(),
timestamp,
bid,
ask,
bid_size,
ask_size,
exchange: Some("UNKNOWN".to_string()),
}))
},
_ => {
// Skip other message types (Status, Error, etc.)
Ok(None)
},
}
}
/// SIMD batch processing for performance optimization
fn simd_batch_process(&self, messages: &mut [ProcessedMessage]) -> Result<()> {
use num_traits::ToPrimitive;
if let Some(ref simd_ops) = self.simd_ops {
// Group messages by type for SIMD processing
let mut trade_prices = Vec::new();
let mut trade_volumes = Vec::new();
for msg in messages {
if let ProcessedMessage::Trade { price, size, .. } = msg {
trade_prices.push(price.to_f64());
// Handle Option<f64> from rust_decimal::Decimal::to_f64()
if let Some(volume_f64) = size.to_f64() {
trade_volumes.push(volume_f64);
} else {
warn!("Failed to convert trade volume to f64, skipping");
continue;
}
}
}
// Ensure both vectors have the same length after filtering
let min_len = trade_prices.len().min(trade_volumes.len());
trade_prices.truncate(min_len);
trade_volumes.truncate(min_len);
// Calculate VWAP using SIMD if we have enough trades
if trade_prices.len() >= 4 {
let vwap = unsafe { simd_ops.calculate_vwap(&trade_prices, &trade_volumes) }; // SAFETY: Unsafe operation validated - invariants maintained by surrounding code
debug!("Batch VWAP calculated: {:.4}", vwap);
self.metrics.record_vwap(vwap);
}
}
Ok(())
}
/// Get symbol name for instrument ID
fn get_symbol(&self, instrument_id: u32) -> String {
self.symbol_map
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&instrument_id)
.cloned()
.unwrap_or_else(|| format!("UNKNOWN_{}", instrument_id))
}
/// Scale integer price to decimal using instrument-specific scaling
fn scale_price(&self, price: i64, instrument_id: u32) -> Result<Price> {
let scale = self
.price_scales
.read()
.unwrap_or_else(|e| e.into_inner())
.get(&instrument_id)
.copied()
.unwrap_or(4); // Default to 4 decimal places
let decimal_price = Decimal::from(price);
let scale_factor = Decimal::from(10_i64.pow(scale as u32));
let scaled_decimal = decimal_price / scale_factor;
let result_f64 = scaled_decimal.to_f64().ok_or_else(|| {
DataError::InvalidFormat("Failed to convert decimal to f64".to_string())
})?;
Price::from_f64(result_f64)
.map_err(|e| DataError::InvalidFormat(format!("Failed to convert price: {}", e)))
}
/// Send processed messages to event system
pub async fn send_to_event_system(&self, messages: Vec<ProcessedMessage>) -> Result<()> {
if let Some(ref processor) = self.event_processor {
for msg in messages {
let trading_event = self.convert_to_trading_event(msg)?;
// Capture event with sub-microsecond latency
if let Err(e) = processor.capture_event(trading_event).await {
error!("Failed to capture trading event: {}", e);
self.metrics.increment_event_errors();
}
}
}
Ok(())
}
/// Convert processed message to trading event
fn convert_to_trading_event(&self, msg: ProcessedMessage) -> Result<TradingEvent> {
match msg {
ProcessedMessage::Trade {
symbol,
timestamp,
price,
size,
trade_id,
..
} => Ok(TradingEvent::OrderExecuted {
trade_id: trade_id.unwrap_or_default(),
symbol,
quantity: Decimal::from(size),
price: Decimal::from_f64(price.to_f64()).unwrap_or(Decimal::ZERO),
timestamp,
sequence_number: None,
metadata: None,
}),
ProcessedMessage::Quote {
symbol, timestamp, ..
} => Ok(TradingEvent::SystemEvent {
event_type: SystemEventType::MarketDataFeed,
message: format!("Quote update for {}", symbol),
level: EventLevel::Info,
timestamp,
sequence_number: None,
metadata: None,
}),
ProcessedMessage::OrderBook {
symbol, timestamp, ..
} => Ok(TradingEvent::SystemEvent {
event_type: SystemEventType::MarketDataFeed,
message: format!("OrderBook update for {}", symbol),
level: EventLevel::Info,
timestamp,
sequence_number: None,
metadata: None,
}),
_ => Err(DataError::Conversion(
"Unsupported message type for trading event".to_string(),
)),
}
}
/// Get performance metrics
pub fn get_metrics(&self) -> DbnParserMetricsSnapshot {
self.metrics.get_snapshot()
}
/// Parse MBP-10 file and aggregate into order book snapshots
///
/// # Arguments
/// * `path` - Path to DBN file containing MBP-10 data
///
/// # Returns
/// Vector of aggregated 10-level order book snapshots
///
/// # Example
/// ```no_run
/// use data::providers::databento::dbn_parser::DbnParser;
///
/// # async fn example() -> anyhow::Result<()> {
/// let parser = DbnParser::new()?;
/// let snapshots = parser.parse_mbp10_file("test_data/ES.FUT.mbp10.dbn").await?;
/// println!("Loaded {} snapshots", snapshots.len());
/// # Ok(())
/// # }
/// ```
pub async fn parse_mbp10_file<P: AsRef<Path>>(&self, path: P) -> Result<Vec<Mbp10Snapshot>> {
use std::fs::File;
use std::io::BufReader;
let path = path.as_ref();
info!("📖 Parsing MBP-10 file: {:?}", path);
// 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<dyn std::io::Read> = 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))
})?;
// Read metadata
let metadata = decoder.metadata();
let symbol = metadata
.symbols
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
info!(" Symbol: {}, Schema: {:?}", symbol, metadata.schema);
// Track snapshot aggregation
// MBP-10 messages are incremental updates, so we need to aggregate them into full snapshots
let mut current_snapshot = Mbp10Snapshot::empty(symbol.clone());
let mut snapshots = Vec::new();
let mut update_count = 0;
const SNAPSHOT_INTERVAL: usize = 100; // Create snapshot every 100 updates
// Decode all MBP-10 records
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
let record_enum = record.as_enum().map_err(|e| {
DataError::InvalidFormat(format!("Failed to convert record: {}", e))
})?;
if let RecordRefEnum::Mbp10(mbp10) = record_enum {
update_count += 1;
// Phase E.1 fix (2026-05-15): MBP-10 messages carry
// the FULL post-update top-10 book in
// `mbp10.levels: [BidAskPair; 10]` — not just the
// single update event. We copy the full top-10
// here so downstream consumers (OFI, microprice,
// FillModel L2/L3) see real data. Mirror of the
// parse_mbp10_streaming fix in the same file.
let is_bid = mbp10.side == b'B' as i8;
let action = OrderBookAction::from(mbp10.action as u8);
current_snapshot.update_level(
0, // Level index
action,
mbp10.price,
mbp10.size,
1, // Order count (not available in MBP-10 single update)
is_bid,
);
let max_lvl = mbp10.levels.len().min(current_snapshot.levels.len());
for lvl in 1..max_lvl {
current_snapshot.levels[lvl].bid_px = mbp10.levels[lvl].bid_px;
current_snapshot.levels[lvl].bid_sz = mbp10.levels[lvl].bid_sz;
current_snapshot.levels[lvl].bid_ct = mbp10.levels[lvl].bid_ct;
current_snapshot.levels[lvl].ask_px = mbp10.levels[lvl].ask_px;
current_snapshot.levels[lvl].ask_sz = mbp10.levels[lvl].ask_sz;
current_snapshot.levels[lvl].ask_ct = mbp10.levels[lvl].ask_ct;
}
current_snapshot.timestamp = mbp10.hd.ts_event;
current_snapshot.sequence = mbp10.sequence;
// Create snapshot periodically to reduce memory
if update_count % SNAPSHOT_INTERVAL == 0 {
snapshots.push(current_snapshot.clone());
if snapshots.len() % 1000 == 0 {
info!(" Progress: {} snapshots aggregated", snapshots.len());
}
}
self.metrics.increment_orderbook_processed();
}
},
Ok(None) => {
// End of stream
break;
},
Err(e) => {
return Err(DataError::InvalidFormat(format!(
"Failed to decode MBP-10 record: {}",
e
)));
},
}
}
// Add final snapshot if there are pending updates
if update_count % SNAPSHOT_INTERVAL != 0 {
snapshots.push(current_snapshot);
}
info!(
"✅ Parsed {} MBP-10 snapshots from {} updates",
snapshots.len(),
update_count
);
Ok(snapshots)
}
/// Stream MBP-10 records through a callback without materializing all snapshots.
///
/// This is significantly faster than `parse_mbp10_file` for large files because it
/// avoids allocating and cloning millions of snapshots. The callback receives each
/// aggregated snapshot by reference at the configured interval.
///
/// # Arguments
///
/// * `path` - Path to the .dbn or .dbn.zst file
/// * `snapshot_interval` - Create a snapshot every N raw updates (e.g. 100)
/// * `filter` - Instrument selection strategy. See [`InstrumentFilter`] for variants.
/// ES.FUT files from databento are parent-symbol expansions and contain ~14 distinct
/// instrument_ids per file (one per contract month). Without filtering, K-window
/// labels span contract boundaries and produce $5000+ ΔP artifacts.
/// * `callback` - Called with each aggregated snapshot (by reference, not cloned)
///
/// # Returns
///
/// Total number of snapshots processed.
pub fn parse_mbp10_streaming<P, F>(
&self,
path: P,
snapshot_interval: usize,
filter: InstrumentFilter,
mut callback: F,
) -> Result<usize>
where
P: AsRef<Path>,
F: FnMut(&Mbp10Snapshot),
{
let path = path.as_ref();
// FrontMonth is two-pass: pass 1 detects the dominant id and validates
// it resolves to an ES contract via SymbolMapping records; pass 2
// streams emission. Resolve to Id(winning_id) so the hot loop below
// stays branch-free.
let resolved_filter = match filter {
InstrumentFilter::FrontMonth => {
let winning_id = self.detect_front_month_id(path)?;
InstrumentFilter::Id(winning_id)
}
other => other,
};
let id_filter: Option<u32> = match resolved_filter {
InstrumentFilter::All => None,
InstrumentFilter::Id(id) => Some(id),
InstrumentFilter::FrontMonth => unreachable!("resolved above"),
};
use std::fs::File;
use std::io::BufReader;
let file = File::open(path)?;
let is_zstd = path.to_string_lossy().ends_with(".dbn.zst");
let reader: Box<dyn std::io::Read> = 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))
})?;
let symbol = decoder
.metadata()
.symbols
.first()
.map(|s| s.to_string())
.unwrap_or_else(|| "UNKNOWN".to_string());
let mut current_snapshot = Mbp10Snapshot::empty(symbol);
let mut update_count: usize = 0;
let mut snapshot_count: usize = 0;
// Diagnostic counters: how many records matched vs. were skipped by
// the instrument_id filter. Logged at end-of-parse so multi-instrument
// DBN files are visible in training-job stdout.
let mut kept: u64 = 0;
let mut skipped: u64 = 0;
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
let record_enum = record.as_enum().map_err(|e| {
DataError::InvalidFormat(format!("Failed to convert record: {}", e))
})?;
if let RecordRefEnum::Mbp10(mbp10) = record_enum {
if let Some(filter_id) = id_filter {
if mbp10.hd.instrument_id != filter_id {
skipped += 1;
continue;
}
}
kept += 1;
update_count += 1;
let is_bid = mbp10.side == b'B' as i8;
let action = OrderBookAction::from(mbp10.action as u8);
current_snapshot.update_level(
0,
action,
mbp10.price,
mbp10.size,
1,
is_bid,
);
// Phase E.1 fix (2026-05-15): copy the full top-10
// post-update book snapshot from `mbp10.levels[1..]`
// into `current_snapshot.levels[1..]`. Without this,
// levels[1..10] stayed at default-empty, so downstream
// consumers (OFI calculator's L2-L5 reads, microprice
// at `snapshot.levels[1]`, FillModel L2/L3 distributions)
// received zeros for everything below L1. The dbn
// crate's `Mbp10Msg` carries the full 10-level
// post-update book in `levels: [BidAskPair; 10]`;
// previously only the single update event's
// (price, size) was captured (into level 0 via
// `update_level`).
//
// Field-by-field copy preserves the existing scale
// convention (raw 1e9 fixed-point i64; readers apply
// 1e-9 via `BidAskPair::price_to_f64` or local
// `raw_price_to_f32` workarounds).
let max_lvl = mbp10.levels.len().min(current_snapshot.levels.len());
for lvl in 1..max_lvl {
current_snapshot.levels[lvl].bid_px = mbp10.levels[lvl].bid_px;
current_snapshot.levels[lvl].bid_sz = mbp10.levels[lvl].bid_sz;
current_snapshot.levels[lvl].bid_ct = mbp10.levels[lvl].bid_ct;
current_snapshot.levels[lvl].ask_px = mbp10.levels[lvl].ask_px;
current_snapshot.levels[lvl].ask_sz = mbp10.levels[lvl].ask_sz;
current_snapshot.levels[lvl].ask_ct = mbp10.levels[lvl].ask_ct;
}
current_snapshot.timestamp = mbp10.hd.ts_event;
current_snapshot.sequence = mbp10.sequence;
if update_count % snapshot_interval == 0 {
callback(&current_snapshot);
snapshot_count += 1;
}
}
}
Ok(None) => break,
Err(e) => {
return Err(DataError::InvalidFormat(format!(
"Failed to decode MBP-10 record: {}",
e
)));
}
}
}
// Final partial snapshot
if update_count % snapshot_interval != 0 {
callback(&current_snapshot);
snapshot_count += 1;
}
if let Some(filter_id) = id_filter {
tracing::info!(
kept,
skipped,
filter = filter_id,
path = %path.display(),
"instrument filter applied"
);
}
Ok(snapshot_count)
}
/// First pass for [`InstrumentFilter::FrontMonth`]: stream-decodes the DBN
/// file once to identify the most-frequent `instrument_id` (the front-month
/// contract has the bulk of trading volume) and validates that its
/// resolved symbol matches an ES futures contract pattern using
/// SymbolMapping records.
///
/// Returns the winning instrument_id on success. Errors when:
/// - The file contains no MBP-10 records.
/// - The dominant id cannot be resolved to a symbol via SymbolMapping
/// records (suggests a malformed or non-databento DBN file).
/// - The resolved symbol does not match the ES contract pattern
/// (suggests a calendar spread or non-ES instrument is dominating —
/// loud failure so the caller surfaces the data anomaly).
fn detect_front_month_id(&self, path: &Path) -> Result<u32> {
use std::collections::HashMap;
use std::fs::File;
use std::io::BufReader;
let file = File::open(path)?;
let is_zstd = path.to_string_lossy().ends_with(".dbn.zst");
let reader: Box<dyn std::io::Read> = 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))
})?;
let mut counts: HashMap<u32, u64> = HashMap::new();
let mut id_to_symbol: HashMap<u32, String> = HashMap::new();
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
let record_enum = record.as_enum().map_err(|e| {
DataError::InvalidFormat(format!("Failed to convert record: {}", e))
})?;
match record_enum {
RecordRefEnum::Mbp10(mbp10) => {
*counts.entry(mbp10.hd.instrument_id).or_insert(0) += 1;
}
RecordRefEnum::SymbolMapping(sym) => {
if let Ok(out) = sym.stype_out_symbol() {
id_to_symbol
.insert(sym.hd.instrument_id, out.to_string());
}
}
_ => {}
}
}
Ok(None) => break,
Err(e) => {
return Err(DataError::InvalidFormat(format!(
"front-month detect: decode failed: {}",
e
)));
}
}
}
let (winner_id, winner_count) = counts
.iter()
.max_by_key(|(_, c)| *c)
.map(|(id, c)| (*id, *c))
.ok_or_else(|| {
DataError::InvalidFormat(format!(
"front-month detect: no MBP-10 records in {}",
path.display()
))
})?;
let total: u64 = counts.values().sum();
// Soft-validate via SymbolMapping records when present. Databento
// historical bulk files often emit mappings only in the metadata
// header (date-range form) and skip in-stream SymbolMappingMsg
// entirely; in that case we trust the dominant-id (volume leader =
// front-month) and proceed. When the in-stream symbol IS present,
// we check it against the ES contract regex and warn (not fail) on
// mismatch so calendar-spread anomalies surface in logs without
// blocking training.
let winner_symbol = id_to_symbol.get(&winner_id).cloned();
let es_re = regex::Regex::new(r"^ES[FGHJKMNQUVXZ]\d{1,2}$")
.expect("static ES futures regex compiles");
let symbol_status: &str = match &winner_symbol {
Some(sym) if es_re.is_match(sym) => "es-contract",
Some(_) => "symbol-mismatch",
None => "no-streaming-mapping",
};
if symbol_status == "symbol-mismatch" {
warn!(
instrument_id = winner_id,
symbol = %winner_symbol.as_deref().unwrap_or(""),
path = %path.display(),
"front-month dominant id resolves to a non-ES symbol via SymbolMapping; \
proceeding anyway (calendar spread or unexpected contract?)"
);
}
info!(
instrument_id = winner_id,
symbol = %winner_symbol.as_deref().unwrap_or("(metadata-only)"),
symbol_status,
count = winner_count,
total = total,
distinct_ids = counts.len(),
path = %path.display(),
"front-month detected"
);
Ok(winner_id)
}
}
/// Strategy for selecting which `instrument_id` records to keep when streaming
/// MBP-10 from a DBN file. ES.FUT parent-expanded files contain ~14 distinct
/// contract months in one stream; without filtering, downstream K-window
/// labels span contract boundaries and produce ΔP artifacts at the rolls.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstrumentFilter {
/// Emit all MBP-10 records regardless of `instrument_id`. Preserves the
/// pre-refactor behavior for callers that need multi-instrument streams
/// (e.g., DQN training that doesn't compute multi-step labels).
All,
/// Emit only records where `record.hd.instrument_id == id`. Fails silently
/// for files where the configured id never appears (caller must validate).
Id(u32),
/// Two-pass: detect the most-frequent `instrument_id` in the file (volume
/// leader = front-month for ES.FUT), validate it resolves to a valid ES
/// contract via SymbolMapping records, then emit only its records.
/// Self-tuning across quarterly contract rolls.
FrontMonth,
}
/// Processed message types from DBN parsing
#[derive(Debug, Clone)]
pub enum ProcessedMessage {
/// Trade tick message
Trade {
/// Trading symbol
symbol: String,
/// Event timestamp
timestamp: HardwareTimestamp,
/// Trade price
price: Price,
/// Trade size
size: Decimal,
/// Trade side (buy/sell)
side: OrderSide,
/// Optional trade identifier
trade_id: Option<String>,
/// Trade conditions
conditions: Vec<String>,
},
/// Quote (BBO) message
Quote {
/// Trading symbol
symbol: String,
/// Event timestamp
timestamp: HardwareTimestamp,
/// Bid price
bid: Option<Price>,
/// Ask price
ask: Option<Price>,
/// Bid size
bid_size: Option<Decimal>,
/// Ask size
ask_size: Option<Decimal>,
/// Exchange identifier
exchange: Option<String>,
},
/// Order book depth update
OrderBook {
/// Trading symbol
symbol: String,
/// Event timestamp
timestamp: HardwareTimestamp,
/// Price level
price: Price,
/// Size at level
size: Decimal,
/// Side (bid/ask)
side: OrderSide,
/// Action (add/modify/cancel)
action: OrderBookAction,
/// Depth level
level: usize,
/// Optional order ID
order_id: Option<String>,
},
/// OHLCV bar message
Ohlcv {
/// Trading symbol
symbol: String,
/// Bar timestamp
timestamp: HardwareTimestamp,
/// Open price
open: Price,
/// High price
high: Price,
/// Low price
low: Price,
/// Close price
close: Price,
/// Volume
volume: Decimal,
},
/// Status message
Status {
/// Status timestamp
timestamp: HardwareTimestamp,
/// Status message
message: String,
},
}
// OrderBookAction is now imported from mbp10 module (line 23)
// Removed duplicate definition to avoid conflict
/// Performance metrics for DBN parser
#[derive(Debug)]
pub struct DbnParserMetrics {
messages_parsed: AtomicU64,
trades_processed: AtomicU64,
quotes_processed: AtomicU64,
orderbook_processed: AtomicU64,
bars_processed: AtomicU64,
unknown_messages: AtomicU64,
event_errors: AtomicU64,
parse_latency_sum_ns: AtomicU64,
parse_latency_count: AtomicU64,
per_tick_latency_sum_ns: AtomicU64,
per_tick_latency_count: AtomicU64,
vwap_sum: AtomicU64, // Store as u64 (scaled by 10000)
vwap_count: AtomicU64,
}
impl DbnParserMetrics {
pub fn new() -> Self {
Self {
messages_parsed: AtomicU64::new(0),
trades_processed: AtomicU64::new(0),
quotes_processed: AtomicU64::new(0),
orderbook_processed: AtomicU64::new(0),
bars_processed: AtomicU64::new(0),
unknown_messages: AtomicU64::new(0),
event_errors: AtomicU64::new(0),
parse_latency_sum_ns: AtomicU64::new(0),
parse_latency_count: AtomicU64::new(0),
per_tick_latency_sum_ns: AtomicU64::new(0),
per_tick_latency_count: AtomicU64::new(0),
vwap_sum: AtomicU64::new(0),
vwap_count: AtomicU64::new(0),
}
}
pub fn increment_messages_parsed(&self) {
self.messages_parsed.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_trades_processed(&self) {
self.trades_processed.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_quotes_processed(&self) {
self.quotes_processed.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_orderbook_processed(&self) {
self.orderbook_processed.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_bars_processed(&self) {
self.bars_processed.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_unknown_messages(&self) {
self.unknown_messages.fetch_add(1, Ordering::Relaxed);
}
pub fn increment_event_errors(&self) {
self.event_errors.fetch_add(1, Ordering::Relaxed);
}
pub fn record_parse_latency(&self, latency_ns: u64) {
self.parse_latency_sum_ns
.fetch_add(latency_ns, Ordering::Relaxed);
self.parse_latency_count.fetch_add(1, Ordering::Relaxed);
}
pub fn record_per_tick_latency(&self, latency_ns: u64) {
self.per_tick_latency_sum_ns
.fetch_add(latency_ns, Ordering::Relaxed);
self.per_tick_latency_count.fetch_add(1, Ordering::Relaxed);
}
pub fn record_vwap(&self, vwap: f64) {
let scaled_vwap = (vwap * 10000.0) as u64;
self.vwap_sum.fetch_add(scaled_vwap, Ordering::Relaxed);
self.vwap_count.fetch_add(1, Ordering::Relaxed);
}
pub fn get_snapshot(&self) -> DbnParserMetricsSnapshot {
let parse_count = self.parse_latency_count.load(Ordering::Relaxed);
let avg_parse_latency_ns = if parse_count > 0 {
self.parse_latency_sum_ns.load(Ordering::Relaxed) / parse_count
} else {
0
};
let tick_count = self.per_tick_latency_count.load(Ordering::Relaxed);
let avg_per_tick_latency_ns = if tick_count > 0 {
self.per_tick_latency_sum_ns.load(Ordering::Relaxed) / tick_count
} else {
0
};
let vwap_count = self.vwap_count.load(Ordering::Relaxed);
let avg_vwap = if vwap_count > 0 {
(self.vwap_sum.load(Ordering::Relaxed) / vwap_count) as f64 / 10000.0
} else {
0.0
};
DbnParserMetricsSnapshot {
messages_parsed: self.messages_parsed.load(Ordering::Relaxed),
trades_processed: self.trades_processed.load(Ordering::Relaxed),
quotes_processed: self.quotes_processed.load(Ordering::Relaxed),
orderbook_processed: self.orderbook_processed.load(Ordering::Relaxed),
bars_processed: self.bars_processed.load(Ordering::Relaxed),
unknown_messages: self.unknown_messages.load(Ordering::Relaxed),
event_errors: self.event_errors.load(Ordering::Relaxed),
avg_parse_latency_ns,
avg_per_tick_latency_ns,
avg_vwap,
}
}
}
/// Snapshot of DBN parser metrics
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DbnParserMetricsSnapshot {
pub messages_parsed: u64,
pub trades_processed: u64,
pub quotes_processed: u64,
pub orderbook_processed: u64,
pub bars_processed: u64,
pub unknown_messages: u64,
pub event_errors: u64,
pub avg_parse_latency_ns: u64,
pub avg_per_tick_latency_ns: u64,
pub avg_vwap: f64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_dbn_message_sizes() {
// Verify packed struct sizes for zero-copy parsing
// DbnMessageHeader: 16 bytes (2+1+1+4+8)
assert_eq!(size_of::<DbnMessageHeader>(), 16);
// DbnTradeMessage: 38 bytes (header:16 + price:8 + size:4 + action:1 + side:1 + flags:2 + depth:1 + sequence:4 + padding:1)
assert_eq!(size_of::<DbnTradeMessage>(), 38);
// DbnQuoteMessage: 50 bytes (header:16 + bid_px:8 + ask_px:8 + bid_sz:4 + ask_sz:4 + bid_ct:1 + ask_ct:1 + flags:2 + sequence:4 + padding:2)
assert_eq!(size_of::<DbnQuoteMessage>(), 50);
// DbnOrderBookMessage: 48 bytes
assert_eq!(size_of::<DbnOrderBookMessage>(), 48);
// DbnOhlcvMessage: 56 bytes
assert_eq!(size_of::<DbnOhlcvMessage>(), 56);
}
#[test]
fn test_dbn_parser_creation() {
let parser = DbnParser::new();
assert!(parser.is_ok());
let parser = parser.unwrap();
let metrics = parser.get_metrics();
assert_eq!(metrics.messages_parsed, 0);
}
#[tokio::test]
async fn test_symbol_mapping() {
let parser = DbnParser::new().unwrap();
let mut mapping = std::collections::HashMap::new();
mapping.insert(1, "AAPL".to_string());
mapping.insert(2, "MSFT".to_string());
parser.update_symbol_map(mapping);
assert_eq!(parser.get_symbol(1), "AAPL");
assert_eq!(parser.get_symbol(2), "MSFT");
assert_eq!(parser.get_symbol(999), "UNKNOWN_999");
}
#[test]
fn test_price_scaling() {
let parser = DbnParser::new().unwrap();
let mut scales = std::collections::HashMap::new();
scales.insert(1, 4); // 4 decimal places
scales.insert(2, 2); // 2 decimal places
parser.update_price_scales(scales);
let price1 = parser.scale_price(123450, 1).unwrap(); // 123450 / 10^4 = 12.3450
let price2 = parser.scale_price(12345, 2).unwrap(); // 12345 / 10^2 = 123.45
// Price::from_f64() stores values with 8 decimal places (multiplies by 100_000_000)
assert_eq!(price1, Price::from_f64(12.3450).unwrap());
assert_eq!(price2, Price::from_f64(123.45).unwrap());
}
}