Adds a parent/child GitLab CI pipeline for ML model training: - Generator script produces per-model hyperopt/train/evaluate jobs - Parent pipeline (.gitlab-ci-training.yml) with manual trigger - NFS-backed ReadWriteMany PVC for shared training outputs - Hyperopt params wired into training binaries (DQN, PPO, TFT, Mamba2) - Shared DBN loader eliminates duplicate code across hyperopt adapters - Supervised hyperopt unified to DBN data (was parquet-only) Pipeline: hyperopt (4 models) → train (10 models) → evaluate ensemble Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
198 lines
6.8 KiB
Rust
198 lines
6.8 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`.
|
|
|
|
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.
|
|
///
|
|
/// 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<R: std::io::Read>(
|
|
decoder: &mut dbn::decode::dbn::Decoder<R>,
|
|
path: &Path,
|
|
) -> Result<Vec<OHLCVBar>> {
|
|
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;
|
|
|
|
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 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<OHLCVBar> = 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<Utc>.
|
|
fn nanos_to_datetime(nanos: u64) -> chrono::DateTime<Utc> {
|
|
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.
|
|
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);
|
|
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<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
|
|
}
|