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:
@@ -379,19 +379,25 @@ extern "C" __global__ void decision_policy_default(
|
|||||||
// per-horizon contributions — per-horizon sizing logic (Kelly, cap_units,
|
// per-horizon contributions — per-horizon sizing logic (Kelly, cap_units,
|
||||||
// sharpe weights, attribution mask) is unchanged. Spec §4.2.
|
// sharpe weights, attribution mask) is unchanged. Spec §4.2.
|
||||||
//
|
//
|
||||||
// The scale factor is in [0, 1] when raw_max_conv > 0 (since the EMA
|
// CRT-A2.1: clamp the EMA-rescale to [0, 1]. The original A2 formula
|
||||||
// is a convex blend of historical raw_max_conv values, bounded by their
|
// `final_size *= (conv_ema / raw_max_conv)` was predicated on "scale ∈ [0, 1]
|
||||||
// running max). At the first observation the EMA == raw_max_conv exactly
|
// because the EMA is bounded by the running max." That assumption is wrong:
|
||||||
// (bootstrap), so the scale is 1.0 and behaviour is bit-identical to the
|
// the Wiener-α EMA tracks the MEAN of raw_max_conv, not a running max.
|
||||||
// pre-A2 kernel for the very first decision.
|
// 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
|
// 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
|
// to non-zero) would already have returned. We still divide-guard with eps.
|
||||||
// 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.
|
|
||||||
if (raw_max_conv > 1e-9f) {
|
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
|
// 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;
|
sp -= 1;
|
||||||
float final_size = stack_val[sp];
|
float final_size = stack_val[sp];
|
||||||
const unsigned int attribution = stack_mask[sp];
|
const unsigned int attribution = stack_mask[sp];
|
||||||
// CRT-A2: rescale the aggregate magnitude by (conv_ema / raw_max_conv).
|
// CRT-A2.1: same clamp as decision_policy_default — scale ∈ [0, 1].
|
||||||
// See decision_policy_default for rationale.
|
// See decision_policy_default comment block for full rationale and
|
||||||
|
// empirical evidence (smoke vjmwc 9,347% max-drawdown).
|
||||||
if (raw_max_conv > 1e-9f) {
|
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));
|
int lots = (int)truncf(final_size + (final_size >= 0.0f ? 0.5f : -0.5f));
|
||||||
if (lots == 0) {
|
if (lots == 0) {
|
||||||
|
|||||||
@@ -1354,6 +1354,59 @@ fn conviction_ema_smooths_micro_oscillations() -> Result<()> {
|
|||||||
Ok(())
|
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
|
/// 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 —
|
/// >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
|
/// the smoothing is on magnitude only, not on sign. This is the property
|
||||||
|
|||||||
Reference in New Issue
Block a user