diff --git a/crates/ml-alpha/examples/alpha_rl_train.rs b/crates/ml-alpha/examples/alpha_rl_train.rs index f10f2ac24..5dbb72ce6 100644 --- a/crates/ml-alpha/examples/alpha_rl_train.rs +++ b/crates/ml-alpha/examples/alpha_rl_train.rs @@ -389,6 +389,8 @@ fn main() -> Result<()> { inference_only: false, outcome_label_cost: cli.outcome_label_cost, instrument_filter: cli.instrument_mode, + frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS, + frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA, }; let mut loader = MultiHorizonLoader::new(&loader_cfg).context("MultiHorizonLoader::new")?; @@ -1002,6 +1004,8 @@ fn main() -> Result<()> { inference_only: false, outcome_label_cost: cli.outcome_label_cost, instrument_filter: cli.instrument_mode, + frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS, + frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA, }; let mut eval_loader = MultiHorizonLoader::new(&eval_loader_cfg) .context("MultiHorizonLoader::new (eval)")?; diff --git a/crates/ml-alpha/examples/alpha_train.rs b/crates/ml-alpha/examples/alpha_train.rs index 704f187ca..f31e9257e 100644 --- a/crates/ml-alpha/examples/alpha_train.rs +++ b/crates/ml-alpha/examples/alpha_train.rs @@ -506,6 +506,8 @@ fn main() -> Result<()> { inference_only: false, outcome_label_cost: cli.outcome_label_cost, instrument_filter: cli.instrument_mode, + frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS, + frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA, }) .context("train loader")?; let mut val_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig { @@ -518,6 +520,8 @@ fn main() -> Result<()> { inference_only: false, outcome_label_cost: cli.outcome_label_cost, instrument_filter: cli.instrument_mode, + frd_horizon_ticks: ml_alpha::rl::common::FRD_HORIZON_TICKS, + frd_bucket_range_sigma: ml_alpha::rl::common::FRD_BUCKET_RANGE_SIGMA, }) .context("val loader")?; tracing::info!( diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs index 40be2f217..1c94f4e44 100644 --- a/crates/ml-alpha/src/data/loader.rs +++ b/crates/ml-alpha/src/data/loader.rs @@ -258,6 +258,28 @@ pub struct MultiHorizonLoaderConfig { /// `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 @@ -575,7 +597,11 @@ impl MultiHorizonLoader { regime_full, ) }; - let frd_labels_full = compute_frd_labels(&snapshots); + let frd_labels_full = compute_frd_labels( + &snapshots, + cfg.frd_horizon_ticks, + cfg.frd_bucket_range_sigma, + ); Ok(Some(LoadedFile { snapshots, labels_full, @@ -927,10 +953,20 @@ fn mid_price_f32(s: &Mbp10Snapshot) -> f32 { /// 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_σ`). Both reference `FRD_BUCKET_RANGE_SIGMA` const so the -/// label and forward interpretations stay aligned. -fn compute_frd_labels(snapshots: &[Mbp10Snapshot]) -> [Vec; crate::rl::common::FRD_N_HORIZONS] { - use crate::rl::common::{FRD_BUCKET_RANGE_SIGMA, FRD_HORIZON_TICKS, FRD_N_ATOMS, FRD_N_HORIZONS}; +/// 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; crate::rl::common::FRD_N_HORIZONS] { + use crate::rl::common::{FRD_N_ATOMS, FRD_N_HORIZONS}; let n = snapshots.len(); let mids: Vec = snapshots.iter().map(mid_price_f32).collect(); @@ -964,9 +1000,9 @@ fn compute_frd_labels(snapshots: &[Mbp10Snapshot]) -> [Vec; crate::rl::comm let mut out: [Vec; 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 / FRD_BUCKET_RANGE_SIGMA; + let scale = half / range_sigma.max(1e-6); - for (h_idx, &h_ticks) in FRD_HORIZON_TICKS.iter().enumerate() { + 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). @@ -1147,7 +1183,7 @@ mod trade_flow_tests { #[cfg(test)] mod frd_label_tests { use super::*; - use crate::rl::common::{FRD_HORIZON_TICKS, FRD_N_ATOMS, FRD_N_HORIZONS}; + 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` @@ -1172,7 +1208,7 @@ mod frd_label_tests { fn frd_labels_flat_price_maps_to_mid_bucket() { // Constant mid → all forward returns 0 → bucket = round(half) = 10. let snaps: Vec = (0..2500).map(|_| snap_with_mid(100.0)).collect(); - let labels = compute_frd_labels(&snaps); + 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]; @@ -1205,7 +1241,7 @@ mod frd_label_tests { // 7.75 is way > range_σ=3, so bucket clamps to FRD_N_ATOMS-1 = 20. let snaps: Vec = (0..2500).map(|i| snap_with_mid(100.0 + 0.01 * i as f64)).collect(); - let labels = compute_frd_labels(&snaps); + 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]; @@ -1224,7 +1260,7 @@ mod frd_label_tests { // n=10 < h_ticks[0]=60 → every label sentinel for h0; same for // h1 (=300) and h2 (=1800). let snaps: Vec = (0..10).map(|_| snap_with_mid(100.0)).collect(); - let labels = compute_frd_labels(&snaps); + 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); @@ -1252,6 +1288,8 @@ mod inference_mode_tests { 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, }) } @@ -1334,6 +1372,8 @@ mod inference_mode_tests { 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(); diff --git a/crates/ml-alpha/src/trainer/integrated.rs b/crates/ml-alpha/src/trainer/integrated.rs index 39ca0c529..a902f0e30 100644 --- a/crates/ml-alpha/src/trainer/integrated.rs +++ b/crates/ml-alpha/src/trainer/integrated.rs @@ -1467,12 +1467,21 @@ impl IntegratedTrainer { (crate::rl::isv_slots::RL_TRAIL_K_INIT_INDEX, 2.0), (crate::rl::isv_slots::RL_TRAIL_ADJUST_RATE_INDEX, 0.9), // SP20 P3 FRD head seeds — λ, LR, h1-3 tick offsets, ±σ atom range. + // Per feedback_isv_for_adaptive_bounds + single-source-of-truth: + // horizon ticks + bucket-range σ derive from the canonical + // `rl::common` consts (same source the loader's + // `compute_frd_labels` reads via `MultiHorizonLoaderConfig`), + // so the two sides can't drift across literal-vs-const edits. (crate::rl::isv_slots::RL_FRD_LAMBDA_INDEX, 0.5), (crate::rl::isv_slots::RL_FRD_LR_INDEX, 1e-3), - (crate::rl::isv_slots::RL_FRD_HORIZON_1_TICKS_INDEX, 60.0), - (crate::rl::isv_slots::RL_FRD_HORIZON_2_TICKS_INDEX, 300.0), - (crate::rl::isv_slots::RL_FRD_HORIZON_3_TICKS_INDEX, 1800.0), - (crate::rl::isv_slots::RL_FRD_BUCKET_RANGE_SIGMA_INDEX, 3.0), + (crate::rl::isv_slots::RL_FRD_HORIZON_1_TICKS_INDEX, + crate::rl::common::FRD_HORIZON_TICKS[0] as f32), + (crate::rl::isv_slots::RL_FRD_HORIZON_2_TICKS_INDEX, + crate::rl::common::FRD_HORIZON_TICKS[1] as f32), + (crate::rl::isv_slots::RL_FRD_HORIZON_3_TICKS_INDEX, + crate::rl::common::FRD_HORIZON_TICKS[2] as f32), + (crate::rl::isv_slots::RL_FRD_BUCKET_RANGE_SIGMA_INDEX, + crate::rl::common::FRD_BUCKET_RANGE_SIGMA), (crate::rl::isv_slots::RL_KL_TARGET_INDEX, 0.01), (crate::rl::isv_slots::RL_IMPROVEMENT_THRESHOLD_INDEX, 0.99), (crate::rl::isv_slots::RL_PLATEAU_PATIENCE_INDEX, 1000.0), diff --git a/crates/ml-alpha/tests/multi_horizon_loader.rs b/crates/ml-alpha/tests/multi_horizon_loader.rs index e58bf7015..c72c750cc 100644 --- a/crates/ml-alpha/tests/multi_horizon_loader.rs +++ b/crates/ml-alpha/tests/multi_horizon_loader.rs @@ -28,6 +28,8 @@ fn cfg_from_env() -> Option { 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, }) } @@ -72,6 +74,8 @@ fn loader_errors_on_empty_files() { 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"); diff --git a/crates/ml-backtesting/src/harness.rs b/crates/ml-backtesting/src/harness.rs index 10f75032c..9b632f192 100644 --- a/crates/ml-backtesting/src/harness.rs +++ b/crates/ml-backtesting/src/harness.rs @@ -261,6 +261,8 @@ impl BacktestHarness { // are still the legacy all-records files. A follow-up that plumbs // a CLI flag through `BacktestHarnessConfig` can lift this. 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 loader = MultiHorizonLoader::new(&loader_cfg)?; diff --git a/crates/ml-backtesting/tests/ring3_replay.rs b/crates/ml-backtesting/tests/ring3_replay.rs index 37153806c..84ca9aa8f 100644 --- a/crates/ml-backtesting/tests/ring3_replay.rs +++ b/crates/ml-backtesting/tests/ring3_replay.rs @@ -50,6 +50,8 @@ fn try_loader() -> Option { inference_only: true, outcome_label_cost: ml_alpha::data::loader::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, }; match MultiHorizonLoader::new(&cfg) { Ok(l) => Some(l), diff --git a/crates/ml-backtesting/tests/trainer_parity.rs b/crates/ml-backtesting/tests/trainer_parity.rs index 054c95b29..7b1a60822 100644 --- a/crates/ml-backtesting/tests/trainer_parity.rs +++ b/crates/ml-backtesting/tests/trainer_parity.rs @@ -37,6 +37,8 @@ fn try_loader(inference_only: bool) -> Option { inference_only, outcome_label_cost: ml_alpha::data::loader::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, }; match MultiHorizonLoader::new(&cfg) { Ok(l) => Some(l),