Direct inspection of ES.FUT_2024-Q1.dbn.zst (databento python client, offline
kubectl-cp from PVC) pinpoints the source of the 8,799 corrupt bars that
sanitize_bars (Fix 25) caught at the bar-level gate.
Q1 file content:
Outrights (correct): ESH4/ESM4/ESU4/ESZ4/ESH5 — 43,353 records (76.87%)
price range $5,063–$5,478
Spreads (poison): ESH4-ESM4/ESM4-ESU4/... — 13,048 records (23.13%)
price range $47.30–$219.70
Calendar spreads trade at the price DIFFERENCE between adjacent contracts
(~$60-150 cost-of-carry roll basis). Databento's stype_in=parent resolution
for ES.FUT returns BOTH outrights AND every spread combination in the same
DBN stream. The legacy decoder keyed only by ts_event + dedup-by-volume;
during low-volume overnight windows + active rollover periods, spread bars
beat the outright on volume and survived the dedup. 780 spread bars
survived in Q1 alone; ~8,800 across 2024-2026.
Fix: build dbn::TsSymbolMap from metadata once, resolve each record's
instrument_id → symbol, skip any symbol containing `-` (spread separator).
Same-ts dedup-by-volume continues to handle legitimate front/back-month
overlap among outrights.
Adds `time = "0.3"` to ml/Cargo.toml (dbn::TsSymbolMap uses time::Date).
Why complementary to the Fix 25 sanitize_bars gate:
- Spread filter (this commit) catches the cause precisely by symbol pattern,
but only for instruments where parent-symbol resolution is the source.
- sanitize_bars catches the symptom universally by close-ratio bound, but
can't distinguish a low-basis spread from a fast-moving outright in
extreme cases.
Both layers active: spread filter dispatches at parser level, sanitize as
defense-in-depth backstop for unknown future contamination shapes.
Validation: `cargo check -p ml --example train_baseline_rl` clean.
Refs: Bug 2 chain (#191, #194, label_scale=5443 leaks), today's
sanitize_bars find (Fix 25). Closes the contamination-source investigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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<Vec<PathBuf>> {
|
||
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<PathBuf>) -> 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<Vec<OHLCVBar>> {
|
||
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<R: std::io::Read>(
|
||
decoder: &mut dbn::decode::dbn::Decoder<R>,
|
||
path: &Path,
|
||
) -> Result<Vec<OHLCVBar>> {
|
||
use dbn::decode::DbnMetadata;
|
||
use dbn::OhlcvMsg;
|
||
use std::collections::BTreeMap;
|
||
|
||
let price_scale = 1e-9_f64;
|
||
let mut by_ts: BTreeMap<u64, OHLCVBar> = 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::<OhlcvMsg>()
|
||
.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<OHLCVBar> = 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`<Utc>.
|
||
fn nanos_to_datetime(nanos: u64) -> chrono::DateTime<Utc> {
|
||
#[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<Vec<OHLCVBar>> {
|
||
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<low,
|
||
// non-finite values, or close moves outside [0.5×, 2.0×] of the previous
|
||
// bar (catches DBN parse glitches, broker tick errors, near-zero-open bars
|
||
// that produce extreme log returns downstream). This is the FIRST defense
|
||
// line; subsequent layers (`safe_log_return` clamp, `validate_features`
|
||
// pre-norm bound, `NormStats::normalize` post-norm clamp,
|
||
// `validate_normalized_features` final gate) are defense-in-depth backstops
|
||
// for shapes this filter doesn't anticipate.
|
||
let pre = all_bars.len();
|
||
all_bars = sanitize_bars(all_bars);
|
||
let dropped = pre.saturating_sub(all_bars.len());
|
||
if dropped > 0 {
|
||
warn!(
|
||
"load_all_bars: dropped {} corrupt bars out of {} for symbol {} \
|
||
(non-positive OHLC, high<low, or extreme close-to-prev ratio)",
|
||
dropped, pre, symbol
|
||
);
|
||
}
|
||
info!("Total bars loaded for {} (post-sanitize): {}", symbol, all_bars.len());
|
||
Ok(all_bars)
|
||
}
|
||
|
||
/// Drop bars that fail OHLC sanity checks. Sequential — each bar is validated
|
||
/// against its sorted predecessor (`prev.close → bar.open` ratio).
|
||
fn sanitize_bars(bars: Vec<OHLCVBar>) -> Vec<OHLCVBar> {
|
||
let mut out: Vec<OHLCVBar> = Vec::with_capacity(bars.len());
|
||
let mut prev_close: Option<f64> = 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<low at {} (h={} l={})",
|
||
bar.timestamp, bar.high, bar.low);
|
||
continue;
|
||
}
|
||
// Cross-bar invariant: close moves within ±100% of previous close.
|
||
// Real markets stay well inside ±10% per bar; ±100% catches genuine
|
||
// corruption while not rejecting any legitimate move (futures gap
|
||
// limits are far below this).
|
||
if let Some(prev) = prev_close {
|
||
let ratio = bar.close / prev;
|
||
if !ratio.is_finite() || !(0.5..=2.0).contains(&ratio) {
|
||
warn!("sanitize: drop extreme close ratio at {} (close={} prev_close={} ratio={:.3e})",
|
||
bar.timestamp, bar.close, prev, ratio);
|
||
continue;
|
||
}
|
||
}
|
||
prev_close = Some(bar.close);
|
||
out.push(bar);
|
||
}
|
||
out
|
||
}
|
||
|
||
/// List subdirectory names for error messages.
|
||
pub fn list_subdirs(dir: &Path) -> Vec<String> {
|
||
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
|
||
}
|