feat(aux-labels): D-style asymmetric loss-aversion label generator

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) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-22 08:47:24 +02:00
parent 6d772a5d68
commit aa2b706346

View File

@@ -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<f32>; N_HORIZONS],
/// `y_short[h][t]` = (price[t] - price[t+K] - cost) - 1.5 * |max_drawup_short|
pub y_short: [Vec<f32>; 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<OutcomeLabelsD> {
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<f32>; N_HORIZONS] = std::array::from_fn(|_| vec![f32::NAN; n]);
let mut y_short: [Vec<f32>; 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<f32> {
let n = prices.len();
let mut out = vec![f32::NAN; n];
if n == 0 || k >= n {
return out;
}
let mut dq: VecDeque<usize> = 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<f32> {
let n = prices.len();
let mut out = vec![f32::NAN; n];
if n == 0 || k >= n {
return out;
}
let mut dq: VecDeque<usize> = 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<f32> = (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());
}
}