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