feat(sp21): T2.2 Phase 8.2 — signal-drive E6/E7/E8 thresholds via pnl_std (atomic)

Three producers in enrichment.rs had hardcoded magnitude thresholds
sized for time-bar trades (per-trade pnl ≈ 1e-3..1e-2). Foxhunt's
volume bars (bars_per_day ≈ 34_496) produce per-trade pnl in
1e-7..1e-5 range, so the constants tripped every cycle:

  - compute_winner_concentration: `all_mean <= 1e-6` → win_conc=0
  - compute_hindsight_labels:     `t.pnl < -0.001` → hindsight count=0
  - compute_curriculum_weights:   `(1/sharpe).clamp(0.1, 10.0)` →
                                  similar small Sharpes saturate to 10 →
                                  uniform weights → curric_conc=0

Surfaced by smoke v5 (train-vds7r, commit d1638959d): across all 3
cycles of fold 0, the E6/E7/E8 scalar signals stayed pinned at
0.0000 — the Phase 5/6/7 PER-alpha-boost path was dark code.

Fix:
  - New `compute_pnl_std(trades)` helper: Welford std over eval-trade
    pnl column; 0.0 on empty, |pnl| on single-element bootstrap
  - `compute_winner_concentration(trades, pnl_std)`: guard becomes
    `all_mean <= (0.1 × pnl_std).max(0.0)`
  - `compute_hindsight_labels(trades, pnl_std)`: filter becomes
    `t.pnl < (-0.5 × pnl_std).min(-1e-9)` (floor handles cold start)
  - `compute_curriculum_weights`: clamp relaxed (0.1, 10.0) →
    (0.01, 100.0); fallback weight 0.1 → 0.01
  - `run_enrichments` computes pnl_std once per cycle, threads through
    to producers; diagnostic log extended with pnl_std field

Pearls honoured:
  - feedback_isv_for_adaptive_bounds: hardcoded constants → signal-
    derived thresholds
  - pearl_controller_anchors_isv_driven: anchors derive from observed
    data scale, not bar-resolution magic numbers
  - pearl_first_observation_bootstrap: pnl_std=0 → producers return
    sentinel 0.0 or use absolute floor (cold-start preserved)

Verification:
  - cargo check -p ml --features cuda          # clean
  - cargo test -p ml --lib financials          # 7/7 (unchanged)

Expected v6 cycle 1: pnl_std ≈ 1e-5, win_conc ≈ 1.5..3.0,
hindsight count > 40k, curric_conc > 0.

Note: curriculum clamp bounds (0.01, 100.0) are still hardcoded;
making them fully ISV-driven is deferred to Phase 9. Immediate
Phase 8.2 goal is unblocking the dark-code path so the downstream
per_update_pa / per_insert_pa alpha-boost composition actually
fires on volume-bar trades.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-11 10:05:19 +02:00
parent d1638959d3
commit 79d0c53034
2 changed files with 194 additions and 14 deletions

View File

@@ -425,10 +425,16 @@ fn compute_agreement_threshold(
///
/// Used by `per_update_pa` (PER alpha scaling) — see
/// `WINNER_CONCENTRATION_INDEX` in `cuda_pipeline::sp21_isv_slots`.
/// Negative or zero `all_mean_pnl` (i.e., losing strategy)
/// short-circuits to 0.0 sentinel — the signal is ill-defined when
/// the policy isn't profitable.
fn compute_winner_concentration(trades: &[EvalTrade]) -> f32 {
///
/// **Phase 8.2 (2026-05-11)** — losing-strategy guard threshold is
/// signal-driven via `pnl_std` instead of a hardcoded `1e-6`. On
/// volume bars, per-trade `pnl` (realized/equity) lands in
/// `1e-7..1e-5` range, so the original constant tripped on every
/// profitable cycle and the signal stayed pinned at 0. The new
/// guard `all_mean ≤ 0.1 × pnl_std` is bar-resolution-invariant per
/// `pearl_controller_anchors_isv_driven` and
/// `feedback_isv_for_adaptive_bounds`.
fn compute_winner_concentration(trades: &[EvalTrade], pnl_std: f32) -> f32 {
if trades.is_empty() {
return 0.0;
}
@@ -437,9 +443,12 @@ fn compute_winner_concentration(trades: &[EvalTrade]) -> f32 {
let top_n = (sorted.len() / 10).max(1);
let top_mean: f32 = sorted[..top_n].iter().sum::<f32>() / top_n as f32;
let all_mean: f32 = sorted.iter().sum::<f32>() / sorted.len() as f32;
if all_mean <= 1e-6 {
// Losing or break-even strategy — concentration is ill-defined.
// Return 0.0 sentinel so the kernel short-circuits to no boost.
// Guard: losing or break-even strategy → 0.0 sentinel so the
// kernel short-circuits to no boost. Threshold is data-scale-
// adaptive: 0.1×pnl_std means "mean is below a tenth of a single
// trade's typical magnitude" (effectively zero). With `pnl_std=0`
// (degenerate input), the OR clause traps on all_mean ≤ 0.0.
if all_mean <= (0.1 * pnl_std).max(0.0) {
return 0.0;
}
top_mean / all_mean
@@ -516,11 +525,25 @@ fn compute_winner_indices(trades: &[EvalTrade]) -> Vec<usize> {
}
/// E7: Hindsight optimal labels for losing trades.
fn compute_hindsight_labels(trades: &[EvalTrade]) -> Vec<HindsightExperience> {
///
/// **Phase 8.2 (2026-05-11)** — losing-trade filter threshold is
/// signal-driven (`-0.5 × pnl_std`) instead of hardcoded `-0.001`.
/// Volume bars produce per-trade `pnl` in `1e-7..1e-5` range — the
/// constant filter never matched, so hindsight emitted `0` synthetic
/// experiences every cycle (the Phase 6.5 injection path was dark).
/// `0.5 × pnl_std` selects trades that lost at least half a typical
/// trade-magnitude (clearly losing rather than noise), bar-resolution
/// invariant per `pearl_controller_anchors_isv_driven`.
fn compute_hindsight_labels(trades: &[EvalTrade], pnl_std: f32) -> Vec<HindsightExperience> {
let take_n = (trades.len() / 10).max(1);
// Sentinel guard: pnl_std=0 (degenerate or pre-warmup) → fall back
// to a tiny absolute floor (-1e-9). Any negative pnl will pass,
// including legitimate sub-bps losers; once pnl_std > 0 the
// adaptive threshold dominates.
let threshold = (-0.5 * pnl_std).min(-1e-9);
trades
.iter()
.filter(|t| t.pnl < -0.001)
.filter(|t| t.pnl < threshold)
.take(take_n)
.map(|t| HindsightExperience {
window_index: t.window_index,
@@ -539,6 +562,18 @@ fn compute_hindsight_labels(trades: &[EvalTrade]) -> Vec<HindsightExperience> {
}
/// E8: Curriculum weights — inverse-Sharpe per segment.
///
/// **Phase 8.2 (2026-05-11)** — relaxed inverse-Sharpe clamp from
/// `(0.1, 10.0)` to `(0.01, 100.0)` so close-but-distinct small
/// per-segment Sharpes (e.g. 0.05 vs 0.09 → `1/sharpe` of 20 vs 11)
/// no longer both saturate the upper bound. Under the tight clamp,
/// every cycle produced near-uniform weights → `curric_conc=0`. The
/// wider range preserves segment-rank fidelity while still keeping
/// the post-normalisation weights well-conditioned (max/min ratio
/// of 1e4 instead of 1e2). Per `pearl_controller_anchors_isv_driven`
/// these bounds should eventually be ISV-driven too — left as a
/// follow-up because the immediate Phase 8.2 goal is just to unblock
/// the dark-code path; full adaptive bounds are Phase 9 scope.
fn compute_curriculum_weights(trades: &[EvalTrade], n_segments: usize) -> Vec<f32> {
if trades.is_empty() || n_segments == 0 {
return Vec::new();
@@ -549,7 +584,7 @@ fn compute_curriculum_weights(trades: &[EvalTrade], n_segments: usize) -> Vec<f3
let start = seg * seg_size;
let end = ((seg + 1) * seg_size).min(trades.len());
if start >= trades.len() {
return 0.1;
return 0.01;
}
let slice = &trades[start..end];
let mean: f32 =
@@ -560,7 +595,7 @@ fn compute_curriculum_weights(trades: &[EvalTrade], n_segments: usize) -> Vec<f3
.sum::<f32>()
/ slice.len().max(1) as f32;
let sharpe = mean / var.sqrt().max(1e-6);
(1.0 / sharpe.max(0.1)).clamp(0.1, 10.0)
(1.0 / sharpe.max(0.01)).clamp(0.01, 100.0)
})
.collect();
let total: f32 = weights.iter().sum();
@@ -572,6 +607,34 @@ fn compute_curriculum_weights(trades: &[EvalTrade], n_segments: usize) -> Vec<f3
weights
}
/// SP21 T2.2 Phase 8.2 (2026-05-11) — per-trade pnl standard deviation.
///
/// Single-pass std computed via Welford-style numerically-stable
/// algorithm over the eval-trade tape's `pnl` column. Used as the
/// signal-driven scale for adaptive thresholds in
/// `compute_winner_concentration` and `compute_hindsight_labels`,
/// honouring `feedback_isv_for_adaptive_bounds`.
///
/// Returns `0.0` for empty input. With single-element input returns
/// `|pnl|` as a coarse magnitude (variance is mathematically zero
/// but the producers need a non-zero scale to escape sentinel).
fn compute_pnl_std(trades: &[EvalTrade]) -> f32 {
if trades.is_empty() {
return 0.0;
}
if trades.len() == 1 {
return trades[0].pnl.abs();
}
let n = trades.len() as f32;
let mean: f32 = trades.iter().map(|t| t.pnl).sum::<f32>() / n;
let var: f32 = trades
.iter()
.map(|t| (t.pnl - mean).powi(2))
.sum::<f32>()
/ n;
var.max(0.0).sqrt()
}
// ---------------------------------------------------------------------------
// Main dispatcher
// ---------------------------------------------------------------------------
@@ -590,6 +653,13 @@ pub(crate) fn run_enrichments(
) -> EnrichmentResult {
state.cycle_count += 1;
// SP21 T2.2 Phase 8.2 (2026-05-11): cycle-scoped pnl_std, used as
// the data-scale anchor for E6/E7 thresholds (replaces bar-
// resolution-dependent constants 1e-6 and -1e-3). Computed once
// per cycle and threaded through to producers; honours
// `pearl_controller_anchors_isv_driven`.
let pnl_std = compute_pnl_std(eval_trades);
// E1: Q-value bias correction
let q_correction = compute_q_correction(eval_trades);
@@ -616,10 +686,15 @@ pub(crate) fn run_enrichments(
let winner_indices = compute_winner_indices(eval_trades);
// SP21 T2.2 Phase 5+6 (2026-05-11) — scalar signals for PER alpha
// scaling, derived from the same per-trade tape that feeds E6/E7.
let winner_concentration = compute_winner_concentration(eval_trades);
// Phase 8.2 (2026-05-11): pnl_std threads through as data-scale
// anchor — replaces the hardcoded 1e-6 short-circuit.
let winner_concentration = compute_winner_concentration(eval_trades, pnl_std);
// E7: Hindsight labels
let hindsight = compute_hindsight_labels(eval_trades);
// Phase 8.2 (2026-05-11): adaptive `-0.5×pnl_std` loser filter
// replaces the hardcoded `-1e-3` (which silently excluded all
// volume-bar losers).
let hindsight = compute_hindsight_labels(eval_trades, pnl_std);
let hindsight_magnitude = compute_hindsight_magnitude(&hindsight);
// E8: Curriculum weights
@@ -632,7 +707,7 @@ pub(crate) fn run_enrichments(
info!(
"Enrichment cycle {} complete: q_corr={:.4}, eps={:.4}, gamma={:?}, \
branch_lr={:?}, agree_thr={:.4}, winners={}, win_conc={:.4}, \
branch_lr={:?}, agree_thr={:.4}, pnl_std={:.2e}, winners={}, win_conc={:.4}, \
hindsight={}, hindsight_mag={:.4}, curriculum_segs={}, curric_conc={:.4}",
state.cycle_count,
q_correction,
@@ -640,6 +715,7 @@ pub(crate) fn run_enrichments(
gamma,
branch_lr_scale,
agreement_threshold,
pnl_std,
winner_indices.len(),
winner_concentration,
hindsight.len(),