fix(crt-a2.1): clamp conviction-EMA rescale to [0, 1] — never amplify weak signals

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 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-20 19:38:50 +02:00
parent 3d8f12deba
commit fe24987690
2 changed files with 76 additions and 14 deletions

View File

@@ -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) {

View File

@@ -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