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