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:
@@ -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!(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user