Files
foxhunt/crates/ml-alpha/src/data/loader.rs
jgrusewski 0e70bf96fe fix(rl): wire BCE + aux losses into GPU loader path + event-based sync
BCE and aux losses were hardcoded to 0 in the GPU data path. Now
step_batched_from_device returns (bce_loss, aux_loss), stored as
last_bce_loss / last_aux_loss and fed into step stats + JSONL.

Event-based sync replaces host-blocking stream sync:
- perception: raw_event_record after training graph, raw_event_sync
  in read_deferred_stats (instant — graph completed long ago)
- diag_staging: raw_event_record after snapshot_async, raw_event_sync
  in sync_and_swap (instant — copies completed during GPU work)

All JSONL fields preserved. Loss values one-step deferred (same as
before for Q/π/V, now also for BCE/aux).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 22:18:30 +02:00

1668 lines
74 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 cudarc::driver::CudaSlice;
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 rayon::prelude::*;
use crate::cfc::snap_features::{Mbp10RawInput, BOOK_LEVELS, ES_TICK_SIZE, REGIME_DIM};
use crate::heads::N_HORIZONS;
use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_ab};
/// Cached on-disk shape of per-file label + regime arrays. Sidecar lives
/// alongside the predecoded MBP-10 snapshot sidecar in `cfg.predecoded_dir`.
///
/// Cache key (encoded in the sidecar filename) includes (horizons,
/// outcome_label_cost, instrument_filter) because labels depend on these.
/// The existing predecoded-MBP-10 sidecar's `instrument_filter` part of
/// its filename plus the horizons/cost suffix here give a complete cache
/// key.
///
/// Refilled on schema-mismatch errors (deserialize failure) — the bincode
/// payload's struct shape is itself the schema check. The load path also
/// checks `regime_full.len() == snapshots.len()` as a defensive length
/// guard against silently-stale caches (the underlying MBP-10 sidecar is
/// invalidated by mtime+size mismatch in `ml_features::predecoded`, but
/// that doesn't propagate to a label-length check here without this
/// guard).
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedLabels {
labels_full: [Vec<f32>; N_HORIZONS],
outcome_prof_long_full: [Vec<f32>; N_HORIZONS],
outcome_prof_short_full: [Vec<f32>; N_HORIZONS],
outcome_size_long_full: [Vec<f32>; N_HORIZONS],
outcome_size_short_full: [Vec<f32>; N_HORIZONS],
sigma_k_full: [Vec<f32>; N_HORIZONS],
pos_fraction: Vec<f32>,
regime_full: Vec<[f32; REGIME_DIM]>,
}
/// Cache-key suffix for the labels sidecar. Encodes (horizons, cost,
/// instrument_filter) so changes to any of these properties invalidate
/// the cache. Format: `labels_h<H0>_<H1>_<H2>_cost<HEX>_<FILTER>`.
/// The cost is encoded as the f32's raw bit pattern in lowercase hex to
/// keep '.' out of the filename (portable across filesystems) and to
/// preserve exact-float identity (0.5 != 0.50000001 → different bits →
/// different cache key).
fn labels_cache_suffix(
horizons: &[usize; N_HORIZONS],
cost_price_units: f32,
filter: InstrumentFilter,
) -> String {
let cost_bits = cost_price_units.to_bits();
let filter_tag = match filter {
InstrumentFilter::All => String::from("all"),
InstrumentFilter::Id(id) => format!("instr{id}"),
InstrumentFilter::FrontMonth => String::from("front_month"),
};
format!(
"labels_h{}_{}_{}_cost{:08x}_{}",
horizons[0], horizons[1], horizons[2], cost_bits, filter_tag
)
}
/// Path to the labels-cache sidecar for `source` under `predecoded_dir`.
/// Layout: `<predecoded_dir>/<source_stem>.<suffix>.predecoded.bin`.
fn labels_cache_path(
source: &std::path::Path,
predecoded_dir: &std::path::Path,
horizons: &[usize; N_HORIZONS],
cost_price_units: f32,
filter: InstrumentFilter,
) -> PathBuf {
let suffix = labels_cache_suffix(horizons, cost_price_units, filter);
let stem = source
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
predecoded_dir.join(format!("{stem}.{suffix}.predecoded.bin"))
}
/// Try to read cached labels. Returns `Ok(Some(...))` on hit,
/// `Ok(None)` on miss (file does not exist), and `Err` only on IO /
/// decode errors. Callers treat `Err` the same as a miss (recompute)
/// but log a warning.
fn try_read_labels_cache(path: &std::path::Path) -> Result<Option<CachedLabels>> {
use std::fs::File;
use std::io::BufReader;
if !path.exists() {
return Ok(None);
}
let f = File::open(path).with_context(|| format!("open labels cache {}", path.display()))?;
let reader = BufReader::new(f);
let cached: CachedLabels = bincode::deserialize_from(reader)
.with_context(|| format!("decode labels cache {}", path.display()))?;
Ok(Some(cached))
}
/// Write labels cache to disk. Best-effort: failures are logged but don't
/// halt the training run.
fn write_labels_cache(path: &std::path::Path, cached: &CachedLabels) -> Result<()> {
use std::fs::File;
use std::io::BufWriter;
let f = File::create(path).with_context(|| format!("create labels cache {}", path.display()))?;
let writer = BufWriter::new(f);
bincode::serialize_into(writer, cached)
.with_context(|| format!("encode labels cache {}", path.display()))?;
Ok(())
}
/// 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,
/// SP20 P3 F.5 — FRD head forward-return horizons in tick units.
/// Determines `compute_frd_labels`'s lookahead distances. Default
/// is `crate::rl::common::FRD_HORIZON_TICKS = [60, 300, 1800]` —
/// the spec-recommended microstructure / minute / hour timescales.
/// The trainer-side ISV slots (`RL_FRD_HORIZON_{1,2,3}_TICKS_INDEX`,
/// slots 500/501/502) seed the SAME defaults so loader + trainer
/// agree out of the box. Override here to retune both the
/// supervised labels AND the ISV seeds (caller is responsible for
/// the latter via `RL_FRD_HORIZON_*` writes if they want runtime
/// alignment).
pub frd_horizon_ticks: [usize; crate::rl::common::FRD_N_HORIZONS],
/// SP20 P3 F.5 — FRD bucket-range in σ units. The atom span is
/// `[-range_σ, +range_σ]` mapped onto `FRD_N_ATOMS=21` atoms.
/// Default = `crate::rl::common::FRD_BUCKET_RANGE_SIGMA = 3.0`
/// (matches the ISV seed at `RL_FRD_BUCKET_RANGE_SIGMA_INDEX`
/// slot 503). Per `pearl_glm_fitter_link_must_match_inference`:
/// the label generator AND the trainer-side ISV-driven runtime
/// MUST stay aligned — caller is responsible for matching ISV
/// slot 503 if overriding here.
pub frd_bucket_range_sigma: f32,
}
/// 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],
/// SP20 P3 F.5 — per-(horizon, snapshot) FRD return-bucket label
/// indices `[0, FRD_N_ATOMS)`. Sentinel `-1` at the rightmost edge
/// where `snapshot_idx + h_ticks >= snapshots.len()`. Length per
/// horizon equals `snapshots.len()`. Caller writes the row at
/// the anchor index into `trainer.frd_labels_d` before each
/// `step_with_lobsim`. See `compute_frd_labels` for the bucket
/// math; the trainer-side softmax+CE interpretation MUST match
/// (same `FRD_BUCKET_RANGE_SIGMA` const).
pub frd_labels: [Vec<i32>; crate::rl::common::FRD_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],
/// SP20 P3 F.5 — full-file FRD per-horizon bucket-index labels.
/// See `LabeledSequence::frd_labels` doc for shape + semantics.
/// Sliced per anchor on each `next_sequence()` call.
frd_labels_full: [Vec<i32>; crate::rl::common::FRD_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
};
// SPEED-A (2026-05-22): parallel per-file load + label generation.
// Each file's load_or_predecode + generate_labels + generate_outcome_labels_ab +
// compute_regime_features is pure CPU work over disjoint inputs (snapshots,
// cfg.horizons, cfg.outcome_label_cost). par_iter gives ~4-8x speedup on
// typical 4-8 core machines. Files emitting a "too few snapshots" warning
// are filtered out post-collection (via Option<LoadedFile>).
let load_one = |path: &PathBuf| -> Result<Option<LoadedFile>> {
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)",
);
return Ok(None);
}
// SPEED-B (2026-05-22): on-disk cache for the per-file label +
// regime arrays so subsequent training runs skip the multi-second
// label-generation pass. Cache key encodes (horizons, cost,
// instrument_filter). Inference-only runs skip the cache WRITE
// (they don't compute labels) but DO honor a hit if present —
// the cached arrays would just go unused in that mode.
let labels_cache = labels_cache_path(
path,
&cfg.predecoded_dir,
&cfg.horizons,
cfg.outcome_label_cost,
cfg.instrument_filter,
);
let cached = match try_read_labels_cache(&labels_cache) {
Ok(Some(c)) if c.regime_full.len() == snapshots.len() => {
tracing::info!(
path = %labels_cache.display(),
n_snapshots = snapshots.len(),
"labels cache HIT",
);
Some(c)
}
Ok(Some(c)) => {
// Stale length — the underlying MBP-10 sidecar was
// refreshed (re-downloaded quarter) but the labels cache
// wasn't. Treat as miss, recompute, overwrite.
tracing::warn!(
path = %labels_cache.display(),
cached_n = c.regime_full.len(),
actual_n = snapshots.len(),
"labels cache length mismatch — recomputing",
);
None
}
Ok(None) => None,
Err(e) => {
tracing::warn!(
path = %labels_cache.display(),
err = %e,
"labels cache read failed — recomputing",
);
None
}
};
let (
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,
) = if let Some(c) = cached {
(
c.labels_full,
c.outcome_prof_long_full,
c.outcome_prof_short_full,
c.outcome_size_long_full,
c.outcome_size_short_full,
c.sigma_k_full,
c.pos_fraction,
c.regime_full,
)
} else {
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);
// Best-effort write to cache for next run. Skipped when
// inference_only since labels_full/outcome_* are all-default
// and a stored cache there would mis-serve a later
// non-inference run that hit it.
if !cfg.inference_only {
let payload = CachedLabels {
labels_full: labels_full.clone(),
outcome_prof_long_full: outcome_prof_long_full.clone(),
outcome_prof_short_full: outcome_prof_short_full.clone(),
outcome_size_long_full: outcome_size_long_full.clone(),
outcome_size_short_full: outcome_size_short_full.clone(),
sigma_k_full: sigma_k_full.clone(),
pos_fraction: pos_fraction.clone(),
regime_full: regime_full.clone(),
};
if let Err(e) = write_labels_cache(&labels_cache, &payload) {
tracing::warn!(
path = %labels_cache.display(),
err = %e,
"labels cache write failed (training continues)",
);
} else {
tracing::info!(
path = %labels_cache.display(),
n_snapshots = snapshots.len(),
"labels cache WRITE",
);
}
}
(
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,
)
};
let frd_labels_full = compute_frd_labels(
&snapshots,
cfg.frd_horizon_ticks,
cfg.frd_bucket_range_sigma,
);
Ok(Some(LoadedFile {
snapshots,
labels_full,
outcome_prof_long_full,
outcome_prof_short_full,
outcome_size_long_full,
outcome_size_short_full,
sigma_k_full,
frd_labels_full,
pos_fraction,
regime_full,
}))
};
let files_loaded_results: Vec<Result<Option<LoadedFile>>> =
files.par_iter().map(load_one).collect();
let mut files_loaded: Vec<LoadedFile> = Vec::with_capacity(files.len());
for result in files_loaded_results {
match result? {
Some(lf) => files_loaded.push(lf),
None => {} // Already warned in load_one.
}
}
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]))
}
/// Upload ALL pre-converted snapshots + FRD labels to GPU as a
/// single flat buffer. Returns a [`GpuDataset`] that the
/// [`GpuDataLoader`] kernel reads from each step — zero CPU work
/// in the training hot path.
///
/// This method is the CPU-side half of the GPU-resident data loader:
/// 1. Pre-converts every `Mbp10Snapshot` → `Mbp10RawInput` (calls
/// `convert()` once per snapshot at init, not per step).
/// 2. Concatenates all files into flat arrays.
/// 3. Uploads via `htod_copy` (one-time bulk transfer).
/// 4. Builds per-file offset + size tables on device.
/// 5. Initializes per-batch PRNG seeds on device.
///
/// Per `feedback_no_htod_htoh_only_mapped_pinned.md`: the init-time
/// `htod_copy` is a one-shot outside the captured-graph hot path.
/// The per-step path is pure device→device (kernel reads dataset
/// buffers, writes SoA buffers).
///
/// [`GpuDataset`]: super::gpu_dataset::GpuDataset
/// [`GpuDataLoader`]: super::gpu_dataset::GpuDataLoader
pub fn upload_to_gpu(
&self,
dev: &ml_core::device::MlDevice,
batch_size: usize,
seed: u64,
) -> Result<super::gpu_dataset::GpuDataset> {
use crate::cfc::snap_features::MBP10_RAW_INPUT_BYTES;
use crate::rl::common::FRD_N_HORIZONS;
let stream = dev.cuda_stream().context("upload_to_gpu: CUDA stream")?.clone();
// ── 1. Pre-convert ALL snapshots → Mbp10RawInput + build offsets ──
let n_files = self.files_loaded.len();
let total_snaps: usize = self.files_loaded.iter().map(|f| f.snapshots.len()).sum();
tracing::info!(
n_files,
total_snaps,
bytes = total_snaps * MBP10_RAW_INPUT_BYTES,
"upload_to_gpu: pre-converting snapshots",
);
let mut file_offsets: Vec<i32> = Vec::with_capacity(n_files);
let mut file_sizes: Vec<i32> = Vec::with_capacity(n_files);
let mut offset: usize = 0;
for lf in &self.files_loaded {
file_offsets.push(offset as i32);
file_sizes.push(lf.snapshots.len() as i32);
offset += lf.snapshots.len();
}
// ── 2. Upload snapshots per-file (streaming) ────────────────────
// Allocate the full GPU buffer upfront, then convert + upload one
// file at a time. Peak host memory = one file's worth of converted
// snapshots (~1-2GB) instead of all files (~10GB).
let total_bytes = total_snaps * MBP10_RAW_INPUT_BYTES;
let snapshots_d = stream
.alloc_zeros::<u8>(total_bytes)
.context("upload_to_gpu: snapshots alloc")?;
let mut gpu_offset_bytes: usize = 0;
for (f_idx, lf) in self.files_loaded.iter().enumerate() {
let n = lf.snapshots.len();
let mut file_raws: Vec<Mbp10RawInput> = Vec::with_capacity(n);
for (idx, snap) in lf.snapshots.iter().enumerate() {
let prev = if idx == 0 { snap } else { &lf.snapshots[idx - 1] };
file_raws.push(convert(snap, prev, lf.regime_full[idx]));
}
let file_bytes = n * MBP10_RAW_INPUT_BYTES;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
snapshots_d.raw_ptr() + gpu_offset_bytes as u64,
file_raws.as_ptr() as *const std::ffi::c_void,
file_bytes,
);
}
gpu_offset_bytes += file_bytes;
tracing::info!(
file = f_idx,
n_snapshots = n,
gpu_offset_mb = gpu_offset_bytes / (1024 * 1024),
"upload_to_gpu: file uploaded",
);
drop(file_raws);
}
tracing::info!(
gpu_bytes = total_bytes,
"upload_to_gpu: snapshots uploaded",
);
// ── 3. Upload file offsets + sizes ───────────────────────────────
let file_offsets_d = stream
.alloc_zeros::<i32>(n_files)
.context("upload_to_gpu: file_offsets alloc")?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
file_offsets_d.raw_ptr(),
file_offsets.as_ptr() as *const std::ffi::c_void,
n_files * 4,
);
}
let file_sizes_d = stream
.alloc_zeros::<i32>(n_files)
.context("upload_to_gpu: file_sizes alloc")?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
file_sizes_d.raw_ptr(),
file_sizes.as_ptr() as *const std::ffi::c_void,
n_files * 4,
);
}
// ── 4. Upload FRD labels [FRD_N_HORIZONS, total_snaps] row-major ──
let frd_total = FRD_N_HORIZONS * total_snaps;
let mut frd_flat: Vec<i32> = vec![-1_i32; frd_total];
let mut global_offset: usize = 0;
for lf in &self.files_loaded {
let n = lf.snapshots.len();
for h in 0..FRD_N_HORIZONS {
let src = &lf.frd_labels_full[h];
let dst_start = h * total_snaps + global_offset;
frd_flat[dst_start..dst_start + n].copy_from_slice(&src[..n]);
}
global_offset += n;
}
let frd_labels_d = stream
.alloc_zeros::<i32>(frd_total)
.context("upload_to_gpu: frd_labels alloc")?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
frd_labels_d.raw_ptr(),
frd_flat.as_ptr() as *const std::ffi::c_void,
frd_total * 4,
);
}
// ── 5. Upload supervised BCE labels [N_HORIZONS, total_snaps] row-major ──
//
// Same row-major layout as FRD labels: `all_labels[h * total_snaps + snap_idx]`.
// Six float arrays: labels, outcome_prof_{long,short}, outcome_size_{long,short},
// sigma_k. Plus per-file pos_fraction [n_files, 2 * N_HORIZONS].
let label_total = N_HORIZONS * total_snaps;
let upload_f32_labels = |name: &str, accessor: &dyn Fn(&LoadedFile, usize) -> &Vec<f32>| -> Result<CudaSlice<f32>> {
let mut flat: Vec<f32> = vec![f32::NAN; label_total];
let mut g_off: usize = 0;
for lf in &self.files_loaded {
let n = lf.snapshots.len();
for h in 0..N_HORIZONS {
let src = accessor(lf, h);
let dst_start = h * total_snaps + g_off;
flat[dst_start..dst_start + n].copy_from_slice(&src[..n]);
}
g_off += n;
}
let buf = stream
.alloc_zeros::<f32>(label_total)
.with_context(|| format!("upload_to_gpu: {name} alloc"))?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
buf.raw_ptr(),
flat.as_ptr() as *const std::ffi::c_void,
label_total * 4,
);
}
Ok(buf)
};
let labels_d = upload_f32_labels("labels", &|lf, h| &lf.labels_full[h])?;
let outcome_prof_long_d = upload_f32_labels("outcome_prof_long", &|lf, h| &lf.outcome_prof_long_full[h])?;
let outcome_prof_short_d = upload_f32_labels("outcome_prof_short", &|lf, h| &lf.outcome_prof_short_full[h])?;
let outcome_size_long_d = upload_f32_labels("outcome_size_long", &|lf, h| &lf.outcome_size_long_full[h])?;
let outcome_size_short_d = upload_f32_labels("outcome_size_short", &|lf, h| &lf.outcome_size_short_full[h])?;
let sigma_k_d = upload_f32_labels("sigma_k", &|lf, h| &lf.sigma_k_full[h])?;
// Per-file pos_fraction: [n_files, 2 * N_HORIZONS].
let pf_total = n_files * 2 * N_HORIZONS;
let mut pf_flat: Vec<f32> = vec![0.0_f32; pf_total];
for (f_idx, lf) in self.files_loaded.iter().enumerate() {
let base = f_idx * 2 * N_HORIZONS;
let src = &lf.pos_fraction;
pf_flat[base..base + src.len()].copy_from_slice(src);
}
let pos_fraction_d = stream
.alloc_zeros::<f32>(pf_total)
.context("upload_to_gpu: pos_fraction alloc")?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
pos_fraction_d.raw_ptr(),
pf_flat.as_ptr() as *const std::ffi::c_void,
pf_total * 4,
);
}
tracing::info!(
label_total,
pf_total,
"upload_to_gpu: supervised labels uploaded",
);
// ── 6. Initialize per-batch PRNG seeds ──────────────────────────
let prng_seeds: Vec<u32> = (0..batch_size as u64)
.map(|b| {
// Mix seed + batch index to get distinct per-batch seeds.
// Ensures different batch elements sample different files/anchors.
let mixed = seed.wrapping_mul(2654435761).wrapping_add(b);
(mixed & 0xFFFF_FFFF) as u32
})
.collect();
let prng_d = stream
.alloc_zeros::<u32>(batch_size)
.context("upload_to_gpu: prng alloc")?;
unsafe {
cudarc::driver::sys::cuMemcpyHtoD_v2(
prng_d.raw_ptr(),
prng_seeds.as_ptr() as *const std::ffi::c_void,
batch_size * 4,
);
}
let max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
tracing::info!(
n_files,
total_snaps,
max_horizon,
batch_size,
"upload_to_gpu: dataset ready",
);
Ok(super::gpu_dataset::GpuDataset {
snapshots_d,
file_offsets_d,
file_sizes_d,
frd_labels_d,
prng_d,
labels_d,
outcome_prof_long_d,
outcome_prof_short_d,
outcome_size_long_d,
outcome_size_short_d,
sigma_k_d,
pos_fraction_d,
n_files,
total_snapshots: total_snaps,
max_horizon,
})
}
/// 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()
}
/// Phase R2 (was G fix): return a `(s_t, s_{t+1})` pair of
/// consecutive sequences at adjacent anchors in the same file.
/// The first sequence ends one snapshot earlier than the second —
/// the canonical RL Bellman-target requirement that
/// `IntegratedTrainer::step_with_lobsim` consumes as
/// `(snapshots, next_snapshots)`.
///
/// Both yielded sequences come from the SAME random file so the
/// underlying market regime / time-of-day context is consistent.
/// The anchor for `s_t` is sampled uniformly from `[min_anchor,
/// max_anchor 1)` so `anchor + 1` also satisfies the
/// upper-bound constraint. Counts as ONE yielded sequence against
/// `n_max_sequences` (the loader views the pair as one training
/// step's worth of input).
pub fn next_sequence_pair(
&mut self,
) -> Result<Option<(LabeledSequence, LabeledSequence)>> {
if self.yielded >= self.cfg.n_max_sequences {
return Ok(None);
}
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 max_horizon = *self.cfg.horizons.iter().max().expect("non-empty horizons");
let total = self.cfg.multi_resolution.total_positions();
let lookback = self.cfg.multi_resolution.required_lookback_ticks();
let min_anchor = lookback.saturating_sub(total);
// Reserve one extra event past the standard max_anchor so
// `anchor + 1`'s window also fits within the file.
let max_anchor = lf
.snapshots
.len()
.saturating_sub(total + max_horizon + 1);
anyhow::ensure!(
max_anchor > min_anchor,
"file too short for paired-window sampling (total={total}, lookback={lookback}, max_horizon={max_horizon}): \
snapshots.len()={} cannot span [{min_anchor}, {max_anchor})",
lf.snapshots.len()
);
let anchor_t: usize = self.rng.gen_range(min_anchor..max_anchor);
let s_t = self.build_sequence_at(lf, anchor_t)?;
let s_tp1 = self.build_sequence_at(lf, anchor_t + 1)?;
self.yielded += 1;
Ok(Some((s_t, s_tp1)))
}
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];
// Multi-resolution anchor sampling has TWO bounds:
// * Upper: anchor + total + max_horizon < snapshots.len()
// (room for the forward-label window after the input edge)
// * Lower: anchor + total >= lookback
// (room to look back across coarsest aggregation scale)
// For the default 3-scale config (total=32, lookback=1510) the lower
// bound is binding: anchor must be ≥ lookback - total = 1478.
let total = self.cfg.multi_resolution.total_positions();
let lookback = self.cfg.multi_resolution.required_lookback_ticks();
let min_anchor = lookback.saturating_sub(total);
let max_anchor = lf.snapshots.len().saturating_sub(total + max_horizon);
anyhow::ensure!(
max_anchor > min_anchor,
"file too short for multi-resolution(total={total}, lookback={lookback}) max_horizon={max_horizon}: \
snapshots.len()={} cannot span [{min_anchor}, {max_anchor})",
lf.snapshots.len()
);
let anchor: usize = self.rng.gen_range(min_anchor..max_anchor);
let seq = self.build_sequence_at(lf, anchor)?;
self.yielded += 1;
Ok(Some(seq))
}
/// Construct a `LabeledSequence` at a specific `(file, anchor)`
/// pair. Pure function of the loader config + file contents; does
/// not advance the loader's `yielded` counter (callers update it).
///
/// Phase R2: this helper exists so `next_sequence_random` (random
/// anchor) and `next_sequence_pair` (paired adjacent anchors)
/// share the multi-resolution windowing logic verbatim per
/// `feedback_single_source_of_truth_no_duplicates`.
fn build_sequence_at(
&self,
lf: &LoadedFile,
anchor: usize,
) -> Result<LabeledSequence> {
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);
// SP20 P3 F.5 — FRD labels for THIS sequence. The encoder
// forward emits h_t aligned to the newest snapshot (= the
// last position in `sequence` = file index `edge - 1`).
// That single per-horizon bucket index becomes the
// supervised target the trainer writes into frd_labels_d
// for the current batch slot.
let newest_idx = edge - 1;
let mut frd_labels: [Vec<i32>; crate::rl::common::FRD_N_HORIZONS] = Default::default();
for h in 0..crate::rl::common::FRD_N_HORIZONS {
// Yield a length-1 vec per horizon (the trainer
// consumes labels[K-1] only — FRD supervises h_t at
// the rightmost K position, not the full K window).
let v = lf
.frd_labels_full
.get(h)
.and_then(|col| col.get(newest_idx).copied())
.unwrap_or(-1);
frd_labels[h] = vec![v];
}
Ok(LabeledSequence {
snapshots: sequence,
labels,
outcome_prof_long,
outcome_prof_short,
outcome_size_long,
outcome_size_short,
sigma_k,
frd_labels,
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
}
/// SP20 P3 F.5 — per-file FRD label generation.
///
/// For each snapshot index `i` and each FRD horizon `h_ticks ∈
/// FRD_HORIZON_TICKS = [60, 300, 1800]`, compute the σ-normalized
/// forward return:
///
/// ```text
/// r = (mid[i + h_ticks] - mid[i]) / sigma_per_step
/// ```
///
/// where `sigma_per_step` is the file-level sample std of single-
/// tick mid increments. Bucket `r` into `FRD_N_ATOMS = 21` atoms
/// uniform over `[-FRD_BUCKET_RANGE_SIGMA, +FRD_BUCKET_RANGE_SIGMA]`
/// (default ±3σ) — clip to `[0, FRD_N_ATOMS-1]` for tail returns.
///
/// Sentinel `-1` marks `i + h_ticks >= snapshots.len()` (the
/// forward return isn't realized at the rightmost edge of the
/// file) — the `rl_frd_softmax_ce_grad` kernel zeros loss + grad
/// for those rows.
///
/// Output shape: `[FRD_N_HORIZONS][snapshots.len()]` row-major
/// per horizon. The caller slices into this at the anchor index
/// when yielding a sequence (same pattern as the BCE labels).
///
/// Per `pearl_glm_fitter_link_must_match_inference`: the bucket
/// edges here MUST match the trainer-side FRD head's softmax + CE
/// interpretation (atom `a` = bucket center `(a / (N-1) × 2 - 1) ×
/// range_σ`). The `range_sigma` argument and trainer-side ISV slot
/// 503 MUST stay aligned — caller is responsible for keeping them
/// in sync (default path: both come from `FRD_BUCKET_RANGE_SIGMA`).
///
/// Per `feedback_isv_for_adaptive_bounds`: horizons + range come in
/// as parameters (sourced from `MultiHorizonLoaderConfig`, defaults
/// from the canonical consts) — NOT hardcoded literals in the
/// function body. Single source of truth: `rl::common`.
fn compute_frd_labels(
snapshots: &[Mbp10Snapshot],
horizon_ticks: [usize; crate::rl::common::FRD_N_HORIZONS],
range_sigma: f32,
) -> [Vec<i32>; crate::rl::common::FRD_N_HORIZONS] {
use crate::rl::common::{FRD_N_ATOMS, FRD_N_HORIZONS};
let n = snapshots.len();
let mids: Vec<f32> = snapshots.iter().map(mid_price_f32).collect();
// File-level sample std of single-tick mid increments. Bootstraps
// off the loaded file alone (no warmup blackout — for the very
// first file this is the only signal). At ~3M ticks per file the
// sample is statistically reliable.
let sigma_per_step: f32 = {
if mids.len() < 2 {
// Pathological 1-snapshot file — sentinel everything.
1.0
} else {
let mut sum_sq = 0.0_f64;
let mut cnt = 0_usize;
for i in 1..mids.len() {
let d = (mids[i] - mids[i - 1]) as f64;
if d.is_finite() {
sum_sq += d * d;
cnt += 1;
}
}
if cnt == 0 {
1.0
} else {
((sum_sq / cnt as f64).sqrt() as f32).max(1e-6)
}
}
};
let mut out: [Vec<i32>; FRD_N_HORIZONS] = Default::default();
let n_atoms_f = FRD_N_ATOMS as f32;
let half = (FRD_N_ATOMS - 1) as f32 / 2.0;
let scale = half / range_sigma.max(1e-6);
for (h_idx, &h_ticks) in horizon_ticks.iter().enumerate() {
// sqrt(h_ticks) Brownian scaling — variance of an h-step
// sum of iid increments is h × per-step variance, so std
// scales with sqrt(h).
let sigma_h = sigma_per_step * (h_ticks as f32).sqrt();
let mut labels = vec![-1_i32; n];
if h_ticks < n {
for i in 0..(n - h_ticks) {
let r = (mids[i + h_ticks] - mids[i]) / sigma_h.max(1e-6);
// Bucket center mapping: atom `a` → center `(a - half) / scale`.
// Inverse: `a = round(r × scale + half)`.
let a_raw = (r * scale + half).round() as i32;
let a_clamped = a_raw.clamp(0, (FRD_N_ATOMS - 1) as i32);
labels[i] = a_clamped;
}
}
// Final safety — guard against pathological NaN propagation.
for v in labels.iter_mut() {
if *v < -1 || *v >= n_atoms_f as i32 {
*v = -1;
}
}
out[h_idx] = labels;
}
out
}
fn convert(
cur: &Mbp10Snapshot,
prev: &Mbp10Snapshot,
regime: [f32; REGIME_DIM],
) -> Mbp10RawInput {
let mut bid_px = [0.0f32; BOOK_LEVELS];
let mut bid_sz = [0.0f32; BOOK_LEVELS];
let mut ask_px = [0.0f32; BOOK_LEVELS];
let mut ask_sz = [0.0f32; BOOK_LEVELS];
for (i, lvl) in cur.levels.iter().take(BOOK_LEVELS).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(); BOOK_LEVELS];
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 frd_label_tests {
use super::*;
use crate::rl::common::{FRD_BUCKET_RANGE_SIGMA, FRD_HORIZON_TICKS, FRD_N_ATOMS, FRD_N_HORIZONS};
/// Construct a synthetic snapshot stream with a deterministic
/// mid-price function. Only L1 bid/ask matter — `compute_frd_labels`
/// reads `mid_price_f32` which uses only the first level.
fn snap_with_mid(mid: f64) -> Mbp10Snapshot {
let half_spread = 0.005;
let bid = mid - half_spread;
let ask = mid + half_spread;
let mut levels = vec![BidAskPair::empty(); BOOK_LEVELS];
levels[0] = BidAskPair {
bid_px: BidAskPair::price_from_f64(bid),
bid_sz: 1,
bid_ct: 1,
ask_px: BidAskPair::price_from_f64(ask),
ask_sz: 1,
ask_ct: 1,
};
Mbp10Snapshot::new("ES.FUT".into(), 0, levels, 0, 0)
}
#[test]
fn frd_labels_flat_price_maps_to_mid_bucket() {
// Constant mid → all forward returns 0 → bucket = round(half) = 10.
let snaps: Vec<Mbp10Snapshot> = (0..2500).map(|_| snap_with_mid(100.0)).collect();
let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
let mid_bucket = (FRD_N_ATOMS as i32 - 1) / 2;
for h in 0..FRD_N_HORIZONS {
let h_ticks = FRD_HORIZON_TICKS[h];
// Non-sentinel range: [0, n - h_ticks)
for i in 0..(snaps.len() - h_ticks) {
assert_eq!(
labels[h][i], mid_bucket,
"flat-price horizon {} idx {} should be mid bucket {}; got {}",
h, i, mid_bucket, labels[h][i]
);
}
// Sentinel range: [n - h_ticks, n)
for i in (snaps.len() - h_ticks)..snaps.len() {
assert_eq!(
labels[h][i], -1,
"right-edge sentinel at h={} idx={} should be -1; got {}",
h, i, labels[h][i]
);
}
}
}
#[test]
fn frd_labels_monotonic_ramp_lands_in_upper_buckets() {
// Monotone ramp: mid[i] = 100 + i × 0.01. Forward return at
// each horizon is positive → bucket > mid (= 10). Exact bucket
// depends on σ_per_step (= 0.01 here, std of constant
// increments), so a 60-tick forward return is +0.6 ticks.
// σ_h = 0.01 × sqrt(60) ≈ 0.0775 → r = 0.6 / 0.0775 ≈ 7.75.
// 7.75 is way > range_σ=3, so bucket clamps to FRD_N_ATOMS-1 = 20.
let snaps: Vec<Mbp10Snapshot> =
(0..2500).map(|i| snap_with_mid(100.0 + 0.01 * i as f64)).collect();
let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
let max_bucket = (FRD_N_ATOMS - 1) as i32;
for h in 0..FRD_N_HORIZONS {
let h_ticks = FRD_HORIZON_TICKS[h];
for i in 0..(snaps.len() - h_ticks) {
assert_eq!(
labels[h][i], max_bucket,
"monotonic-up horizon {} idx {} should clamp to max bucket {}; got {}",
h, i, max_bucket, labels[h][i]
);
}
}
}
#[test]
fn frd_labels_short_input_below_h_ticks_all_sentinel() {
// n=10 < h_ticks[0]=60 → every label sentinel for h0; same for
// h1 (=300) and h2 (=1800).
let snaps: Vec<Mbp10Snapshot> = (0..10).map(|_| snap_with_mid(100.0)).collect();
let labels = compute_frd_labels(&snaps, FRD_HORIZON_TICKS, FRD_BUCKET_RANGE_SIGMA);
for h in 0..FRD_N_HORIZONS {
for (i, v) in labels[h].iter().enumerate() {
assert_eq!(*v, -1, "sub-h_ticks input must be all -1; got {} at h={} i={}", v, h, i);
}
}
}
}
#[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,
frd_horizon_ticks: crate::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: crate::rl::common::FRD_BUCKET_RANGE_SIGMA,
})
}
/// 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,
frd_horizon_ticks: crate::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: crate::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
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.
}
}
#[cfg(test)]
mod labels_cache_tests {
use super::*;
#[test]
fn labels_cache_suffix_is_deterministic_and_keys_on_inputs() {
let h1 = [100, 300, 1000];
let h2 = [100, 300, 1001]; // different last horizon
let c1 = 0.5_f32;
let c2 = 0.25_f32;
let f1 = InstrumentFilter::All;
let f2 = InstrumentFilter::FrontMonth;
// Same inputs → same suffix
assert_eq!(
labels_cache_suffix(&h1, c1, f1),
labels_cache_suffix(&h1, c1, f1)
);
// Different horizon → different suffix
assert_ne!(
labels_cache_suffix(&h1, c1, f1),
labels_cache_suffix(&h2, c1, f1)
);
// Different cost → different suffix
assert_ne!(
labels_cache_suffix(&h1, c1, f1),
labels_cache_suffix(&h1, c2, f1)
);
// Different filter → different suffix
assert_ne!(
labels_cache_suffix(&h1, c1, f1),
labels_cache_suffix(&h1, c1, f2)
);
}
}