Files
foxhunt/crates/ml-alpha/tests/multi_horizon_loader.rs
jgrusewski 7df7c81d37 refactor(rl): FRD horizons + range_σ are ISV-driven, not literals
Closes the literal/const drift gap that F.5 introduced. Per
feedback_isv_for_adaptive_bounds + feedback_single_source_of_truth_no_duplicates:
adaptive bounds belong in ISV (or in a single canonical const that
ISV references), never duplicated as literals across modules.

Single canonical source: `crate::rl::common::FRD_HORIZON_TICKS` +
`FRD_BUCKET_RANGE_SIGMA` (already declared in F.5).

Producer-side fixes:
  * Trainer ISV bootstrap (integrated.rs): the seed values for slots
    500-503 now dereference the canonical consts instead of hardcoded
    60.0/300.0/1800.0/3.0 literals. Future tuning of the consts
    automatically propagates to both ISV seeds and loader-side
    labels — no manual sync required, no drift possible.
  * compute_frd_labels (loader.rs): takes `horizon_ticks` and
    `range_sigma` as parameters instead of reading consts directly.
    Caller (the file-load closure) sources them from the new
    MultiHorizonLoaderConfig fields.

Consumer-side fixes — 8 MultiHorizonLoaderConfig literal sites now
provide the two new fields, all defaulting to the canonical consts:
  * crates/ml-alpha/src/data/loader.rs (2 internal test-fixture sites)
  * crates/ml-alpha/tests/multi_horizon_loader.rs (2 sites)
  * crates/ml-alpha/examples/alpha_train.rs (2 sites)
  * crates/ml-alpha/examples/alpha_rl_train.rs (2 sites)
  * crates/ml-backtesting/src/harness.rs (1 site)
  * crates/ml-backtesting/tests/{trainer_parity,ring3_replay}.rs (2 sites)

The "optimal by default" property is preserved: every caller that
doesn't explicitly override gets the spec-recommended 60/300/1800
ticks + ±3σ. Callers that need to retune set the config fields, and
the trainer's ISV slots provide a runtime knob for the same numerics.

Verification (RTX 3050 Ti):
  * cargo check -p ml-alpha -p ml-backtesting --examples --tests → clean
  * cargo test --lib (6/6 unit tests for FRD label gen + loss_balance) → pass
  * frd_head 10/10 + integrated_trainer_smoke 1/1 + trade_mgmt 5/5 → pass
  * audit-rust-consts → 0 flags

The two new MultiHorizonLoaderConfig fields are required (no Default
impl) — callers MUST opt in to the FRD label-generation contract by
naming the fields. This is the same discipline applied across other
config consumers; making them Option<...> would silently default to
"no FRD labels" and break F.4's expected supervised signal.
2026-05-24 19:23:39 +02:00

119 lines
4.6 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::single_scale_32(),
horizons: HORIZONS,
n_max_sequences: 100,
seed: 0xA1A2_A3A4,
inference_only: false,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
})
}
#[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::single_scale_32(),
horizons: HORIZONS,
n_max_sequences: 10,
seed: 0,
inference_only: false,
outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES,
instrument_filter: InstrumentFilter::All,
frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS,
frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA,
};
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");
}