Files
foxhunt/crates/ml-features/src/trades_loader.rs
jgrusewski e7ce4395e8 perf(precompute): parallel trades load + predecoded sidecar cache
Flamegraph of precompute_features on 1Q ES showed 62% of CPU time in
zstd decompression, 6% in DBN FSM parsing, and only 2% in the actual
feature math — single-threaded zstd was the bottleneck, not compute.

Two fixes:

1. Per-quarter parallelism on the volume-bar trades loop (was sequential
   `for file in &trade_files`); brings it in line with the OFI path that
   already used par_iter.

2. Predecoded sidecar cache in `crates/ml-features/src/predecoded.rs`:
   first call to a `.dbn.zst` writes a bincode'd Vec<Mbp10Snapshot> or
   Vec<DbnTrade> under `<output_dir>/predecoded/`. Subsequent calls
   deserialize the sidecar and skip zstd entirely. An mtime+size header
   self-invalidates the sidecar when the source changes — no manual
   flush needed when a quarter is re-downloaded.

   Local 1Q ES results:
   - cold (writes sidecar): 40.7s (was 39.3s; +1.4s for write)
   - warm (HIT):             4.7s  (8.7× faster)
   - zstd in flat perf:      62% → 0% of CPU samples
   - sidecar disk per Q:     ~150MB

The sidecar layer also auto-dedupes within a single run: the OFI section
re-loads trades, but the second call hits the sidecar that the
volume-bar section wrote moments earlier.

CLI: `--rebuild-predecoded` purges sidecars for cold-path testing or
after a wire-format change to Mbp10Snapshot / DbnTrade. Sidecars also
self-invalidate on format-version mismatch so old caches are skipped
silently rather than mis-deserializing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 14:11:26 +02:00

369 lines
14 KiB
Rust

//! Trade Data Loader for DBN `Schema::Trades`
//!
//! Loads Databento trade records from `.dbn` or `.dbn.zst` files and provides
//! time-windowed access for feeding into `OFICalculator::feed_trade()` (VPIN,
//! Kyle's Lambda).
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
use dbn::decode::{DbnDecoder, DecodeRecordRef};
use dbn::RecordRefEnum;
use serde::{Deserialize, Serialize};
use crate::MLError;
/// A single parsed trade from a DBN file.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct DbnTrade {
/// Event timestamp in nanoseconds since Unix epoch
pub timestamp: u64,
/// Trade price (f64, converted from i64 scaled by 1e-9)
pub price: f64,
/// Trade size in contracts
pub volume: u64,
/// True if buyer-initiated (side == 'B'), false if seller-initiated
pub is_buy: bool,
/// Databento instrument_id — identifies which contract month this trade belongs to
pub instrument_id: u32,
}
/// Load all trades from a `.dbn` or `.dbn.zst` file (synchronous).
///
/// Auto-detects zstd compression by file extension. Returns trades sorted
/// by timestamp (as they appear in the file — DBN files are already ordered).
pub fn load_trades_sync(file_path: &Path) -> Result<Vec<DbnTrade>, MLError> {
let file = File::open(file_path).map_err(|e| {
MLError::InsufficientData(format!("Failed to open trade file {:?}: {}", file_path, e))
})?;
let is_zstd = file_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| {
MLError::InsufficientData(format!("Failed to create zstd decoder: {}", e))
})?,
)
} else {
Box::new(BufReader::new(file))
};
let mut decoder = DbnDecoder::new(reader).map_err(|e| {
MLError::InsufficientData(format!("Failed to create DBN decoder: {}", e))
})?;
let mut trades = Vec::new();
loop {
match decoder.decode_record_ref() {
Ok(Some(record)) => {
let record_enum = record.as_enum().map_err(|e| {
MLError::InsufficientData(format!("Failed to convert DBN record: {}", e))
})?;
if let RecordRefEnum::Trade(trade) = record_enum {
// Prices are i64 scaled by 1e-9 per DBN specification
let price_f64 = trade.price as f64 * 1e-9;
// Side: trade.side == b'B' as i8 => Buy, b'A' as i8 => Sell
let is_buy = trade.side == b'B' as i8;
trades.push(DbnTrade {
timestamp: trade.hd.ts_event,
price: price_f64,
volume: u64::from(trade.size),
is_buy,
instrument_id: trade.hd.instrument_id,
});
}
}
Ok(None) => break,
Err(e) => {
return Err(MLError::InsufficientData(format!(
"DBN decode error: {}",
e
)));
}
}
}
// Sort by timestamp (should already be ordered, but ensure correctness)
trades.sort_by_key(|t| t.timestamp);
Ok(trades)
}
/// Get the sub-slice of trades within `[bar_start_ns, bar_end_ns)`.
///
/// Uses binary search for O(log n) lookup on the pre-sorted trades slice.
pub fn get_trades_for_bar(trades: &[DbnTrade], bar_start_ns: u64, bar_end_ns: u64) -> &[DbnTrade] {
if trades.is_empty() {
return &[];
}
// First trade with timestamp >= bar_start_ns
let start = trades.partition_point(|t| t.timestamp < bar_start_ns);
// First trade with timestamp >= bar_end_ns
let end = trades.partition_point(|t| t.timestamp < bar_end_ns);
trades.get(start..end).unwrap_or(&[])
}
/// Default volume bar size: 100 contracts per bar for ES futures.
pub const DEFAULT_VOLUME_BAR_SIZE: u64 = 100;
/// Build volume bars from a sorted slice of trades.
///
/// Aggregates trades until cumulative volume reaches `bar_size` contracts.
/// Each bar records OHLCV from the trades within that volume bucket.
/// Trades MUST be pre-sorted by timestamp and pre-filtered to a single instrument_id.
///
/// Returns bars sorted by timestamp (bar timestamp = last trade in bar).
pub fn build_volume_bars(trades: &[DbnTrade], bar_size: u64) -> Vec<ml_core::types::OHLCVBar> {
if trades.is_empty() || bar_size == 0 {
return Vec::new();
}
let mut bars = Vec::new();
let mut bar_open = 0.0_f64;
let mut bar_high = 0.0_f64;
let mut bar_low = 0.0_f64;
#[allow(unused_assignments)]
let mut bar_close = 0.0_f64;
let mut bar_volume: u64 = 0;
#[allow(unused_assignments)]
let mut bar_ts: u64 = 0;
for trade in trades {
if trade.price <= 0.0 || trade.volume == 0 {
continue;
}
if bar_volume == 0 {
bar_open = trade.price;
bar_high = trade.price;
bar_low = trade.price;
}
bar_high = bar_high.max(trade.price);
bar_low = bar_low.min(trade.price);
bar_close = trade.price;
bar_ts = trade.timestamp;
bar_volume += trade.volume;
if bar_volume >= bar_size {
let ts_secs = (bar_ts / 1_000_000_000) as i64;
let ts_nanos = (bar_ts % 1_000_000_000) as u32;
let timestamp = chrono::DateTime::<chrono::Utc>::from_timestamp(ts_secs, ts_nanos)
.unwrap_or_else(|| chrono::Utc::now());
bars.push(ml_core::types::OHLCVBar {
timestamp,
open: bar_open,
high: bar_high,
low: bar_low,
close: bar_close,
volume: bar_volume as f64,
});
bar_volume = 0;
}
}
bars
}
/// Filter trades to keep only the front-month contract (highest cumulative volume).
///
/// Databento DBN files contain trades from ALL contract months for a symbol.
/// This function groups trades by `instrument_id` and keeps only the instrument
/// with the highest total volume — the front-month continuous contract.
pub fn filter_front_month(trades: &[DbnTrade]) -> Vec<DbnTrade> {
if trades.is_empty() {
return Vec::new();
}
let mut volume_by_id: std::collections::HashMap<u32, u64> = std::collections::HashMap::new();
for trade in trades {
*volume_by_id.entry(trade.instrument_id).or_insert(0) += trade.volume;
}
let front_month_id = volume_by_id
.into_iter()
.max_by_key(|&(_, vol)| vol)
.map(|(id, _)| id)
.unwrap_or(0);
tracing::info!(
instrument_id = front_month_id,
"Front-month contract selected (highest volume)"
);
trades.iter()
.filter(|t| t.instrument_id == front_month_id)
.cloned()
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_trade(timestamp: u64, price: f64, volume: u64, is_buy: bool) -> DbnTrade {
DbnTrade {
timestamp,
price,
volume,
is_buy,
instrument_id: 0,
}
}
#[test]
fn test_get_trades_for_bar_empty() {
let trades: &[DbnTrade] = &[];
let result = get_trades_for_bar(trades, 1000, 2000);
assert!(result.is_empty());
}
#[test]
fn test_get_trades_for_bar_binary_search() {
let trades = vec![
make_trade(1000, 100.0, 10, true),
make_trade(2000, 101.0, 20, false),
make_trade(3000, 102.0, 30, true),
make_trade(4000, 103.0, 40, false),
make_trade(5000, 104.0, 50, true),
];
// Window [2000, 4000) should return trades at timestamps 2000 and 3000
let result = get_trades_for_bar(&trades, 2000, 4000);
assert_eq!(result.len(), 2);
assert_eq!(result.first().map(|t| t.timestamp), Some(2000));
assert_eq!(result.last().map(|t| t.timestamp), Some(3000));
// Window [1000, 6000) should return all 5 trades
let all = get_trades_for_bar(&trades, 1000, 6000);
assert_eq!(all.len(), 5);
// Window [5001, 6000) should return nothing (past all trades)
let none = get_trades_for_bar(&trades, 5001, 6000);
assert!(none.is_empty());
// Window [0, 1000) should return nothing (before all trades)
let before = get_trades_for_bar(&trades, 0, 1000);
assert!(before.is_empty());
}
#[test]
fn test_build_volume_bars_basic() {
let trades = vec![
DbnTrade { timestamp: 1000, price: 5100.0, volume: 30, is_buy: true, instrument_id: 1 },
DbnTrade { timestamp: 2000, price: 5101.0, volume: 40, is_buy: false, instrument_id: 1 },
DbnTrade { timestamp: 3000, price: 5099.0, volume: 30, is_buy: true, instrument_id: 1 },
DbnTrade { timestamp: 4000, price: 5102.0, volume: 50, is_buy: true, instrument_id: 1 },
DbnTrade { timestamp: 5000, price: 5103.0, volume: 50, is_buy: false, instrument_id: 1 },
];
let bars = super::build_volume_bars(&trades, 100);
assert_eq!(bars.len(), 2, "Should produce 2 bars from 200 contracts");
assert!((bars[0].open - 5100.0).abs() < 0.01);
assert!((bars[0].high - 5101.0).abs() < 0.01);
assert!((bars[0].low - 5099.0).abs() < 0.01);
assert!((bars[0].close - 5099.0).abs() < 0.01);
assert!((bars[0].volume - 100.0).abs() < 0.01);
assert!((bars[1].open - 5102.0).abs() < 0.01);
assert!((bars[1].close - 5103.0).abs() < 0.01);
}
#[test]
fn test_build_volume_bars_large_trade() {
let trades = vec![
DbnTrade { timestamp: 1000, price: 5100.0, volume: 500, is_buy: true, instrument_id: 1 },
];
let bars = super::build_volume_bars(&trades, 100);
assert_eq!(bars.len(), 1);
assert!((bars[0].volume - 500.0).abs() < 0.01);
}
#[test]
fn test_build_volume_bars_empty() {
let bars = super::build_volume_bars(&[], 100);
assert!(bars.is_empty());
}
#[test]
fn test_build_volume_bars_partial_dropped() {
let trades = vec![
DbnTrade { timestamp: 1000, price: 5100.0, volume: 50, is_buy: true, instrument_id: 1 },
DbnTrade { timestamp: 2000, price: 5101.0, volume: 25, is_buy: false, instrument_id: 1 },
];
let bars = super::build_volume_bars(&trades, 100);
assert!(bars.is_empty(), "Partial bar should be dropped");
}
#[test]
fn test_filter_front_month() {
let trades = vec![
DbnTrade { timestamp: 1000, price: 5100.0, volume: 100, is_buy: true, instrument_id: 1 },
DbnTrade { timestamp: 2000, price: 5101.0, volume: 200, is_buy: false, instrument_id: 1 },
DbnTrade { timestamp: 1500, price: 5160.0, volume: 30, is_buy: true, instrument_id: 2 },
DbnTrade { timestamp: 2500, price: 5161.0, volume: 20, is_buy: false, instrument_id: 2 },
DbnTrade { timestamp: 3000, price: 61.25, volume: 10, is_buy: true, instrument_id: 3 },
];
let filtered = super::filter_front_month(&trades);
assert_eq!(filtered.len(), 2, "Should keep only front month (id=1)");
assert!(filtered.iter().all(|t| t.instrument_id == 1));
}
#[test]
fn test_filter_front_month_empty() {
let filtered = super::filter_front_month(&[]);
assert!(filtered.is_empty());
}
#[test]
fn test_filter_front_month_per_file_pattern() {
// Simulates quarterly roll: Q1 has front-month id=100, Q2 has id=200.
// If you filter globally, only one quarter survives (the one with more volume).
// Correct behavior: filter PER FILE, then concatenate both quarters.
let q1_trades = vec![
DbnTrade { timestamp: 1000, price: 5100.0, volume: 200, is_buy: true, instrument_id: 100 },
DbnTrade { timestamp: 2000, price: 5101.0, volume: 100, is_buy: false, instrument_id: 100 },
DbnTrade { timestamp: 1500, price: 5160.0, volume: 30, is_buy: true, instrument_id: 101 }, // back month
];
let q2_trades = vec![
DbnTrade { timestamp: 5000, price: 5200.0, volume: 300, is_buy: true, instrument_id: 200 },
DbnTrade { timestamp: 6000, price: 5201.0, volume: 150, is_buy: false, instrument_id: 200 },
DbnTrade { timestamp: 5500, price: 5260.0, volume: 20, is_buy: true, instrument_id: 201 }, // back month
];
// Per-file filtering (CORRECT): each file keeps its own front month
let q1_filtered = super::filter_front_month(&q1_trades);
let q2_filtered = super::filter_front_month(&q2_trades);
assert_eq!(q1_filtered.len(), 2, "Q1 should keep id=100 (2 trades)");
assert_eq!(q2_filtered.len(), 2, "Q2 should keep id=200 (2 trades)");
assert!(q1_filtered.iter().all(|t| t.instrument_id == 100));
assert!(q2_filtered.iter().all(|t| t.instrument_id == 200));
let combined_len = q1_filtered.len() + q2_filtered.len();
assert_eq!(combined_len, 4, "Per-file: 4 trades from both quarters");
// Global filtering (WRONG): merges all, picks single highest-volume id
let mut all_trades = q1_trades.clone();
all_trades.extend(q2_trades.clone());
let global_filtered = super::filter_front_month(&all_trades);
// id=200 has 450 volume vs id=100's 300 → global keeps only Q2
assert!(global_filtered.iter().all(|t| t.instrument_id == 200),
"Global filter incorrectly drops Q1 entirely");
assert_eq!(global_filtered.len(), 2, "Global filter loses Q1 data");
// This test documents WHY per-file filtering is required:
// global loses 50% of the data (one quarter).
assert!(combined_len > global_filtered.len(),
"Per-file ({combined_len}) must produce more data than global ({})",
global_filtered.len());
}
}