//! 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. /// /// When a `.FUT` parent symbol is used, the DBN file may contain duplicate /// bars at the same timestamp from overlapping contracts (e.g. ESH5, ESM5 at /// roll dates). We deduplicate by timestamp, keeping the bar with the highest /// volume (front-month contract). fn decode_ohlcv_records( decoder: &mut dbn::decode::dbn::Decoder, path: &Path, ) -> Result> { 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; 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 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 { let timestamp = nanos_to_datetime(ts_nanos); 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(); if deduped > 0 { info!( " Deduplicated {}/{} bars by timestamp from {}", deduped, total_records, path.display() ); } 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); info!("Total bars loaded for {}: {}", symbol, all_bars.len()); Ok(all_bars) } /// List subdirectory names for error messages. pub fn list_subdirs(dir: &Path) -> 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 }