Files
foxhunt/crates/ml-alpha/tests/multi_horizon_loader.rs
jgrusewski e9c4acfb12 feat(ml-alpha): MultiHorizonLoader inference-only mode
Add inference_only flag to MultiHorizonLoaderConfig that skips per-file
forward-label precomputation (~half the file-load cost), plus
peek_first() and next_inference_input() chronological-streaming methods
for the ml-backtesting LOB harness.

- min_size relaxed to cfg.seq_len when inference_only=true (training
  still requires seq_len + max_horizon + 1 for label generation)
- New cursor fields (inference_file_idx, inference_snap_idx) walk every
  loaded snapshot in chronological order; reset() zeros both
- peek_first() seeds CfcTrunk::capture_graph_a with cur==prev semantics
  (prev_ts_ns==ts_ns, trade_signed_vol=0) — natural stream-start
- next_inference_input() errors if cfg.inference_only=false (guard
  against accidental mixing of training/inference paths)
- All trainer call-sites (alpha_train example + multi_horizon_loader
  tests) updated with inference_only: false (zero behaviour change)
- Inline test module exercises both modes; tests skip gracefully when
  fixture data isn't populated rather than panicking

See docs/superpowers/specs/2026-05-18-real-lob-integration-design.md
§1 (trainer parity) + §7 (orchestrator).

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

120 lines
4.3 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 loader smoke tests.
//!
//! Real-data path is `--ignored`; runs only with FOXHUNT_TEST_DATA set.
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
};
use std::path::PathBuf;
fn cfg_from_env() -> Option<MultiHorizonLoaderConfig> {
let root = std::env::var("FOXHUNT_TEST_DATA").ok()?;
let mbp10 = PathBuf::from(format!("{root}/mbp10"));
let predec = PathBuf::from(format!("{root}/predecoded"));
if !mbp10.exists() {
return None;
}
let files = discover_mbp10_files_sorted(&mbp10).ok()?;
Some(MultiHorizonLoaderConfig {
files,
predecoded_dir: predec,
seq_len: 64,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
decision_stride: 1,
inference_only: false,
})
}
#[test]
#[ignore]
fn loader_yields_seq_with_valid_labels() {
let Some(cfg) = cfg_from_env() else {
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
return;
};
let mut loader = MultiHorizonLoader::new(&cfg).expect("loader init");
assert!(loader.n_files() > 0, "no DBN files found in mbp10 root");
let mut count = 0;
while let Some(seq) = loader.next_sequence().expect("next") {
assert_eq!(seq.snapshots.len(), cfg.seq_len);
for h in 0..5 {
assert_eq!(seq.labels[h].len(), cfg.seq_len);
for &v in &seq.labels[h] {
assert!(v.is_nan() || v == 0.0 || v == 1.0, "label not binary: {v}");
}
}
let any_valid = seq.labels.iter().any(|l| l.iter().any(|v| !v.is_nan()));
assert!(any_valid, "expected at least one non-NaN label in a sequence");
count += 1;
if count >= 5 {
break;
}
}
assert!(count >= 5, "expected at least 5 sequences, got {count}");
}
#[test]
fn loader_errors_on_empty_files() {
let cfg = MultiHorizonLoaderConfig {
files: Vec::new(),
predecoded_dir: PathBuf::from("/tmp/does_not_exist_foxhunt_test"),
seq_len: 64,
horizons: [30, 100, 300, 1000, 6000],
n_max_sequences: 10,
seed: 0,
decision_stride: 1,
inference_only: false,
};
let res = MultiHorizonLoader::new(&cfg);
assert!(res.is_err(), "expected error for empty file list");
}
#[test]
#[ignore]
fn loader_stride_4_yields_correct_spacing() {
// Verifies decision_stride=4 produces snapshots that are 4-apart in
// the source file. Only meaningful with real data. Uses ts_ns to
// confirm the spacing (since indices aren't exposed externally).
let Some(mut cfg) = cfg_from_env() else {
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
return;
};
cfg.decision_stride = 4;
cfg.seq_len = 32;
cfg.n_max_sequences = 5;
let mut loader = MultiHorizonLoader::new(&cfg).expect("loader init");
while let Some(seq) = loader.next_sequence().expect("next") {
assert_eq!(seq.snapshots.len(), cfg.seq_len);
// The Δt between consecutive K-positions should be visibly
// larger than a single-tick gap. At stride=4 on ES MBP-10, that
// means the median Δt across positions should exceed ~3× the
// typical tick interval (microsecond-scale jitter is fine).
let dts_ns: Vec<i64> = (1..seq.snapshots.len())
.map(|k| (seq.snapshots[k].ts_ns as i64) - (seq.snapshots[k - 1].ts_ns as i64))
.filter(|d| *d >= 0) // ts_ns is monotonic in MBP-10
.collect();
assert!(!dts_ns.is_empty(), "no positive Δt in sequence");
let mut sorted = dts_ns.clone();
sorted.sort();
let median = sorted[sorted.len() / 2];
eprintln!("stride=4 median Δt_ns between K-positions = {median}");
// Sanity: median Δt should be > 0 (stride>1 means no zero-Δt
// self-pairs in the steady state). Stride > 1 with sparse
// periods could theoretically dip to zero on quiet windows,
// so don't assert a lower bound on the median magnitude.
assert!(median >= 0, "Δt_ns negative — ts_ns not monotonic?");
}
}
#[test]
fn discover_errors_on_missing_root() {
let res = discover_mbp10_files_sorted(&PathBuf::from(
"/tmp/does_not_exist_foxhunt_test",
));
assert!(res.is_err(), "expected error for missing root dir");
}