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>
120 lines
4.3 KiB
Rust
120 lines
4.3 KiB
Rust
//! 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");
|
||
}
|