Files
foxhunt/crates/ml-alpha/src/data/loader.rs
jgrusewski 19986c8d96 feat(ml-alpha): tick-rule signed trade-flow inference at L1 (C16)
Replaces the placeholder `trade_signed_vol = trade_count_delta` (always
non-negative, sign-neutral) with a proper Databento-standard tick-rule
inference applied to L1 size + price deltas across consecutive MBP-10
snapshots:

  • 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 hit
    the bid; subtract (prev.bid_sz − cur.bid_sz).
  • ask_px[0] moved up → previous best ask cleared; add prev.ask_sz.
  • bid_px[0] moved down → previous best bid hit; subtract prev.bid_sz.
  • Pure cancellations (size shrank but price moved AWAY from us) =
    ambiguous; ignore.

Convention matches `Mbp10RawInput::trade_signed_vol`: positive =
buyer-initiated, negative = seller-initiated. This is a LOWER-BOUND
estimator — won't catch trades that cleared multiple levels (those
manifest only via deeper-level deltas) or trades against hidden /
off-book liquidity. Acceptable for v1 queue-decay signal; production
deployments can layer the trades-stream loader for ground-truth flow.

Wired into BacktestHarness::run() → sim.step_resting_orders(ts, vol)
so the queue-decay branch of resting_orders.cu finally fires with
non-zero input. Previously the harness passed 0.0 unconditionally,
which meant resting limits could only fill via the price-cross
marketability branch — same-price queue-decay was inert.

Six new unit tests cover each branch of the inference (pure cancel,
ask-shrank, bid-shrank, ask-px-up, bid-px-down, mixed both-sides).
34 ml-alpha lib tests + 33 ml-backtesting lib + 12 GPU fixtures + 3
fuzz still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 10:08:20 +02:00

659 lines
29 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 `seq_len`-sized windows of `Mbp10RawInput` plus 5-horizon 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;
use rand::{Rng, SeedableRng};
use rand_chacha::ChaCha8Rng;
use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM};
use crate::multi_horizon_labels::generate_labels;
/// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots.
const ALPHA_MED: f32 = 0.02; // half-life ≈ 35 snapshots ≈ 9s @ 250ms
const ALPHA_SLOW: f32 = 0.0005; // half-life ≈ 1400 snapshots ≈ 6 min
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_med = f32::NAN;
let mut ema_mid_slow = f32::NAN;
let mut ema_mid2_med = f32::NAN; // E[mid²] for var estimate
let mut ema_mid2_slow = 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_med.is_nan() {
ema_mid_med = mid; ema_mid_slow = mid;
ema_mid2_med = mid * mid; ema_mid2_slow = mid * mid;
ema_spread = spread_t; ema_trades = trades as f32;
} else {
ema_mid_med = (1.0 - ALPHA_MED) * ema_mid_med + ALPHA_MED * mid;
ema_mid_slow = (1.0 - ALPHA_SLOW) * ema_mid_slow + ALPHA_SLOW * mid;
ema_mid2_med = (1.0 - ALPHA_MED) * ema_mid2_med + ALPHA_MED * mid * mid;
ema_mid2_slow = (1.0 - ALPHA_SLOW) * ema_mid2_slow + ALPHA_SLOW * mid * mid;
ema_spread = (1.0 - ALPHA_MED) * ema_spread + ALPHA_MED * spread_t;
ema_trades = (1.0 - ALPHA_MED) * ema_trades + ALPHA_MED * trades as f32;
}
let var_med = (ema_mid2_med - ema_mid_med * ema_mid_med ).max(0.0);
let var_slow = (ema_mid2_slow - ema_mid_slow * ema_mid_slow).max(0.0);
let std_med = (var_med + REGIME_EPS).sqrt();
let std_slow = (var_slow + REGIME_EPS).sqrt();
out[k][0] = (mid - ema_mid_med) / std_med; // z-score med
out[k][1] = (mid - ema_mid_slow) / std_slow; // z-score slow
out[k][2] = (ema_mid_med - ema_mid_slow) / std_slow; // trend signal
out[k][3] = (1.0 + var_slow.sqrt() * 1e4).ln(); // log-compressed slow 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,
/// Number of consecutive snapshots per training sequence.
pub seq_len: usize,
/// Forward-horizon offsets in snapshots; spec mandates 5 of these.
pub horizons: [usize; 5],
/// 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,
/// Decision-stride sampling. With `decision_stride = S`, position k
/// of the yielded sequence reads snapshot `anchor + k * S` from the
/// source file. `S = 1` (default) preserves the original
/// consecutive-snapshot behaviour. `S > 1` expands the effective
/// time-window covered by each sequence (window = (seq_len - 1) * S
/// + 1 snapshots) at the same K-positions compute cost. The
/// per-sequence `Δt = ts_ns - prev_ts_ns` then reflects the actual
/// elapsed time between consecutive K-positions, which is what
/// downstream Mamba2 SSM dt_s + Phase 2C TGN Fourier features
/// consume. Labels stay in absolute-snapshot horizons (h=6000 is
/// always "predict 6000 snapshots forward" regardless of stride).
pub decision_stride: usize,
/// 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,
}
/// 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 {
/// `seq_len` consecutive raw snapshot inputs.
pub snapshots: Vec<Mbp10RawInput>,
/// `[5][seq_len]` 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>; 5],
}
/// 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 label vector for horizon
/// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per
/// anchor on each `next_sequence()` call.
labels_full: [Vec<f32>; 5],
/// 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");
// Training requires `seq_len + max_horizon + 1` snapshots/file to compute
// forward labels; inference only needs `seq_len` (no forward window).
let min_size = if cfg.inference_only {
cfg.seq_len.max(1)
} else {
cfg.seq_len + 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)
.with_context(|| format!("load mbp10 {}", path.display()))?;
if snapshots.len() < min_size {
tracing::warn!(
path = %path.display(),
snapshots = snapshots.len(),
min_size,
"skipping file (too few snapshots for seq_len + max_horizon)",
);
continue;
}
let mut labels_full: [Vec<f32>; 5] = Default::default();
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;
}
}
let regime_full = compute_regime_features(&snapshots);
files_loaded.push(LoadedFile { snapshots, labels_full, 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 `CfcTrunk::capture_graph_a`. 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 and does
/// NOT apply `decision_stride` (caller's concern — see
/// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §7).
/// 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>> {
if self.yielded >= self.cfg.n_max_sequences {
return Ok(None);
}
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
let stride = self.cfg.decision_stride.max(1);
// 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];
// With stride S, position k reads snapshot `anchor + k * S`; the
// last sequence index is `anchor + (seq_len-1) * S`, and the
// label there looks `max_horizon` forward in raw snapshots
// (labels stay in absolute-snapshot units regardless of stride).
let last_seq_offset = (self.cfg.seq_len - 1) * stride;
let needed = last_seq_offset + 1 + max_horizon;
anyhow::ensure!(
lf.snapshots.len() > needed,
"file too short for seq_len={} stride={} max_horizon={}: {} <= {}",
self.cfg.seq_len, stride, 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>; 5] = Default::default();
for h in 0..5 {
let mut row = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
row.push(lf.labels_full[h][anchor + k * stride]);
}
labels[h] = row;
}
let mut sequence = Vec::with_capacity(self.cfg.seq_len);
for k in 0..self.cfg.seq_len {
let idx = anchor + k * stride;
let cur = &lf.snapshots[idx];
// `prev` for microstructure features is the snapshot one
// K-position back in the strided sequence, NOT the
// consecutive-snapshot prior. This makes `Δt = ts_ns -
// prev_ts_ns` carry the actual elapsed time between
// consecutive K-positions — Mamba2's dt_s and the Phase 2C
// TGN Fourier features both consume this.
let prev_idx = if k == 0 {
// At k=0 there's no prior K-position; fall back to the
// immediately-prior snapshot (or self if anchor=0). Δt
// here will be ~tick-scale, but only at position 0.
if idx == 0 { 0 } else { idx - 1 }
} else {
anchor + (k - 1) * stride
};
let prev = &lf.snapshots[prev_idx];
sequence.push(convert(cur, prev, lf.regime_full[idx]));
}
self.yielded += 1;
Ok(Some(LabeledSequence { snapshots: sequence, labels }))
}
}
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(),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0xCAFEF00D,
decision_stride: 1,
inference_only,
})
}
/// 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"),
seq_len: 1,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 1,
seed: 0,
decision_stride: 1,
inference_only: false,
};
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.
}
}