//! Shared utilities for the baseline training/evaluation pipeline. //! //! Contains DBN file loading, OHLCV bar decoding with timestamp dedup, //! and cost helpers used by `train_baseline`, `evaluate_baseline`, and //! `hyperopt_baseline`. #[allow(dead_code)] pub mod completion; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use chrono::{TimeZone, Utc}; use tracing::{info, warn}; use dbn::decode::DecodeRecord; use ml::types::OHLCVBar; /// Recursively discover .dbn.zst files under `dir`. pub fn find_dbn_files(dir: &Path) -> Result> { let mut files = Vec::new(); if !dir.exists() { anyhow::bail!("Data directory does not exist: {}", dir.display()); } collect_dbn_files_recursive(dir, &mut files)?; files.sort(); Ok(files) } /// Recursive helper that walks the directory tree without `walkdir`. fn collect_dbn_files_recursive(dir: &Path, out: &mut Vec) -> Result<()> { let entries = std::fs::read_dir(dir) .with_context(|| format!("Cannot read directory: {}", dir.display()))?; for entry in entries { let dir_entry = entry.with_context(|| "Failed to read dir entry")?; let entry_path = dir_entry.path(); if entry_path.is_dir() { collect_dbn_files_recursive(&entry_path, out)?; } else if entry_path .file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.ends_with(".dbn.zst")) { out.push(entry_path); } else { // Skip non-.dbn.zst files } } Ok(()) } /// Load OHLCV bars from a single .dbn or .dbn.zst file using the `dbn` crate decoder. /// /// Reads `OhlcvMsg` records and converts them to [`OHLCVBar`]. fn load_bars_from_dbn(path: &Path) -> Result> { if path.to_string_lossy().ends_with(".dbn.zst") { let mut decoder = dbn::decode::dbn::Decoder::from_zstd_file(path) .with_context(|| format!("Failed to create zstd DBN decoder for {}", path.display()))?; decode_ohlcv_records(&mut decoder, path) } else { let file = std::fs::File::open(path) .with_context(|| format!("Cannot open DBN file: {}", path.display()))?; let buf = std::io::BufReader::new(file); let mut decoder = dbn::decode::dbn::Decoder::new(buf) .with_context(|| format!("Failed to create DBN decoder for {}", path.display()))?; decode_ohlcv_records(&mut decoder, path) } } /// Decode OHLCV records from an already-opened DBN decoder. /// /// **Calendar-spread filter:** when `stype_in=parent` is used to request /// `ES.FUT`, Databento returns BOTH outright contracts (`ESH4`, `ESM4`, ...) /// AND calendar-spread instruments (`ESH4-ESM4`, `ESM4-ESU4`, ...) in the /// same DBN stream. Spreads trade at the price *difference* between adjacent /// contracts (~$60-150 due to cost-of-carry) so their bars look like /// ridiculously low ES quotes (e.g. close=$61 vs prev=$5141). The /// `instrument_id → symbol` resolution comes from the DBN file's metadata /// `symbol_map()`; any symbol containing `-` is a spread and is rejected at /// parse time. Pre-fix this contamination was 23% of records on Q1 2024 ES /// and ~8800 spread bars survived dedup-by-volume across the full dataset /// because spreads occasionally carry higher minute-volume than the outright /// during low-liquidity overnight periods. /// /// **Same-timestamp dedup:** within outright records, multiple contracts /// (front-month + back-month, e.g. `ESH4` + `ESM4` during rollover week) /// can both report bars at the same `ts_event`. Keep the highest-volume bar /// at each timestamp — that's the active front month. fn decode_ohlcv_records( decoder: &mut dbn::decode::dbn::Decoder, path: &Path, ) -> Result> { use dbn::decode::DbnMetadata; use dbn::OhlcvMsg; use std::collections::BTreeMap; let price_scale = 1e-9_f64; let mut by_ts: BTreeMap = BTreeMap::new(); let mut total_records = 0_usize; let mut skipped_spread = 0_usize; // Build instrument_id → symbol resolver from the DBN metadata. This is the // authoritative source — the same map Databento's Python client uses for // its `symbol` column. Empty for files that lack symbology mappings (older // DBN versions or non-parent-symbol requests); in that case the spread // filter below silently no-ops, and the bar-level sanity gate in // `sanitize_bars` remains as a defense-in-depth backstop. let symbol_map = decoder .metadata() .symbol_map() .with_context(|| format!("Failed to build symbol map for {}", path.display()))?; while let Some(record) = decoder .decode_record::() .with_context(|| format!("Error decoding records from {}", path.display()))? { total_records += 1; let ts_nanos = record.hd.ts_event; let timestamp = nanos_to_datetime(ts_nanos); // Resolve symbol via TsSymbolMap (date + instrument_id → symbol). Drop // any record whose symbol contains a hyphen — those are calendar // spread instruments and their prices are not on the underlying's // scale. If the symbol can't be resolved (no mapping for this date), // skip the record rather than guess. let secs_since_epoch = (ts_nanos / 1_000_000_000) as i64; let date = time::OffsetDateTime::from_unix_timestamp(secs_since_epoch) .map(|dt| dt.date()) .ok(); let symbol = match date.and_then(|d| symbol_map.get(d, record.hd.instrument_id)) { Some(s) => s.as_str(), None => { skipped_spread += 1; continue; } }; if symbol.contains('-') { skipped_spread += 1; continue; } let volume = record.volume as f64; let existing_vol = by_ts.get(&ts_nanos).map(|b| b.volume).unwrap_or(0.0); if volume >= existing_vol { by_ts.insert( ts_nanos, OHLCVBar { timestamp, open: record.open as f64 * price_scale, high: record.high as f64 * price_scale, low: record.low as f64 * price_scale, close: record.close as f64 * price_scale, volume, }, ); } } let bars: Vec = by_ts.into_values().collect(); let deduped = total_records - bars.len() - skipped_spread; if skipped_spread > 0 || deduped > 0 { info!( " {}: {} records → {} bars (skipped {} spreads, deduped {} same-ts)", path.display(), total_records, bars.len(), skipped_spread, deduped, ); } Ok(bars) } /// Convert nanosecond UNIX timestamp to chrono `DateTime`. fn nanos_to_datetime(nanos: u64) -> chrono::DateTime { #[allow(clippy::integer_division)] let secs = (nanos / 1_000_000_000) as i64; let subsec_nanos = (nanos % 1_000_000_000) as u32; Utc.timestamp_opt(secs, subsec_nanos) .single() .unwrap_or_else(Utc::now) } /// Load all OHLCV bars for a single symbol from .dbn.zst files, sorted chronologically. /// /// Only loads files from `data_dir/symbol/` to avoid mixing different futures /// contracts (e.g. ES, NQ, 6E, ZN) into a single price series. #[allow(clippy::cognitive_complexity)] pub fn load_all_bars(data_dir: &Path, symbol: &str) -> Result> { let symbol_dir = data_dir.join(symbol); if !symbol_dir.exists() { anyhow::bail!( "Symbol directory does not exist: {}. Available: {:?}", symbol_dir.display(), list_subdirs(data_dir), ); } let dbn_files = find_dbn_files(&symbol_dir)?; if dbn_files.is_empty() { anyhow::bail!( "No .dbn.zst files found in {}. Run download_baseline first.", symbol_dir.display() ); } info!("Found {} .dbn.zst files for symbol {} in {}", dbn_files.len(), symbol, symbol_dir.display()); let mut all_bars = Vec::new(); for path in &dbn_files { match load_bars_from_dbn(path) { Ok(bars) => { info!(" {} -> {} bars", path.display(), bars.len()); all_bars.extend(bars); } Err(e) => { warn!(" Skipping {} -- {}", path.display(), e); } } } // Sort chronologically all_bars.sort_by_key(|b| b.timestamp); // Bar-level sanity gate. Drops bars with non-positive OHLC, high 0 { warn!( "load_all_bars: dropped {} corrupt bars out of {} for symbol {} \ (non-positive OHLC, high) -> Vec { let mut out: Vec = Vec::with_capacity(bars.len()); let mut prev_close: Option = None; for bar in bars { // Per-bar invariants if !(bar.open.is_finite() && bar.high.is_finite() && bar.low.is_finite() && bar.close.is_finite()) { warn!("sanitize: drop non-finite OHLC at {}", bar.timestamp); continue; } if bar.open <= 0.0 || bar.high <= 0.0 || bar.low <= 0.0 || bar.close <= 0.0 { warn!("sanitize: drop non-positive OHLC at {} (o={} h={} l={} c={})", bar.timestamp, bar.open, bar.high, bar.low, bar.close); continue; } if bar.high < bar.low { warn!("sanitize: drop high Vec { std::fs::read_dir(dir) .ok() .map(|entries| { entries .filter_map(|e| e.ok()) .filter(|e| e.path().is_dir()) .filter_map(|e| e.file_name().into_string().ok()) .collect() }) .unwrap_or_default() } /// Compute round-trip spread slippage in basis points for a given price. /// /// Uses the bid-ask spread model from `backtesting/src/slippage.rs`: /// half-spread = `tick_size` * `spread_ticks` / 2. Round-trip pays the full spread. /// Converted to bps: `spread_price` / price * `10_000`. pub fn spread_cost_bps(price: f64, tick_size: f64, spread_ticks: f64) -> f64 { if price.abs() < 1e-10 { return 0.0; } let spread_price = tick_size * spread_ticks; // full spread in price units spread_price / price * 10_000.0 }