From fe249876906211dd54f150f0a5e7dcec7a3e9c70 Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Wed, 20 May 2026 19:38:50 +0200 Subject: [PATCH] =?UTF-8?q?fix(crt-a2.1):=20clamp=20conviction-EMA=20resca?= =?UTF-8?q?le=20to=20[0,=201]=20=E2=80=94=20never=20amplify=20weak=20signa?= =?UTF-8?q?ls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate 1 catastrophic failure (smoke vjmwc, commit 3d8f12deb): - 62× hyperactivity (157,470 trades vs 2,511 baseline) - 9,347% max-drawdown ($327M loss on $3.5M base) - Sharpe -15.63 (2.4× worse than baseline) Root cause: A2's formula final_size *= (conv_ema / raw_max_conv) amplified weak signals because EMA tracks the MEAN of raw_max_conv, NOT a running max as the author's comment claimed. When raw_max_conv < conv_ema (normal during quiet periods between strong signals), the multiplier was > 1, blowing up small-signal positions: raw=0.05, ema=0.3 → 6× amplification raw=0.01, ema=0.3 → 30× amplification Fix: clamp scale ∈ [0, 1] in BOTH decision_policy_default and decision_policy_program (OP_WRITE_ORDER). Only damp when raw is stronger than EMA (scale < 1); never amplify when raw is weaker (scale capped at 1). Math: scale = conv_ema / raw_max_conv // can be 0..∞ scale_clamped = min(scale, 1.0) // can only be 0..1 final_size *= scale_clamped Test: conviction_ema_rescale_never_amplifies_weak_signal validates target_lots stays ≤ 1 when alpha=0.51 (raw_conv=0.02) after EMA warmup at alpha=0.7 (ema~=0.4). Without the clamp the broken multiplier is 20×. Co-Authored-By: Claude Sonnet 4.6 --- crates/ml-backtesting/cuda/decision_policy.cu | 37 ++++++++----- .../ml-backtesting/tests/stop_controller.rs | 53 +++++++++++++++++++ 2 files changed, 76 insertions(+), 14 deletions(-) diff --git a/crates/ml-backtesting/cuda/decision_policy.cu b/crates/ml-backtesting/cuda/decision_policy.cu index 4ee4608e6..4eca211fb 100644 --- a/crates/ml-backtesting/cuda/decision_policy.cu +++ b/crates/ml-backtesting/cuda/decision_policy.cu @@ -379,19 +379,25 @@ extern "C" __global__ void decision_policy_default( // per-horizon contributions — per-horizon sizing logic (Kelly, cap_units, // sharpe weights, attribution mask) is unchanged. Spec §4.2. // - // The scale factor is in [0, 1] when raw_max_conv > 0 (since the EMA - // is a convex blend of historical raw_max_conv values, bounded by their - // running max). At the first observation the EMA == raw_max_conv exactly - // (bootstrap), so the scale is 1.0 and behaviour is bit-identical to the - // pre-A2 kernel for the very first decision. - // + // CRT-A2.1: clamp the EMA-rescale to [0, 1]. The original A2 formula + // `final_size *= (conv_ema / raw_max_conv)` was predicated on "scale ∈ [0, 1] + // because the EMA is bounded by the running max." That assumption is wrong: + // the Wiener-α EMA tracks the MEAN of raw_max_conv, not a running max. + // When raw_max_conv < conv_ema (normal during quiet periods between strong + // signals), the ratio is > 1 and final_size is AMPLIFIED, not damped. + // Empirical evidence — smoke vjmwc (commit 3d8f12deb): + // n_trades: 157,470 vs 2,511 baseline (62× hyperactivity) + // max_drawdown: 9,347% vs 53.5% baseline ($327M on $3.5M base) + // Sharpe_ann: -15.63 vs -6.64 baseline + // Concretely: raw=0.05, ema=0.3 → 6× amplification on weak signal. + // Fix: clamp scale ∈ [0, 1]. Only damp (scale < 1 when raw > ema); + // never amplify (scale capped at 1 when raw < ema). // Guard: when raw_max_conv ≈ 0 the threshold gate above (when configured - // to non-zero) would already have returned. Under threshold=0.0 a raw_max_conv - // of 0 means every alpha is exactly 0.5 → final_size is also 0 (any dir - // contributions cancel in expectation under uniform Kelly/cap); the scale - // doesn't change that. We still divide-guard with eps to be safe. + // to non-zero) would already have returned. We still divide-guard with eps. if (raw_max_conv > 1e-9f) { - final_size *= (conv_ema / raw_max_conv); + float scale = conv_ema / raw_max_conv; + if (scale > 1.0f) scale = 1.0f; // CRT-A2.1: only damp, never amplify + final_size *= scale; } // Round-to-nearest (truncation alone bites here: 0.8(f32) * 0.75 * 5.0 @@ -680,10 +686,13 @@ extern "C" __global__ void decision_policy_program( sp -= 1; float final_size = stack_val[sp]; const unsigned int attribution = stack_mask[sp]; - // CRT-A2: rescale the aggregate magnitude by (conv_ema / raw_max_conv). - // See decision_policy_default for rationale. + // CRT-A2.1: same clamp as decision_policy_default — scale ∈ [0, 1]. + // See decision_policy_default comment block for full rationale and + // empirical evidence (smoke vjmwc 9,347% max-drawdown). if (raw_max_conv > 1e-9f) { - final_size *= (conv_ema / raw_max_conv); + float scale = conv_ema / raw_max_conv; + if (scale > 1.0f) scale = 1.0f; // CRT-A2.1: only damp, never amplify + final_size *= scale; } int lots = (int)truncf(final_size + (final_size >= 0.0f ? 0.5f : -0.5f)); if (lots == 0) { diff --git a/crates/ml-backtesting/tests/stop_controller.rs b/crates/ml-backtesting/tests/stop_controller.rs index e8718e92b..afb7280cd 100644 --- a/crates/ml-backtesting/tests/stop_controller.rs +++ b/crates/ml-backtesting/tests/stop_controller.rs @@ -1354,6 +1354,59 @@ fn conviction_ema_smooths_micro_oscillations() -> Result<()> { Ok(()) } +/// CRT-A2.1: the conviction-EMA rescale must NEVER amplify a weak signal. +/// +/// The original A2 formula `final_size *= (conv_ema / raw_max_conv)` was +/// predicated on the erroneous claim that "scale ∈ [0,1] because EMA is +/// bounded by the running max." The EMA tracks the MEAN, not a running max; +/// when raw_max_conv < conv_ema (quiet period after strong signals), the +/// multiplier is > 1 and final_size is amplified. This produced 62× trade +/// hyperactivity and 9,347% max-drawdown in smoke vjmwc (commit 3d8f12deb). +/// +/// This test builds up a moderate EMA (~0.4) via warmup decisions at strong +/// alpha, then fires a single weak-alpha decision (|signal|=0.02). With the +/// broken formula the target would be amplified ~20×; with the fix the scale +/// is clamped to 1.0 and target stays ≤ 1 lot. +#[test] +#[ignore = "requires CUDA"] +fn conviction_ema_rescale_never_amplifies_weak_signal() -> Result<()> { + let dev = match MlDevice::cuda(0) { + Ok(d) => d, + Err(e) => { eprintln!("skipping: cuda unavailable ({e})"); return Ok(()); } + }; + let mut sim = LobSimCuda::new(1, &dev)?; + sim.upload_price_range(&[1000.0], &[20000.0])?; + let (bp, bs, ap, az) = level_book(5500.0, 0.25); + sim.apply_snapshot(&bp, &bs, &ap, &az)?; + let cfg = cfg_default(1); + + // Build up a moderate conviction_ema via several decisions at strong alpha. + // alpha=0.7 → raw_max_conv = |0.7-0.5| * 2 = 0.4 per horizon. + for i in 0..20u64 { + sim.broadcast_alpha(&[0.7; N_HORIZONS])?; + sim.step_decision_with_latency(1_000_000_000 * (i + 1), &cfg)?; + } + let ema_after_warmup = sim.read_conviction_ema(0)?; + assert!(ema_after_warmup > 0.3, + "EMA should reach ~0.4 after warmup with alpha=0.7; got {ema_after_warmup}"); + + // Now feed a WEAK signal: alpha=0.51 → raw_max_conv = |0.51-0.5| * 2 = 0.02. + // Broken formula: scale = ema(~0.4) / raw(0.02) = ~20× amplification. + // Fixed formula: scale = min(0.4/0.02, 1.0) = 1.0 → no amplification. + // With max_lots=5, even at full kelly the target before rescale is ≤ 5. + // Amplified by 20× it would hit 100 (capped to 5 by max_lots or not — + // either way the kernel saturates at max_lots). But with a weak signal, + // the raw Kelly fraction is tiny so the raw target is ≤ 1 lot. + // The invariant we assert: target_lots must be ≤ 1 for this near-neutral signal. + sim.broadcast_alpha(&[0.51; N_HORIZONS])?; + sim.step_decision_with_latency(1_000_000_000 * 100, &cfg)?; + let (_side, size) = sim.read_market_target(0)?; + assert!(size <= 1, + "weak signal (alpha=0.51) must not amplify target beyond 1 lot; got size={size} \ + (max_lots=5, ema~={ema_after_warmup:.3}). If size>1 the CRT-A2.1 clamp is missing."); + Ok(()) +} + /// CRT-A2 corollary: when a true reversal arrives (alpha sign flips from /// >0.5 to <0.5), the target direction must respond at the next event — /// the smoothing is on magnitude only, not on sign. This is the property