Files
foxhunt/crates/ml-alpha/tests/multi_horizon_loader.rs
jgrusewski 7abfd3b0b6 chore(ml-alpha-tests): migrate test fixtures to MultiResolutionConfig
multi_horizon_loader.rs constructs MultiHorizonLoaderConfig with
default_three_scale() (matching alpha_train's production default
and yielding total_positions()=32). The cfg.seq_len=32 override
in loader_stride_4_yields_correct_spacing is now a no-op since
the constructor already provides 32, so it's removed with a comment.

perception_overfit.rs was untouched — it doesn't construct
MultiHorizonLoaderConfig, only PerceptionTrainerConfig (whose own
seq_len field is unrelated to the loader migration).
2026-05-22 20:31:44 +02:00

115 lines
4.3 KiB
Rust

//! Phase A loader smoke tests.
//!
//! Real-data path is `--ignored`; runs only with FOXHUNT_TEST_DATA set.
use data::providers::databento::dbn_parser::InstrumentFilter;
use ml_alpha::data::loader::{
discover_mbp10_files_sorted, MultiHorizonLoader, MultiHorizonLoaderConfig,
DEFAULT_OUTCOME_LABEL_COST_ES,
};
use ml_alpha::heads::{HORIZONS, N_HORIZONS};
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,
multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
horizons: HORIZONS,
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
inference_only: false,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
})
}
#[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.multi_resolution.total_positions());
for h in 0..N_HORIZONS {
assert_eq!(seq.labels[h].len(), cfg.multi_resolution.total_positions());
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"),
multi_resolution: ml_alpha::data::aggregation::MultiResolutionConfig::default_three_scale(),
horizons: HORIZONS,
n_max_sequences: 10,
seed: 0,
inference_only: false,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
};
let res = MultiHorizonLoader::new(&cfg);
assert!(res.is_err(), "expected error for empty file list");
}
#[test]
#[ignore]
fn loader_stride_4_yields_correct_spacing() {
// A1: decision_stride removed; sequences are always consecutive snapshots.
// Test repurposed: verifies consecutive-snapshot ts_ns monotonicity.
let Some(mut cfg) = cfg_from_env() else {
eprintln!("skipping: FOXHUNT_TEST_DATA not set or mbp10 dir missing");
return;
};
// cfg.multi_resolution stays at default_three_scale() (total_positions=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.multi_resolution.total_positions());
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!("consecutive median Δt_ns between K-positions = {median}");
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");
}