feat(aux-labels): wire D-style outcome labels into MultiHorizonLoader

Extends MultiHorizonLoaderConfig with `outcome_label_cost` (default
DEFAULT_OUTCOME_LABEL_COST_ES = 0.5 = 2 ticks for ES round-trip).
LabeledSequence + LoadedFile gain outcome_long + outcome_short arrays
of [Vec<f32>; N_HORIZONS] populated by generate_outcome_labels_d during
LoadedFile construction (same !inference_only branch as BCE labels).

CLI exposure: alpha_train.rs gains --outcome-label-cost flag with the
ES default. All 6 MultiHorizonLoaderConfig consumers updated atomically
per feedback_no_partial_refactor: alpha_train (train + val loaders),
in-file test fixtures (×2), multi_horizon_loader.rs (×2), harness.rs,
trainer_parity.rs, ring3_replay.rs.

cargo check --workspace clean; ml-alpha lib tests 41 passed.

B3+ tasks not yet touched: outcome labels are computed and propagated to
LabeledSequence but no consumer reads them yet (aux trunk Tasks B3-B5
will wire that).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 08:53:27 +02:00
parent aa2b706346
commit 859d0c738b
6 changed files with 96 additions and 6 deletions

View File

@@ -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<u64>,
/// 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!(

View File

@@ -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_<YEAR>-Q<n>.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<Vec<PathBuf
pub struct LabeledSequence {
/// `seq_len` consecutive raw snapshot inputs.
pub snapshots: Vec<Mbp10RawInput>,
/// `[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<f32>; 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<f32>; 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<f32>; 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<Mbp10Snapshot>,
/// `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<f32>; 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<f32>; N_HORIZONS],
/// `outcome_short_full[h]` — short-direction counterpart to
/// `outcome_long_full`.
outcome_short_full: [Vec<f32>; 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<f32>; N_HORIZONS] = Default::default();
let mut outcome_long_full: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_short_full: [Vec<f32>; N_HORIZONS] = Default::default();
if !cfg.inference_only {
let prices: Vec<f32> = 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<f32>; N_HORIZONS] = Default::default();
let mut outcome_long: [Vec<f32>; N_HORIZONS] = Default::default();
let mut outcome_short: [Vec<f32>; 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();

View File

@@ -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<MultiHorizonLoaderConfig> {
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");

View File

@@ -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)?;

View File

@@ -48,6 +48,7 @@ fn try_loader() -> Option<MultiHorizonLoader> {
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),

View File

@@ -35,6 +35,7 @@ fn try_loader(inference_only: bool) -> Option<MultiHorizonLoader> {
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),