Files
foxhunt/crates/ml-alpha/src/data/loader.rs
jgrusewski 3b1ed72eaa feat(ml-alpha): replace seq_len with MultiResolutionConfig in loader
Sequence builder now walks fine→coarse scales from the anchor's
forward edge, aggregating each window into one pseudo-snapshot
via aggregate_window. seq_len field deleted (total_positions()
replaces all reads). Greenfield — no legacy single-scale path.
External callers updated in subsequent tasks.
2026-05-22 20:20:11 +02:00

829 lines
39 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Phase A data loader.
//!
//! Reads predecoded MBP-10 sidecars (already on disk from earlier session
//! work — see `crates/ml-features/src/predecoded.rs`). Per spec Section 4:
//! yields multi-resolution windows of `Mbp10RawInput` (length =
//! `multi_resolution.total_positions()`) plus N_HORIZONS binary labels
//! computed via `multi_horizon_labels::generate_labels`.
//!
//! Memory discipline: snapshots stay on the host side here (CPU control
//! flow). The trainer (Task 13) uploads them into GPU buffers via the
//! `CfcTrunk::update_input_buffers` mapped-pinned path before each
//! captured-graph replay.
use std::path::PathBuf;
use anyhow::{Context, Result};
use data::providers::databento::mbp10::{BidAskPair, Mbp10Snapshot};
use ml_features::predecoded::load_or_predecode_mbp10;
/// Re-export so downstream crates (ml-backtesting, tests) can construct a
/// `MultiHorizonLoaderConfig` without taking a direct dependency on `data`.
pub use data::providers::databento::dbn_parser::InstrumentFilter;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM};
use crate::heads::N_HORIZONS;
use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab};
/// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots.
///
/// Empirical ES MBP-10 median inter-event Δt = 74µs (NOT 250ms as the
/// previous comments claimed — mean Δt is 55ms but the distribution is
/// extremely heavy-tailed so the median dominates EMA dynamics). At that
/// rate the half-lives in wall-clock are roughly:
///
/// * `ALPHA_FAST_2P6MS` (α=0.02) → ~35 snapshots × 74µs ≈ 2.6ms
/// — microstructure-burst scale
/// * `ALPHA_MED_100MS` (α=0.0005) → ~1400 snapshots × 74µs ≈ 100ms
/// — 1-2-second clustering scale
///
/// Values preserved (validated by all prior training); the names are
/// updated to reflect what they actually do. True macro-regime EMAs
/// (multi-second / multi-minute half-lives) would need α ~5e-6 or
/// smaller and are deliberately NOT added here — that's a separate
/// feature-engineering decision.
const ALPHA_FAST_2P6MS: f32 = 0.02;
const ALPHA_MED_100MS: f32 = 0.0005;
const REGIME_EPS: f32 = 1e-6;
/// Compute per-snapshot regime features via a single forward EMA pass
/// over the file. Returns `[regime; REGIME_DIM]` aligned to `snapshots`.
///
/// Sentinel-bootstrap (per [[pearl_first_observation_bootstrap]]): the
/// first observation replaces the EMA directly (no zero-bias warmup).
/// Variance EMAs use Welford-style: track ema_x and ema_x² then
/// var = max(ema_x² - ema_x², 0). All transforms are monotone /
/// log-compressed so no tuned constants leak in.
fn compute_regime_features(snapshots: &[Mbp10Snapshot]) -> Vec<[f32; REGIME_DIM]> {
let n = snapshots.len();
let mut out: Vec<[f32; REGIME_DIM]> = vec![[0.0; REGIME_DIM]; n];
if n == 0 { return out; }
let mut ema_mid_fast = f32::NAN;
let mut ema_mid_med = f32::NAN;
let mut ema_mid2_fast = f32::NAN; // E[mid²] for var estimate
let mut ema_mid2_med = f32::NAN;
let mut ema_spread = f32::NAN; // ticks
let mut ema_trades = f32::NAN; // per-snapshot delta count
let mut prev_trade_count: u32 = 0;
for (k, snap) in snapshots.iter().enumerate() {
let mid = mid_price_f32(snap);
let l0 = snap.levels.first().copied().unwrap_or_else(BidAskPair::empty);
let bid_p = BidAskPair::price_to_f64(l0.bid_px) as f32;
let ask_p = BidAskPair::price_to_f64(l0.ask_px) as f32;
let spread_t = if ask_p > bid_p { (ask_p - bid_p) / ES_TICK_SIZE } else { 0.0 };
let trades: u32 = if k == 0 { 0 } else { snap.trade_count.saturating_sub(prev_trade_count) };
prev_trade_count = snap.trade_count;
// Update EMAs with sentinel-bootstrap.
if ema_mid_fast.is_nan() {
ema_mid_fast = mid; ema_mid_med = mid;
ema_mid2_fast = mid * mid; ema_mid2_med = mid * mid;
ema_spread = spread_t; ema_trades = trades as f32;
} else {
ema_mid_fast = (1.0 - ALPHA_FAST_2P6MS) * ema_mid_fast + ALPHA_FAST_2P6MS * mid;
ema_mid_med = (1.0 - ALPHA_MED_100MS) * ema_mid_med + ALPHA_MED_100MS * mid;
ema_mid2_fast = (1.0 - ALPHA_FAST_2P6MS) * ema_mid2_fast + ALPHA_FAST_2P6MS * mid * mid;
ema_mid2_med = (1.0 - ALPHA_MED_100MS) * ema_mid2_med + ALPHA_MED_100MS * mid * mid;
ema_spread = (1.0 - ALPHA_FAST_2P6MS) * ema_spread + ALPHA_FAST_2P6MS * spread_t;
ema_trades = (1.0 - ALPHA_FAST_2P6MS) * ema_trades + ALPHA_FAST_2P6MS * trades as f32;
}
let var_fast = (ema_mid2_fast - ema_mid_fast * ema_mid_fast).max(0.0);
let var_med = (ema_mid2_med - ema_mid_med * ema_mid_med ).max(0.0);
let std_fast = (var_fast + REGIME_EPS).sqrt();
let std_med = (var_med + REGIME_EPS).sqrt();
out[k][0] = (mid - ema_mid_fast) / std_fast; // z-score fast (~2.6ms)
out[k][1] = (mid - ema_mid_med) / std_med; // z-score med (~100ms)
out[k][2] = (ema_mid_fast - ema_mid_med) / std_med; // trend signal
out[k][3] = (1.0 + var_med.sqrt() * 1e4).ln(); // log-compressed med vol
out[k][4] = (1.0 + ema_spread).ln(); // liquidity regime
out[k][5] = (1.0 + ema_trades * 100.0).ln(); // activity regime
}
out
}
#[derive(Clone, Debug)]
pub struct MultiHorizonLoaderConfig {
/// Explicit list of .dbn / .dbn.zst MBP-10 files to load. Order
/// is preserved (no internal shuffle) so callers can use file
/// ordering to control temporal train / val splits. Use
/// [`discover_mbp10_files_sorted`] to enumerate a directory in
/// chronological-filename order.
pub files: Vec<PathBuf>,
/// Directory where predecoded sidecars live (`load_or_predecode_mbp10`
/// arg — typically the same dir as `files` or a sibling).
pub predecoded_dir: PathBuf,
/// Multi-resolution input window. `total_positions()` defines the
/// sequence length the trainer sees; `required_lookback_ticks()` is
/// the per-anchor pre-edge snapshot count the loader needs from each
/// source file.
pub multi_resolution: crate::data::aggregation::MultiResolutionConfig,
/// Forward-horizon offsets in snapshots. Length tracks
/// `ml_alpha::heads::N_HORIZONS` so the loader's label layout matches
/// the per-horizon heads.
pub horizons: [usize; N_HORIZONS],
/// Soft cap on total sequences yielded across all source files.
pub n_max_sequences: usize,
/// Deterministic seed for sequence-anchor selection within files.
/// (Files themselves are taken in the order given in `files`; this
/// seed only randomises which snapshot inside each file becomes
/// the start of each yielded sequence.)
pub seed: u64,
/// When `true`, skip per-file forward-label precomputation (saves
/// ~half the file-load cost) and enable chronological inference
/// streaming via `next_inference_input()`. Used by the ml-backtesting
/// LOB backtest harness; trainer always passes `false`. See
/// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §1.
pub inference_only: bool,
/// Round-trip cost in price units used for D-style outcome label
/// generation (Phase B aux supervision). ES default: 0.5 (= 2 ticks
/// × $0.25 per tick). The cost is subtracted from realized PnL before
/// the 1.5×|max-adverse-excursion| penalty; setting it to 0.0 makes
/// the aux head learn a cost-free outcome and rarely matches the
/// inference-time trading objective.
pub outcome_label_cost: f32,
/// Selects which `instrument_id` records the MBP-10 predecoder keeps.
/// See [`InstrumentFilter`] for variants.
///
/// ES.FUT DBN archives are parent-symbol expansions: the same file
/// carries the front-month contract (e.g. `ESH4` for Q1 2024,
/// instrument_id 17077), back months (`ESM4`, etc.), plus calendar
/// spreads. Without a filter the loader interleaves all of them and
/// computes ΔP across instruments — silently inflating K-step labels
/// by thousands of dollars at the contract boundaries. For ES,
/// `FrontMonth` auto-detects the dominant id per file (handles the
/// quarterly roll); single-instrument datasets can use `All`.
pub instrument_filter: InstrumentFilter,
}
/// Default round-trip cost (price units) baked into D-style outcome labels
/// for ES contracts. 2 ticks × $0.25/tick = $0.50 in price units.
pub const DEFAULT_OUTCOME_LABEL_COST_ES: f32 = 0.5;
/// Discover MBP-10 files under `root` and return them sorted by filename.
/// Filenames follow the `ES.FUT_<YEAR>-Q<n>.dbn.zst` convention, so
/// lexicographic sort = chronological. Caller can then slice for
/// walk-forward CV.
pub fn discover_mbp10_files_sorted(root: &std::path::Path) -> Result<Vec<PathBuf>> {
let mut files: Vec<PathBuf> = std::fs::read_dir(root)
.with_context(|| format!("read mbp10 root {}", root.display()))?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
let s = p.to_string_lossy();
s.ends_with(".dbn.zst") || s.ends_with(".dbn")
})
.collect();
anyhow::ensure!(!files.is_empty(), "no DBN files under {}", root.display());
files.sort();
Ok(files)
}
pub struct LabeledSequence {
/// `multi_resolution.total_positions()` raw or aggregated snapshot inputs
/// in chronological (oldest-first) order.
pub snapshots: Vec<Mbp10RawInput>,
/// `[N_HORIZONS][total_positions]` BCE labels. NaN at positions where the
/// forward window is outside the source file (right edge) or where
/// the move is a tied price (drop, mirror `generate_labels` semantics).
pub labels: [Vec<f32>; N_HORIZONS],
/// `[N_HORIZONS][total_positions]` Candidate-B prof-binary labels for the
/// long direction (1.0 if `ΔP_K - 2×cost > 0`, 0.0 otherwise, NaN at right
/// edge or non-finite price). See [`crate::multi_horizon_labels::generate_outcome_labels_ab`].
pub outcome_prof_long: [Vec<f32>; N_HORIZONS],
/// `[N_HORIZONS][total_positions]` Candidate-B prof-binary labels for the
/// short direction.
pub outcome_prof_short: [Vec<f32>; N_HORIZONS],
/// `[N_HORIZONS][total_positions]` Candidate-B σ-normalized signed-return
/// targets for the long direction. NaN where prof_long==0 or σ_K not yet warmed.
pub outcome_size_long: [Vec<f32>; N_HORIZONS],
/// `[N_HORIZONS][total_positions]` Candidate-B σ-normalized signed-return
/// targets for the short direction. Same NaN semantics as `outcome_size_long`.
pub outcome_size_short: [Vec<f32>; N_HORIZONS],
/// `[N_HORIZONS][total_positions]` rolling 1000-step Welford std of K-step
/// log-returns at each snapshot. NaN during the warmup window. Used
/// downstream for σ-normalized size targets and any in-trainer scaling.
pub sigma_k: [Vec<f32>; N_HORIZONS],
/// Per-file class imbalance for `pos_weight` BCE balancing.
/// Length = `2 * N_HORIZONS`, laid out as
/// `[long_h0, long_h1, ..., long_hN, short_h0, ..., short_hN]`.
/// File-level statistic — every sequence yielded from the same source
/// file carries the same vector.
pub pos_fraction: Vec<f32>,
}
/// Loaded-file cache. Holds the deserialized snapshots + pre-computed
/// per-horizon labels so subsequent `next_sequence()` calls on the
/// same file avoid the multi-second bincode deserialization.
struct LoadedFile {
snapshots: Vec<Mbp10Snapshot>,
/// `labels_full[h]` holds the full-stream BCE label vector for horizon
/// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per
/// anchor on each `next_sequence()` call.
labels_full: [Vec<f32>; N_HORIZONS],
/// `outcome_prof_long_full[h]` holds the full-stream Candidate-B
/// prof-binary long-direction label vector for horizon `cfg.horizons[h]`.
/// NaN at right-edge positions. Sliced per anchor alongside `labels_full`.
outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
/// `outcome_prof_short_full[h]` — short-direction counterpart to
/// `outcome_prof_long_full`.
outcome_prof_short_full: [Vec<f32>; N_HORIZONS],
/// `outcome_size_long_full[h]` holds the full-stream Candidate-B
/// σ-normalized size target for the long direction. NaN where
/// prof_long==0 OR σ_K not yet warmed.
outcome_size_long_full: [Vec<f32>; N_HORIZONS],
/// `outcome_size_short_full[h]` — short-direction counterpart.
outcome_size_short_full: [Vec<f32>; N_HORIZONS],
/// `sigma_k_full[h]` rolling 1000-step Welford std of K-step log-returns.
/// NaN during the per-horizon warmup window.
sigma_k_full: [Vec<f32>; N_HORIZONS],
/// File-level positive-class fraction per (direction, horizon) for BCE
/// pos_weight balancing. Length = `2 * N_HORIZONS`, laid out as
/// `[long_h0..hN, short_h0..hN]`. Copied verbatim into every yielded
/// `LabeledSequence`.
pos_fraction: Vec<f32>,
/// Per-snapshot regime context computed once per file via a forward
/// EMA cascade. See [`compute_regime_features`]. Sliced per anchor.
regime_full: Vec<[f32; REGIME_DIM]>,
}
pub struct MultiHorizonLoader {
cfg: MultiHorizonLoaderConfig,
rng: ChaCha8Rng,
yielded: usize,
/// All MBP-10 files preloaded into memory at construction. Each
/// `LoadedFile` carries its deserialized snapshots + precomputed
/// per-horizon labels + regime features. Per-epoch iteration is
/// pure memory access — no disk IO after the initial preload.
///
/// Memory cost at full ES.FUT dataset (9 quarters, ~45M snapshots
/// total): ~13-15 GB. Worth it: per-epoch wall time drops from
/// ~8 min (file-IO-bound) to ~1 min (compute-bound) on L40S, a
/// ~5-7× speedup that compounds for longer runs.
files_loaded: Vec<LoadedFile>,
/// Chronological cursor for `next_inference_input()`. Only meaningful
/// when `cfg.inference_only == true`.
inference_file_idx: usize,
inference_snap_idx: usize,
}
impl MultiHorizonLoader {
/// Construct + preload the MBP-10 files listed in `cfg.files`,
/// in the order given (no shuffle — file order is the caller's
/// responsibility, see [`discover_mbp10_files_sorted`]). Slow
/// (~50s × N_files), done once at construction. Subsequent
/// `next_sequence` calls are pure memory.
pub fn new(cfg: &MultiHorizonLoaderConfig) -> Result<Self> {
anyhow::ensure!(
!cfg.files.is_empty(),
"MultiHorizonLoaderConfig.files is empty — pass at least one file"
);
let files: Vec<PathBuf> = cfg.files.clone();
let rng = ChaCha8Rng::seed_from_u64(cfg.seed);
let max_horizon = *cfg.horizons.iter().max().expect("non-empty horizons");
// Multi-resolution lookback: each anchor needs `required_lookback_ticks`
// raw snapshots BEFORE the forward edge to fill all aggregation scales.
let lookback = cfg.multi_resolution.required_lookback_ticks();
let min_size = if cfg.inference_only {
lookback.max(1)
} else {
lookback + max_horizon + 1
};
let mut files_loaded: Vec<LoadedFile> = Vec::with_capacity(files.len());
for path in &files {
let snapshots = load_or_predecode_mbp10(path, &cfg.predecoded_dir, cfg.instrument_filter)
.with_context(|| format!(
"load mbp10 {} (filter={:?})", path.display(), cfg.instrument_filter
))?;
if snapshots.len() < min_size {
tracing::warn!(
path = %path.display(),
snapshots = snapshots.len(),
min_size,
"skipping file (too few snapshots for required_lookback_ticks + max_horizon)",
);
continue;
}
let mut labels_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut sigma_k_full: [Vec<f32>; N_HORIZONS] = Default::default();
// Default pos_fraction is zero per direction/horizon; populated below in
// training mode. Length is fixed so downstream broadcast is uniform.
let mut pos_fraction: Vec<f32> = vec![0.0_f32; 2 * N_HORIZONS];
if !cfg.inference_only {
let prices: Vec<f32> = snapshots.iter().map(mid_price_f32).collect();
for (h_idx, &h) in cfg.horizons.iter().enumerate() {
let mut full = vec![f32::NAN; snapshots.len()];
let raw = generate_labels(&prices, h);
for (i, &t) in raw.valid_indices.iter().enumerate() {
full[t] = raw.labels[i];
}
labels_full[h_idx] = full;
}
// Candidate-B aux supervision: paired prof-binary + σ-normalized
// size-regression targets per (direction, horizon). Replaces the
// old D-style asymmetric loss-aversion labels.
let outcome = generate_outcome_labels_ab(
&prices,
&cfg.horizons,
cfg.outcome_label_cost,
)
.with_context(|| format!(
"generate_outcome_labels_ab for {} (cost={})",
path.display(),
cfg.outcome_label_cost,
))?;
outcome_prof_long_full = outcome.y_prof_long;
outcome_prof_short_full = outcome.y_prof_short;
outcome_size_long_full = outcome.y_size_long;
outcome_size_short_full = outcome.y_size_short;
sigma_k_full = outcome.sigma_k;
pos_fraction = outcome.pos_fraction;
}
let regime_full = compute_regime_features(&snapshots);
files_loaded.push(LoadedFile {
snapshots,
labels_full,
outcome_prof_long_full,
outcome_prof_short_full,
outcome_size_long_full,
outcome_size_short_full,
sigma_k_full,
pos_fraction,
regime_full,
});
}
anyhow::ensure!(
!files_loaded.is_empty(),
"no files had enough snapshots ({}+ required) across {} input files",
min_size, cfg.files.len()
);
tracing::info!(
n_files = files_loaded.len(),
total_snapshots = files_loaded.iter().map(|lf| lf.snapshots.len()).sum::<usize>(),
"MultiHorizonLoader preloaded all files",
);
Ok(Self {
cfg: cfg.clone(),
rng,
yielded: 0,
files_loaded,
inference_file_idx: 0,
inference_snap_idx: 0,
})
}
/// Reset per-epoch state: re-seed RNG (so anchor sampling differs
/// each epoch) and zero the yielded counter. The preloaded file
/// inventory is preserved — no disk IO.
pub fn reset(&mut self, seed: u64) {
self.rng = ChaCha8Rng::seed_from_u64(seed);
self.yielded = 0;
self.inference_file_idx = 0;
self.inference_snap_idx = 0;
}
/// Return a clone of the first chronological snapshot from the
/// first loaded file as an `Mbp10RawInput`. Used by the LOB
/// backtest harness to seed the first-call eager warmup of
/// `PerceptionTrainer::forward_only` (P5 ships CUDA Graph
/// capture of the inference dispatch chain). Pure read — no
/// cursor mutation. Available in both training and inference
/// modes.
pub fn peek_first(&self) -> Result<Mbp10RawInput> {
let first_file = self.files_loaded.first()
.context("loader has no loaded files")?;
let first_snap = first_file.snapshots.first()
.context("first loaded file has no snapshots")?;
// No previous snapshot exists; pass the first as both cur/prev.
// This yields prev_mid == cur_mid + trade_signed_vol == 0 + prev_ts_ns == ts_ns,
// which is the natural "stream start" semantics for inference seeding.
Ok(convert(first_snap, first_snap, first_file.regime_full[0]))
}
/// Iterator-style accessor for inference mode: walks every loaded
/// snapshot across every loaded file in chronological order.
/// Returns `Ok(None)` when the stream is exhausted. Unlike
/// [`next_sequence`], does NOT slice fixed-length windows.
/// A1: decision_stride removed; every snapshot is yielded consecutively.
/// Mutates the inference cursor. Errors if `cfg.inference_only` is false.
pub fn next_inference_input(&mut self) -> Result<Option<Mbp10RawInput>> {
anyhow::ensure!(
self.cfg.inference_only,
"next_inference_input requires MultiHorizonLoaderConfig.inference_only = true"
);
loop {
let Some(file) = self.files_loaded.get(self.inference_file_idx) else {
return Ok(None);
};
if self.inference_snap_idx >= file.snapshots.len() {
self.inference_file_idx += 1;
self.inference_snap_idx = 0;
continue;
}
let cur = &file.snapshots[self.inference_snap_idx];
// For the very first snapshot, use itself as prev (matches peek_first).
let prev = if self.inference_snap_idx == 0 {
cur
} else {
&file.snapshots[self.inference_snap_idx - 1]
};
let regime = file.regime_full[self.inference_snap_idx];
let out = convert(cur, prev, regime);
self.inference_snap_idx += 1;
self.yielded += 1;
return Ok(Some(out));
}
}
pub fn n_files(&self) -> usize {
self.files_loaded.len()
}
pub fn yielded(&self) -> usize {
self.yielded
}
pub fn next_sequence(&mut self) -> Result<Option<LabeledSequence>> {
self.next_sequence_random()
}
fn next_sequence_random(&mut self) -> Result<Option<LabeledSequence>> {
if self.yielded >= self.cfg.n_max_sequences {
return Ok(None);
}
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
// A1: decision_stride removed; training sequences are always consecutive
// snapshots (stride = 1). dt_s = 1.0 in all CfC forward passes.
// Pick a random file from the preloaded inventory, then a random
// anchor within that file. Uniform-across-files sampling — each
// file contributes ~n_max_sequences / n_files samples per epoch.
let n_files = self.files_loaded.len();
let file_idx = self.rng.gen_range(0..n_files);
let lf = &self.files_loaded[file_idx];
let needed = self.cfg.multi_resolution.total_positions() + max_horizon;
anyhow::ensure!(
lf.snapshots.len() > needed,
"file too short for total_positions={} max_horizon={}: {} <= {}",
self.cfg.multi_resolution.total_positions(), max_horizon, lf.snapshots.len(), needed
);
let max_anchor = lf.snapshots.len() - needed;
let anchor: usize = self.rng.gen_range(0..max_anchor);
let mut labels: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_prof_short: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_size_short: [Vec<f32>; N_HORIZONS] = Default::default();
let mut sigma_k: [Vec<f32>; N_HORIZONS] = Default::default();
for h in 0..N_HORIZONS {
let mut row = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
let mut row_prof_long = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
let mut row_prof_short = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
let mut row_size_long = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
let mut row_size_short = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
let mut row_sigma = Vec::with_capacity(self.cfg.multi_resolution.total_positions());
for k in 0..self.cfg.multi_resolution.total_positions() {
row.push(lf.labels_full[h][anchor + k]);
row_prof_long.push(lf.outcome_prof_long_full[h][anchor + k]);
row_prof_short.push(lf.outcome_prof_short_full[h][anchor + k]);
row_size_long.push(lf.outcome_size_long_full[h][anchor + k]);
row_size_short.push(lf.outcome_size_short_full[h][anchor + k]);
row_sigma.push(lf.sigma_k_full[h][anchor + k]);
}
labels[h] = row;
outcome_prof_long[h] = row_prof_long;
outcome_prof_short[h] = row_prof_short;
outcome_size_long[h] = row_size_long;
outcome_size_short[h] = row_size_short;
sigma_k[h] = row_sigma;
}
// Multi-resolution window construction. The forward edge is
// `anchor + total_positions` exclusive (the snapshot AT
// `anchor + total_positions - 1` is the newest position in the
// emitted sequence). We walk fine→coarse from the edge backward
// so each scale's positions cover earlier-in-time windows than
// the previous scale, then reverse so the emitted sequence is
// chronological (oldest-first).
let scales = self.cfg.multi_resolution.scales();
let total = self.cfg.multi_resolution.total_positions();
let edge = anchor + total; // exclusive upper bound
let mut sequence_rev: Vec<Mbp10RawInput> = Vec::with_capacity(total);
let mut cursor = edge;
for &(scale, count) in scales {
for _ in 0..count {
let window_end = cursor; // exclusive
let window_start = window_end - scale; // inclusive
let mut window_raws: Vec<Mbp10RawInput> = Vec::with_capacity(scale);
for idx in window_start..window_end {
let cur = &lf.snapshots[idx];
let prev_idx = if idx == 0 { 0 } else { idx - 1 };
let prev = &lf.snapshots[prev_idx];
window_raws.push(convert(cur, prev, lf.regime_full[idx]));
}
if window_raws.len() == 1 {
sequence_rev.push(window_raws.into_iter().next().unwrap());
} else {
sequence_rev.push(
crate::data::aggregation::aggregate_window(window_raws.as_slice()),
);
}
cursor = window_start;
}
}
let sequence: Vec<Mbp10RawInput> = sequence_rev.into_iter().rev().collect();
debug_assert_eq!(sequence.len(), total);
self.yielded += 1;
Ok(Some(LabeledSequence {
snapshots: sequence,
labels,
outcome_prof_long,
outcome_prof_short,
outcome_size_long,
outcome_size_short,
sigma_k,
pos_fraction: lf.pos_fraction.clone(),
}))
}
}
fn mid_price_f32(s: &Mbp10Snapshot) -> f32 {
let l = s.levels.first().copied().unwrap_or_else(BidAskPair::empty);
let bid = BidAskPair::price_to_f64(l.bid_px);
let ask = BidAskPair::price_to_f64(l.ask_px);
(0.5 * (bid + ask)) as f32
}
fn convert(
cur: &Mbp10Snapshot,
prev: &Mbp10Snapshot,
regime: [f32; REGIME_DIM],
) -> Mbp10RawInput {
let mut bid_px = [0.0f32; 10];
let mut bid_sz = [0.0f32; 10];
let mut ask_px = [0.0f32; 10];
let mut ask_sz = [0.0f32; 10];
for (i, lvl) in cur.levels.iter().take(10).enumerate() {
bid_px[i] = BidAskPair::price_to_f64(lvl.bid_px) as f32;
bid_sz[i] = lvl.bid_sz as f32;
ask_px[i] = BidAskPair::price_to_f64(lvl.ask_px) as f32;
ask_sz[i] = lvl.ask_sz as f32;
}
let prev_mid = mid_price_f32(prev);
let trade_count = cur.trade_count.saturating_sub(prev.trade_count);
let trade_signed_vol = infer_signed_trade_flow(cur, prev);
Mbp10RawInput {
bid_px,
bid_sz,
ask_px,
ask_sz,
prev_mid,
trade_signed_vol,
trade_count,
ts_ns: cur.timestamp,
prev_ts_ns: prev.timestamp,
regime,
}
}
/// Infer signed trade flow at L1 from MBP-10 snapshot deltas without
/// the separate trades stream. Convention matches `Mbp10RawInput::
/// trade_signed_vol`: positive = buyer-initiated (aggressive cross of
/// the ask), negative = seller-initiated (aggressive hit of the bid).
///
/// Heuristic (Databento-standard tick rule applied to L1):
/// * ask_px[0] unchanged AND ask_sz[0] decreased
/// → aggressive buys consumed ask depth; add (prev.ask_sz - cur.ask_sz).
/// * bid_px[0] unchanged AND bid_sz[0] decreased
/// → aggressive sells consumed bid depth; subtract (prev.bid_sz - cur.bid_sz).
/// * ask_px[0] moved up (best ask cleared completely)
/// → infer full prev.ask_sz lifted; add prev.ask_sz.
/// * bid_px[0] moved down (best bid cleared completely)
/// → infer full prev.bid_sz hit; subtract prev.bid_sz.
/// * Pure cancellations (size shrank but price moved away from us)
/// ambiguous; ignore.
///
/// This is a lower-bound estimator — won't catch trades that crossed
/// multiple levels (those show up only via the deeper-level deltas)
/// nor trades against hidden / off-book liquidity. Acceptable for v1
/// queue-decay signal; production deployments should layer in the
/// trades-stream loader for ground-truth signed flow.
fn infer_signed_trade_flow(cur: &Mbp10Snapshot, prev: &Mbp10Snapshot) -> f32 {
let cur_l1 = cur.levels.first().copied().unwrap_or_else(BidAskPair::empty);
let prev_l1 = prev.levels.first().copied().unwrap_or_else(BidAskPair::empty);
let cur_bid_px = BidAskPair::price_to_f64(cur_l1.bid_px) as f32;
let cur_bid_sz = cur_l1.bid_sz as f32;
let cur_ask_px = BidAskPair::price_to_f64(cur_l1.ask_px) as f32;
let cur_ask_sz = cur_l1.ask_sz as f32;
let prev_bid_px = BidAskPair::price_to_f64(prev_l1.bid_px) as f32;
let prev_bid_sz = prev_l1.bid_sz as f32;
let prev_ask_px = BidAskPair::price_to_f64(prev_l1.ask_px) as f32;
let prev_ask_sz = prev_l1.ask_sz as f32;
let mut buyer_init = 0.0_f32;
let mut seller_init = 0.0_f32;
// Same-price size decrement = aggressive cross at that price.
if (cur_ask_px - prev_ask_px).abs() < f32::EPSILON && cur_ask_sz < prev_ask_sz {
buyer_init += prev_ask_sz - cur_ask_sz;
}
if (cur_bid_px - prev_bid_px).abs() < f32::EPSILON && cur_bid_sz < prev_bid_sz {
seller_init += prev_bid_sz - cur_bid_sz;
}
// Best price moved away — infer the previous level fully cleared.
if cur_ask_px > prev_ask_px && prev_ask_sz > 0.0 {
buyer_init += prev_ask_sz;
}
if cur_bid_px < prev_bid_px && prev_bid_sz > 0.0 {
seller_init += prev_bid_sz;
}
buyer_init - seller_init
}
#[cfg(test)]
mod trade_flow_tests {
use super::*;
fn snap_with_l1(ts: u64, bid_px: f64, bid_sz: u32, ask_px: f64, ask_sz: u32, trade_count: u32) -> Mbp10Snapshot {
let mut levels = vec![BidAskPair::empty(); 10];
levels[0] = BidAskPair {
bid_px: BidAskPair::price_from_f64(bid_px),
bid_sz,
bid_ct: 1,
ask_px: BidAskPair::price_from_f64(ask_px),
ask_sz,
ask_ct: 1,
};
Mbp10Snapshot::new("ES.FUT".into(), ts, levels, 0, trade_count)
}
#[test]
fn pure_cancel_no_size_change_reports_zero_flow() {
let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
let cur = snap_with_l1(1, 5500.00, 100, 5500.25, 100, 0);
assert_eq!(infer_signed_trade_flow(&cur, &prev), 0.0);
}
#[test]
fn ask_size_shrank_unchanged_price_reports_positive_buyer_flow() {
let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
let cur = snap_with_l1(1, 5500.00, 100, 5500.25, 60, 5);
// 100 → 60 = aggressive buys consumed 40 lots at the ask.
assert_eq!(infer_signed_trade_flow(&cur, &prev), 40.0);
}
#[test]
fn bid_size_shrank_unchanged_price_reports_negative_seller_flow() {
let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
let cur = snap_with_l1(1, 5500.00, 70, 5500.25, 100, 3);
assert_eq!(infer_signed_trade_flow(&cur, &prev), -30.0);
}
#[test]
fn ask_price_moved_up_reports_full_prev_ask_as_buyer_flow() {
// best ask cleared — infer prev.ask_sz fully lifted.
let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 50, 0);
let cur = snap_with_l1(1, 5500.00, 100, 5500.50, 100, 1);
assert_eq!(infer_signed_trade_flow(&cur, &prev), 50.0);
}
#[test]
fn bid_price_moved_down_reports_full_prev_bid_as_seller_flow() {
let prev = snap_with_l1(0, 5500.00, 80, 5500.25, 100, 0);
let cur = snap_with_l1(1, 5499.75, 100, 5500.25, 100, 1);
assert_eq!(infer_signed_trade_flow(&cur, &prev), -80.0);
}
#[test]
fn mixed_buyer_and_seller_within_same_snapshot_nets_correctly() {
let prev = snap_with_l1(0, 5500.00, 100, 5500.25, 100, 0);
// ask shrank by 25 (aggressive buys), bid shrank by 10 (aggressive sells).
let cur = snap_with_l1(1, 5500.00, 90, 5500.25, 75, 3);
assert_eq!(infer_signed_trade_flow(&cur, &prev), 15.0);
}
}
#[cfg(test)]
mod inference_mode_tests {
use super::*;
fn test_fixture_cfg(inference_only: bool) -> Option<MultiHorizonLoaderConfig> {
let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
let mbp10 = std::path::PathBuf::from(&root).join("ES.FUT");
if !mbp10.exists() { return None; }
let files = discover_mbp10_files_sorted(&mbp10).ok()?;
Some(MultiHorizonLoaderConfig {
files,
predecoded_dir: mbp10.clone(),
multi_resolution: crate::data::aggregation::MultiResolutionConfig::from_scales(vec![(1, 1)]).unwrap(),
horizons: crate::heads::HORIZONS,
n_max_sequences: 1,
seed: 0xCAFEF00D,
inference_only,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
})
}
/// Try to construct a loader; return None if no usable data on disk
/// (placeholder fixtures = empty sidecars = 0 snapshots per file).
/// Treat that as "skip — real-data path".
fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
let cfg = test_fixture_cfg(inference_only)?;
match MultiHorizonLoader::new(&cfg) {
Ok(l) => Some(l),
Err(e) => {
eprintln!("skipping: fixture data not usable ({e})");
None
}
}
}
/// Task 1.1 verification: inference_only=true must NOT precompute labels.
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn inference_only_skips_label_precompute() {
let Some(loader) = try_loader(true) else { return };
assert!(loader.files_loaded.iter().all(|f| f.labels_full.iter().all(|v| v.is_empty())),
"inference_only=true must leave labels_full empty");
}
/// Task 1.1 paired check: inference_only=false populates labels.
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn training_mode_populates_labels() {
let Some(loader) = try_loader(false) else { return };
assert!(loader.files_loaded.iter().any(|f| f.labels_full.iter().any(|v| !v.is_empty())),
"inference_only=false must precompute labels for at least one file");
}
/// Task 1.2 verification: peek_first returns the first snapshot
/// converted to Mbp10RawInput with non-zero ts_ns + cur==prev semantics.
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn peek_first_returns_first_chronological_snapshot() {
let Some(loader) = try_loader(true) else { return };
let first = loader.peek_first().expect("peek_first ok");
assert!(first.ts_ns > 0);
assert_eq!(first.ts_ns, first.prev_ts_ns);
assert_eq!(first.trade_signed_vol, 0.0);
}
/// Task 1.3 verification: chronological iteration in inference mode.
#[test]
#[ignore = "requires populated FOXHUNT_TEST_DATA"]
fn next_inference_input_yields_chronological() {
let Some(mut loader) = try_loader(true) else { return };
let snap0 = loader.next_inference_input().expect("snap 0").expect("not none");
let snap1 = loader.next_inference_input().expect("snap 1").expect("not none");
let snap2 = loader.next_inference_input().expect("snap 2").expect("not none");
assert!(snap0.ts_ns <= snap1.ts_ns);
assert!(snap1.ts_ns <= snap2.ts_ns);
assert_eq!(snap0.ts_ns, snap0.prev_ts_ns);
assert_eq!(snap1.prev_ts_ns, snap0.ts_ns);
assert_eq!(snap2.prev_ts_ns, snap1.ts_ns);
}
/// Negative case: next_inference_input on a non-inference loader must error.
/// Tests can always construct an empty-files config to exercise this
/// branch without depending on real data.
#[test]
fn next_inference_input_errors_when_not_inference_mode() {
// We don't need real data here — the assertion fires before file load.
// But we DO need a non-empty files list to satisfy the constructor's
// ensure!. Stub with a path that may or may not exist; if construction
// fails for IO reasons, that's fine — the test cares only about the
// mode check.
let cfg = MultiHorizonLoaderConfig {
files: vec![std::path::PathBuf::from("/tmp/__nonexistent_foxhunt.dbn.zst")],
predecoded_dir: std::path::PathBuf::from("/tmp"),
multi_resolution: crate::data::aggregation::MultiResolutionConfig::from_scales(vec![(1, 1)]).unwrap(),
horizons: crate::heads::HORIZONS,
n_max_sequences: 1,
seed: 0,
inference_only: false,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
};
if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) {
let err = loader.next_inference_input();
assert!(err.is_err(), "must error when inference_only=false");
}
// If new() failed (no file), the assertion is moot — covered elsewhere.
}
}