diff --git a/crates/ml-alpha/examples/alpha_train.rs b/crates/ml-alpha/examples/alpha_train.rs index 5a01f26e8..942eb7ae9 100644 --- a/crates/ml-alpha/examples/alpha_train.rs +++ b/crates/ml-alpha/examples/alpha_train.rs @@ -24,7 +24,9 @@ use anyhow::{Context, Result}; use clap::Parser; use ml_alpha::cfc::snap_features::Mbp10RawInput; -use ml_alpha::data::loader::{MultiHorizonLoader, MultiHorizonLoaderConfig}; +use ml_alpha::data::loader::{ + MultiHorizonLoader, MultiHorizonLoaderConfig, DEFAULT_OUTCOME_LABEL_COST_ES, +}; use ml_alpha::eval::auc::{compute_auc, AucInput}; use ml_alpha::heads::{HORIZONS, N_HORIZONS}; use ml_alpha::trainer::perception::{auto_horizon_weights, PerceptionTrainer, PerceptionTrainerConfig}; @@ -180,6 +182,14 @@ struct Cli { /// Production runs should leave this unset. #[arg(long)] bucket_warmup_cap_steps: Option, + + /// Round-trip trade cost in price units used when generating the + /// D-style asymmetric outcome labels (Phase B aux supervision). + /// Default 0.5 = 2 ticks × $0.25/tick for ES. Setting 0.0 disables + /// the cost penalty in the aux head's target and is almost never + /// useful — defeats the cost-aware objective of the aux supervision. + #[arg(long, default_value_t = DEFAULT_OUTCOME_LABEL_COST_ES)] + outcome_label_cost: f32, } #[derive(Serialize, serde::Deserialize, Default)] @@ -422,6 +432,7 @@ fn main() -> Result<()> { n_max_sequences: cli.n_train_seqs, seed: cli.seed, inference_only: false, + outcome_label_cost: cli.outcome_label_cost, }) .context("train loader")?; let mut val_loader = MultiHorizonLoader::new(&MultiHorizonLoaderConfig { @@ -432,6 +443,7 @@ fn main() -> Result<()> { n_max_sequences: cli.n_val_seqs, seed: cli.seed.wrapping_add(0xC0FFEE), inference_only: false, + outcome_label_cost: cli.outcome_label_cost, }) .context("val loader")?; tracing::info!( diff --git a/crates/ml-alpha/src/data/loader.rs b/crates/ml-alpha/src/data/loader.rs index b779a53bf..57780fd63 100644 --- a/crates/ml-alpha/src/data/loader.rs +++ b/crates/ml-alpha/src/data/loader.rs @@ -20,7 +20,7 @@ use rand_chacha::ChaCha8Rng; use crate::cfc::snap_features::{Mbp10RawInput, ES_TICK_SIZE, REGIME_DIM}; use crate::heads::N_HORIZONS; -use crate::multi_horizon_labels::generate_labels; +use crate::multi_horizon_labels::{generate_labels, generate_outcome_labels_d}; /// EMA smoothing factors. Half-life ≈ ln(2)/α snapshots. /// @@ -132,8 +132,19 @@ pub struct MultiHorizonLoaderConfig { /// LOB backtest harness; trainer always passes `false`. See /// `docs/superpowers/specs/2026-05-18-real-lob-integration-design.md` §1. pub inference_only: bool, + /// Round-trip cost in price units used for D-style outcome label + /// generation (Phase B aux supervision). ES default: 0.5 (= 2 ticks + /// × $0.25 per tick). The cost is subtracted from realized PnL before + /// the 1.5×|max-adverse-excursion| penalty; setting it to 0.0 makes + /// the aux head learn a cost-free outcome and rarely matches the + /// inference-time trading objective. + pub outcome_label_cost: f32, } +/// Default round-trip cost (price units) baked into D-style outcome labels +/// for ES contracts. 2 ticks × $0.25/tick = $0.50 in price units. +pub const DEFAULT_OUTCOME_LABEL_COST_ES: f32 = 0.5; + /// Discover MBP-10 files under `root` and return them sorted by filename. /// Filenames follow the `ES.FUT_-Q.dbn.zst` convention, so /// lexicographic sort = chronological. Caller can then slice for @@ -156,10 +167,23 @@ pub fn discover_mbp10_files_sorted(root: &std::path::Path) -> Result, - /// `[N_HORIZONS][seq_len]` labels. NaN at positions where the + /// `[N_HORIZONS][seq_len]` BCE labels. NaN at positions where the /// forward window is outside the source file (right edge) or where /// the move is a tied price (drop, mirror `generate_labels` semantics). pub labels: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the + /// long direction (Phase B aux supervision). + /// `outcome_long[h][t] = (price[t+K] - price[t] - cost) - + /// 1.5 × |max long drawdown over [t, t+K]|`. + /// NaN at right-edge positions where the forward window is outside the + /// source file. See [`crate::multi_horizon_labels::generate_outcome_labels_d`]. + pub outcome_long: [Vec; N_HORIZONS], + /// `[N_HORIZONS][seq_len]` D-style asymmetric outcome labels for the + /// short direction. + /// `outcome_short[h][t] = (price[t] - price[t+K] - cost) - + /// 1.5 × |max short drawup over [t, t+K]|`. + /// Same NaN edge semantics as `outcome_long`. + pub outcome_short: [Vec; N_HORIZONS], } /// Loaded-file cache. Holds the deserialized snapshots + pre-computed @@ -167,10 +191,17 @@ pub struct LabeledSequence { /// same file avoid the multi-second bincode deserialization. struct LoadedFile { snapshots: Vec, - /// `labels_full[h]` holds the full-stream label vector for horizon + /// `labels_full[h]` holds the full-stream BCE label vector for horizon /// `cfg.horizons[h]`, indexed the same as `snapshots`. Sliced per /// anchor on each `next_sequence()` call. labels_full: [Vec; N_HORIZONS], + /// `outcome_long_full[h]` holds the full-stream D-style asymmetric + /// long-direction outcome label vector for horizon `cfg.horizons[h]`. + /// NaN at right-edge positions. Sliced per anchor alongside `labels_full`. + outcome_long_full: [Vec; N_HORIZONS], + /// `outcome_short_full[h]` — short-direction counterpart to + /// `outcome_long_full`. + outcome_short_full: [Vec; N_HORIZONS], /// Per-snapshot regime context computed once per file via a forward /// EMA cascade. See [`compute_regime_features`]. Sliced per anchor. regime_full: Vec<[f32; REGIME_DIM]>, @@ -232,6 +263,8 @@ impl MultiHorizonLoader { continue; } let mut labels_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_long_full: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_short_full: [Vec; N_HORIZONS] = Default::default(); if !cfg.inference_only { let prices: Vec = snapshots.iter().map(mid_price_f32).collect(); for (h_idx, &h) in cfg.horizons.iter().enumerate() { @@ -242,9 +275,30 @@ impl MultiHorizonLoader { } labels_full[h_idx] = full; } + // Phase B aux supervision: D-style asymmetric loss-aversion labels + // generated from the same mid-price series. NaN at right-edge + // positions is preserved verbatim from the generator. + let outcome = generate_outcome_labels_d( + &prices, + &cfg.horizons, + cfg.outcome_label_cost, + ) + .with_context(|| format!( + "generate_outcome_labels_d for {} (cost={})", + path.display(), + cfg.outcome_label_cost, + ))?; + outcome_long_full = outcome.y_long; + outcome_short_full = outcome.y_short; } let regime_full = compute_regime_features(&snapshots); - files_loaded.push(LoadedFile { snapshots, labels_full, regime_full }); + files_loaded.push(LoadedFile { + snapshots, + labels_full, + outcome_long_full, + outcome_short_full, + regime_full, + }); } anyhow::ensure!( !files_loaded.is_empty(), @@ -365,12 +419,20 @@ impl MultiHorizonLoader { let anchor: usize = self.rng.gen_range(0..max_anchor); let mut labels: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_long: [Vec; N_HORIZONS] = Default::default(); + let mut outcome_short: [Vec; N_HORIZONS] = Default::default(); for h in 0..N_HORIZONS { let mut row = Vec::with_capacity(self.cfg.seq_len); + let mut row_long = Vec::with_capacity(self.cfg.seq_len); + let mut row_short = Vec::with_capacity(self.cfg.seq_len); for k in 0..self.cfg.seq_len { row.push(lf.labels_full[h][anchor + k]); + row_long.push(lf.outcome_long_full[h][anchor + k]); + row_short.push(lf.outcome_short_full[h][anchor + k]); } labels[h] = row; + outcome_long[h] = row_long; + outcome_short[h] = row_short; } let mut sequence = Vec::with_capacity(self.cfg.seq_len); for k in 0..self.cfg.seq_len { @@ -382,7 +444,12 @@ impl MultiHorizonLoader { } self.yielded += 1; - Ok(Some(LabeledSequence { snapshots: sequence, labels })) + Ok(Some(LabeledSequence { + snapshots: sequence, + labels, + outcome_long, + outcome_short, + })) } } @@ -561,6 +628,7 @@ mod inference_mode_tests { n_max_sequences: 1, seed: 0xCAFEF00D, inference_only, + outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES, }) } @@ -641,6 +709,7 @@ mod inference_mode_tests { n_max_sequences: 1, seed: 0, inference_only: false, + outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES, }; if let Ok(mut loader) = MultiHorizonLoader::new(&cfg) { let err = loader.next_inference_input(); diff --git a/crates/ml-alpha/tests/multi_horizon_loader.rs b/crates/ml-alpha/tests/multi_horizon_loader.rs index 6f9f9e75b..3b1750763 100644 --- a/crates/ml-alpha/tests/multi_horizon_loader.rs +++ b/crates/ml-alpha/tests/multi_horizon_loader.rs @@ -4,6 +4,7 @@ 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; @@ -24,6 +25,7 @@ fn cfg_from_env() -> Option { n_max_sequences: 100, seed: 0xA1A2_A3A4, inference_only: false, + outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES, }) } @@ -66,6 +68,7 @@ fn loader_errors_on_empty_files() { n_max_sequences: 10, seed: 0, inference_only: false, + outcome_label_cost: DEFAULT_OUTCOME_LABEL_COST_ES, }; 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 48ae8e2da..ab0f8959d 100644 --- a/crates/ml-backtesting/src/harness.rs +++ b/crates/ml-backtesting/src/harness.rs @@ -253,6 +253,10 @@ impl BacktestHarness { seed: 0, // A1: decision_stride removed; inference mode walks every event at stride=1. inference_only: true, + // Cost is unused in inference_only (outcome labels skipped) but the + // field is required; carry the ES default so a future training-mode + // switch in the harness doesn't silently drop the cost. + outcome_label_cost: ml_alpha::data::loader::DEFAULT_OUTCOME_LABEL_COST_ES, }; 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 1c5b35097..c9cd6db34 100644 --- a/crates/ml-backtesting/tests/ring3_replay.rs +++ b/crates/ml-backtesting/tests/ring3_replay.rs @@ -48,6 +48,7 @@ fn try_loader() -> Option { n_max_sequences: 0, seed: 0xCAFE_F00D, inference_only: true, + outcome_label_cost: ml_alpha::data::loader::DEFAULT_OUTCOME_LABEL_COST_ES, }; 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 2b5468993..f6ddcc4a4 100644 --- a/crates/ml-backtesting/tests/trainer_parity.rs +++ b/crates/ml-backtesting/tests/trainer_parity.rs @@ -35,6 +35,7 @@ fn try_loader(inference_only: bool) -> Option { n_max_sequences: 1, seed: 0xCAFEF00D, inference_only, + outcome_label_cost: ml_alpha::data::loader::DEFAULT_OUTCOME_LABEL_COST_ES, }; match MultiHorizonLoader::new(&cfg) { Ok(l) => Some(l),