From aa2b7063462dc262ebf87795ae4e5a9634b5ff35 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Fri, 22 May 2026 08:47:24 +0200 Subject: [PATCH] feat(aux-labels): D-style asymmetric loss-aversion label generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds generate_outcome_labels_d for the future aux supervision head (Layer B of the anti-calibration plan, docs/superpowers/plans/2026-05-22- horizon-rebase-n3-100-300-1000-and-aux-d-labels.md). Per (snapshot t, horizon K, direction d ∈ {long, short}): profit = signed_pnl(d) - cost dd_against = max adverse excursion in [t, t+K] holding direction d y[t, K, d] = profit - 1.5 × |dd_against| The 1.5× drawdown penalty encodes loss-aversion per behavioral finance — trades with deep drawdowns are penalized even if final outcome is positive. Implementation: monotonic-deque sliding-window min/max per horizon, O(N · N_HORIZONS) amortized. NaN at right-edge positions (t+K >= n). Input validation rejects zero horizon and non-finite/negative cost. 13 tests pass: 5 hand-derived value checks (simple up, drawdown penalty, NaN edge, sliding-window correctness, per-horizon independence), 3 additional edge tests (constant-series invariant, zero-horizon validation, negative-cost validation), plus the 5 pre-existing tests unchanged. Not yet wired into the loader (Task B2) or used as supervision target (Tasks B3-B5). New function only. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ml-alpha/src/multi_horizon_labels.rs | 270 ++++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/crates/ml-alpha/src/multi_horizon_labels.rs b/crates/ml-alpha/src/multi_horizon_labels.rs index b488a4e09..20ff6ea77 100644 --- a/crates/ml-alpha/src/multi_horizon_labels.rs +++ b/crates/ml-alpha/src/multi_horizon_labels.rs @@ -12,6 +12,20 @@ //! larger K — and we explicitly want to bypass `prepare_phase1a_data`, //! which is hardwired to `Phase1aConfig::horizon` and has Phase 1a //! corruption-audit assumptions baked in. +//! +//! Phase B.B1 (2026-05-22) — D-style asymmetric loss-aversion labels. +//! +//! [`generate_outcome_labels_d`] produces per-`(t, horizon, direction)` +//! cost-aware labels that embed the trading objective directly into the +//! supervision signal: realized PnL minus round-trip cost, penalised by +//! `1.5 × max-adverse-excursion` along the holding path. Used as aux +//! supervision targets by the trade-outcome head (wired in subsequent +//! tasks). + +use anyhow::{bail, Result}; +use std::collections::VecDeque; + +use crate::heads::N_HORIZONS; /// Output of [`generate_labels`]. pub struct LongHorizonLabels { @@ -65,6 +79,169 @@ pub fn generate_labels(prices: &[f32], k: usize) -> LongHorizonLabels { } } +/// Per-(direction, horizon) cost-aware asymmetric loss-aversion labels. +/// Used as aux supervision target for the trade-outcome head. +pub struct OutcomeLabelsD { + /// `y_long[h][t]` = (price[t+K] - price[t] - cost) - 1.5 * |max_drawdown_long| + /// NaN at indices where the forward window is outside the source file (right + /// edge) — same edge semantics as `generate_labels`. + pub y_long: [Vec; N_HORIZONS], + /// `y_short[h][t]` = (price[t] - price[t+K] - cost) - 1.5 * |max_drawup_short| + pub y_short: [Vec; N_HORIZONS], + /// Round-trip cost in price units (e.g. 0.5 for ES at 2-tick cost). + pub cost_price_units: f32, +} + +/// D-style asymmetric loss-aversion label generator. +/// +/// Per (snapshot t, horizon K, direction d): +/// profit = signed_pnl(d) - cost +/// dd_against = max adverse excursion in [t, t+K] when holding direction d +/// y[t, K, d] = profit - 1.5 × |dd_against| +/// +/// Uses monotonic-deque sliding-window min/max for O(N) per horizon. +/// `prices` is the bar/snapshot-aligned mid-price series. +pub fn generate_outcome_labels_d( + prices: &[f32], + horizons: &[usize; N_HORIZONS], + cost_price_units: f32, +) -> Result { + if !cost_price_units.is_finite() || cost_price_units < 0.0 { + bail!( + "cost_price_units must be finite and non-negative, got {}", + cost_price_units + ); + } + for (i, &k) in horizons.iter().enumerate() { + if k == 0 { + bail!("horizon[{}] must be > 0, got 0", i); + } + } + + let n = prices.len(); + // SAFETY: NaN-initialised then overwritten in place; no MaybeUninit needed. + let mut y_long: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + let mut y_short: [Vec; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]); + + for (h, &k) in horizons.iter().enumerate() { + if n <= k { + continue; + } + let win_min = forward_window_min(prices, k); + let win_max = forward_window_max(prices, k); + // Valid range for full forward window: t in 0..=n-1-k, i.e. t+k <= n-1. + for t in 0..n - k { + let p_t = prices[t]; + let p_kt = prices[t + k]; + // Guard against non-finite inputs anywhere in the path. The min/max + // helpers propagate NaN naturally (NaN compares false; deque keeps it), + // so we re-check on output here for clarity. + if !p_t.is_finite() + || !p_kt.is_finite() + || !win_min[t].is_finite() + || !win_max[t].is_finite() + { + continue; + } + let delta = p_kt - p_t; + let dd_long = (p_t - win_min[t]).max(0.0); + let dd_short = (win_max[t] - p_t).max(0.0); + y_long[h][t] = (delta - cost_price_units) - 1.5 * dd_long; + y_short[h][t] = (-delta - cost_price_units) - 1.5 * dd_short; + } + } + + Ok(OutcomeLabelsD { + y_long, + y_short, + cost_price_units, + }) +} + +/// Forward sliding-window minimum: `out[t] = min(prices[t..=t+k])` for +/// `t + k < n`, and `NaN` for the right-edge positions where the window +/// extends past the end of the input. +fn forward_window_min(prices: &[f32], k: usize) -> Vec { + let n = prices.len(); + let mut out = vec![f32::NAN; n]; + if n == 0 || k >= n { + return out; + } + let mut dq: VecDeque = VecDeque::new(); + // Seed the deque with the first window [0..=k]. + for j in 0..=k { + while let Some(&back) = dq.back() { + if prices[back] >= prices[j] { + dq.pop_back(); + } else { + break; + } + } + dq.push_back(j); + } + out[0] = prices[*dq.front().expect("deque non-empty after seeding")]; + // Slide forward: at each step drop the front if it leaves [t..=t+k], + // then push the new right edge (t+k). + for t in 1..n - k { + let new_j = t + k; + if let Some(&front) = dq.front() { + if front < t { + dq.pop_front(); + } + } + while let Some(&back) = dq.back() { + if prices[back] >= prices[new_j] { + dq.pop_back(); + } else { + break; + } + } + dq.push_back(new_j); + out[t] = prices[*dq.front().expect("deque non-empty after slide")]; + } + out +} + +/// Forward sliding-window maximum: `out[t] = max(prices[t..=t+k])` for +/// `t + k < n`, and `NaN` for the right-edge positions. +fn forward_window_max(prices: &[f32], k: usize) -> Vec { + let n = prices.len(); + let mut out = vec![f32::NAN; n]; + if n == 0 || k >= n { + return out; + } + let mut dq: VecDeque = VecDeque::new(); + for j in 0..=k { + while let Some(&back) = dq.back() { + if prices[back] <= prices[j] { + dq.pop_back(); + } else { + break; + } + } + dq.push_back(j); + } + out[0] = prices[*dq.front().expect("deque non-empty after seeding")]; + for t in 1..n - k { + let new_j = t + k; + if let Some(&front) = dq.front() { + if front < t { + dq.pop_front(); + } + } + while let Some(&back) = dq.back() { + if prices[back] <= prices[new_j] { + dq.pop_back(); + } else { + break; + } + } + dq.push_back(new_j); + out[t] = prices[*dq.front().expect("deque non-empty after slide")]; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -125,4 +302,97 @@ mod tests { assert!(out.n_dropped_invalid >= 4); assert!(out.labels.iter().all(|&y| y == 0.0 || y == 1.0)); } + + #[test] + fn d_labels_match_hand_derivation_simple_up() { + let prices = vec![100.0, 101.0]; // 1.0 up in 1 step + let labels = generate_outcome_labels_d(&prices, &[1; N_HORIZONS], 0.5).unwrap(); + // K=1, t=0: ΔP = +1.0; cost = 0.5 + // y_long = (1.0 - 0.5) - 1.5 × max(0, 100 - min(100, 101)) = 0.5 - 0 = 0.5 + // y_short = (-1.0 - 0.5) - 1.5 × max(0, max(100, 101) - 100) = -1.5 - 1.5 = -3.0 + assert!((labels.y_long[0][0] - 0.5).abs() < 1e-6); + assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6); + } + + #[test] + fn d_labels_penalize_drawdown_before_recovery() { + let prices = vec![100.0, 99.0, 100.0, 101.0]; // drops then recovers; ΔP_K=3 = +1.0 + let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); + // K=3, t=0: ΔP = +1.0; cost = 0.5 + // y_long: dd_against = 100 - min(100, 99, 100, 101) = 100 - 99 = 1.0 + // y_long = (1.0 - 0.5) - 1.5 × 1.0 = 0.5 - 1.5 = -1.0 + // y_short: dd_against = max(100, 99, 100, 101) - 100 = 1.0 + // y_short = (-1.0 - 0.5) - 1.5 × 1.0 = -1.5 - 1.5 = -3.0 + assert!((labels.y_long[0][0] - (-1.0)).abs() < 1e-6); + assert!((labels.y_short[0][0] - (-3.0)).abs() < 1e-6); + } + + #[test] + fn d_labels_nan_at_right_edge() { + let prices = vec![100.0; 5]; + let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); + // For K=3, indices 0..=1 have full window; indices 2,3,4 lack t+K + assert!(labels.y_long[0][0].is_finite()); + assert!(labels.y_long[0][1].is_finite()); + assert!(labels.y_long[0][2].is_nan()); + assert!(labels.y_long[0][3].is_nan()); + assert!(labels.y_long[0][4].is_nan()); + } + + #[test] + fn d_labels_sliding_window_min_max_correct() { + let prices = vec![5.0, 3.0, 8.0, 1.0, 6.0]; + let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.0).unwrap(); + // K=3, t=0: window [5,3,8,1]; ΔP = 1-5 = -4 + // y_long: dd_against = 5 - min(5,3,8,1) = 5 - 1 = 4 + // y_long = (-4 - 0) - 1.5 × 4 = -10 + // y_short: dd_against = max(5,3,8,1) - 5 = 8 - 5 = 3 + // y_short = (4 - 0) - 1.5 × 3 = -0.5 + assert!((labels.y_long[0][0] - (-10.0)).abs() < 1e-6); + assert!((labels.y_short[0][0] - (-0.5)).abs() < 1e-6); + } + + #[test] + fn d_labels_per_horizon_independent() { + // Monotonic +0.5 per step. + let prices: Vec = (0..20).map(|i| 100.0 + (i as f32 * 0.5)).collect(); + let horizons = [2, 5, 10]; + let labels = generate_outcome_labels_d(&prices, &horizons, 0.0).unwrap(); + // K=2: ΔP = 2 × 0.5 = 1.0; dd_against_long = 0 (monotonic up) + // y_long[0][0] = 1.0 - 0 = 1.0 + // K=5: ΔP = 2.5; y_long[1][0] = 2.5 + // K=10: ΔP = 5.0; y_long[2][0] = 5.0 + assert!((labels.y_long[0][0] - 1.0).abs() < 1e-6); + assert!((labels.y_long[1][0] - 2.5).abs() < 1e-6); + assert!((labels.y_long[2][0] - 5.0).abs() < 1e-6); + // Short on monotonic up should be strictly negative at every horizon. + assert!(labels.y_short[0][0] < 0.0); + assert!(labels.y_short[1][0] < 0.0); + assert!(labels.y_short[2][0] < 0.0); + } + + #[test] + fn d_labels_long_short_invariant_on_constant_series() { + // Constant prices: ΔP = 0, dd = 0 for both sides → both labels = -cost. + let prices = vec![100.0_f32; 10]; + let labels = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], 0.5).unwrap(); + for t in 0..7 { + assert!((labels.y_long[0][t] - (-0.5)).abs() < 1e-6); + assert!((labels.y_short[0][t] - (-0.5)).abs() < 1e-6); + } + } + + #[test] + fn d_labels_reject_zero_horizon() { + let prices = vec![100.0_f32; 10]; + let err = generate_outcome_labels_d(&prices, &[0; N_HORIZONS], 0.5); + assert!(err.is_err()); + } + + #[test] + fn d_labels_reject_negative_cost() { + let prices = vec![100.0_f32; 10]; + let err = generate_outcome_labels_d(&prices, &[3; N_HORIZONS], -0.1); + assert!(err.is_err()); + } }