Files
foxhunt/crates/ml-backtesting/cuda/decision_policy.cu
jgrusewski 0384f76743 fix(decision-policy): use signed-conviction EMA, not magnitude-only EMA
Anti-calibration evidence (smoke ckpts across architecture variants
showed higher reported conviction → MORE negative PnL, -15.6 → -145.2)
traced to a magnitude/direction mismatch in the sizing formula:

  conv_ema = EMA(|conviction_signed|)         # magnitude smoothing
  target_lots = sign(conviction_signed)       # INSTANTANEOUS sign
              × conv_ema × max_lots           # × SMOOTHED magnitude

When per-horizon directions disagree event-to-event, the magnitude EMA
stays high (|x| EMA can't cancel) but the sign flips on noise. Result:
big trades in random direction whenever the 5 horizons disagree.

Replace with a single signed-conviction EMA:

  conv_signed_ema = EMA(conviction_signed)
  target_lots = sign(conv_signed_ema)
              × |conv_signed_ema| × max_lots

Now BOTH sign AND magnitude come from the same smoothed signal. When
directions disagree, signed EMA → 0 collapses size to zero naturally.

Atomic migration across decision_policy.cu (2 kernels), pnl_track.cu
(open-branch reads the SIGNED buffer and takes fabsf for the magnitude-
semantics open_trade_state offsets that composite_exit_check forms ratios
from), and src/sim/mod.rs (renamed device buffers + accessor + 4 launch
sites). No legacy aliases per feedback_no_legacy_aliases; no parallel
buffers per feedback_single_source_of_truth_no_duplicates; α floor 0.4
preserved per pearl_wiener_alpha_floor_for_nonstationary; first-
observation bootstrap preserved per pearl_first_observation_bootstrap.

ml-backtesting lib: 33 passed.
GPU oracle: signed_conviction_ema_collapses_on_sign_flips passes —
observed steady-state shows |signed_ema| ≈ 0.205 (post-fix) producing
|target_lots| = 2 every tail event, vs the pre-fix bug's predicted
|target_lots| = 7 every tail event. Existing
multi_horizon_conviction_cancels_on_disagreement still passes (zero-
cancellation regime is even cleaner under signed EMA).
ml-alpha lib: 33 passed (no regression).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:26:22 +02:00

1265 lines
63 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// decision_policy.cu — alpha → per-horizon Kelly → aggregate → market target.
//
// Hardcoded v1 policy = Strategy::default_for(max_lots) from C3:
// for each h in 0..N_HORIZONS:
// compute signed sizing from (alpha[h], IsvKellyState[h])
// aggregate via WeightedByRealizedSharpe:
// w[h] = max(0, recent_sharpe[h]) / sum_of_positive_sharpes
// final = sum_h w[h] * signed_size[h]
// final → side+size in market_targets[b]
//
// The bytecode VM from spec §6 is intentionally not implemented in v1 —
// the default Strategy::default_for produces exactly this shape and
// alternative compositions are deferred. Bytecode plumbing remains in
// src/policy/mod.rs ready for v2 expansion.
//
// Single-writer per block (thread 0). See spec §5.
#include "lob_state.cuh"
// Minimum closed-trade count before the variance-derived `cap_units`
// constraint is trusted. With N < this threshold the cap falls back to
// the host-supplied `max_lots` budget — a single-sample variance proxy
// (`ret²`) systematically over-estimates Var[ret] for a directionally
// biased loss/win and locks `cap_lots` to ~zero, which kills further
// trading. Threshold of 10 picked as the standard rule-of-thumb for
// "first moment estimate becomes informative" — same role as the
// pearl_first_observation_bootstrap pattern applied to the variance side.
#define MIN_TRADES_FOR_VAR_CAP 10u
// CRT Phase A0.5 corrective: record a single max-conviction f32 per
// decision-tick into a host-allocated growing device buffer. The same
// max_conv calculation as the per-backtest threshold gate (decision_policy_default
// line ~179) but driven once per decision rather than per backtest, with
// the result staged on-device for a single end-of-run DtoV. Lets the
// harness keep the percentile-tuning side-channel alive without round-
// tripping host memory on the per-event hot path.
//
// Launch: grid=(1,1,1), block=(1,1,1). Single thread.
// convictions_d : [max_decisions] device buffer
// write_idx : host-managed write head (passed as scalar)
// Writes one f32 into convictions_d[write_idx]. Bounds-clamps to
// max_decisions so a long-running smoke that exceeds the allocation
// can't OOB. Out-of-bound decisions just stop being recorded; the
// host-side counter knows the real decision_count.
extern "C" __global__ void record_max_conviction_to_slot(
const float* __restrict__ alpha_probs, // [N_HORIZONS]
float* __restrict__ convictions, // [max_decisions]
int write_idx,
int max_decisions
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
if (write_idx < 0 || write_idx >= max_decisions) return;
float max_conv = 0.0f;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
float c = fabsf(alpha_probs[h] - 0.5f) * 2.0f;
// Match the harness's prior clamp: clamp(|p-0.5|*2, 0, 1).
if (c < 0.0f) c = 0.0f;
if (c > 1.0f) c = 1.0f;
if (c > max_conv) max_conv = c;
}
convictions[write_idx] = max_conv;
}
// Per spec §3 + §5: shared stop-check helper called from both
// decision_policy_default and decision_policy_program. Pure-device,
// no host branches (CUDA Graph capture safe per
// pearl_no_host_branches_in_captured_graph).
//
// Returns 1 if a stop fired (and market_targets[b] was written to
// the (3, 0) force-flat encoding); 0 otherwise. Caller checks return
// value to decide whether to skip the VM / sizing logic.
//
// Skeleton: gates on flat position only. SL/trail/averaging logic
// lands in plan Tasks 4-6 of the ISV-stop-controller series.
__device__ static int stop_check_isv(
int b,
const Pos* __restrict__ positions,
const unsigned char* __restrict__ isv_kelly_base,
const unsigned int* __restrict__ open_horizon_masks,
const float* __restrict__ atr_mid_ema,
float* __restrict__ trail_hwm,
const Book* __restrict__ books,
const float* __restrict__ cost_per_lot_per_side,
int* __restrict__ market_targets,
// S2.1: mh_kernel_calls counts stop_check_isv entries with non-zero position.
// Kept as an ongoing generic diagnostic (always-non-decreasing means SL/trail
// are being evaluated). The 6 max_hold-specific counters were removed when
// max_hold enforcement moved to resting_orders_step (event-rate).
unsigned int* __restrict__ mh_kernel_calls
) {
const Pos& pos = positions[b];
if (pos.position_lots == 0) return 0;
// S2.1: count every entry with a non-zero position.
mh_kernel_calls[b] += 1u;
// Spec §5 controller math — SL only in this task; trail in Task 5.
const float atr = atr_mid_ema[b];
const unsigned int mask = open_horizon_masks[b];
const IsvKellyState* isv = reinterpret_cast<const IsvKellyState*>(
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
);
float ema_loss = 0.0f;
float ema_win = 0.0f;
float var_avg = 0.0f;
int n_open = 0;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
if (mask & (1u << h)) {
ema_loss += isv[h].pnl_ema_loss;
ema_win += isv[h].pnl_ema_win;
var_avg += isv[h].realised_return_var;
n_open += 1;
}
}
if (n_open > 0) {
ema_loss /= (float)n_open;
ema_win /= (float)n_open;
var_avg /= (float)n_open;
}
// Per pearl_trade_level_vol_for_stop_distance.md (2026-05-20):
// Trade-level noise floor = sqrt(realised_return_var) bootstrapped from
// cost². Cold-start (var=0): trade_vol = cost (structural minimum,
// sub-cost is sub-fee noise). Post-bootstrap (>= MIN_TRADES_FOR_VAR_CAP
// closes): sqrt(var) dominates — controller learns its own scale.
// cost appears once as a bootstrap sentinel; never as a distance multiplier.
const float cost = cost_per_lot_per_side[b];
const float trade_vol = sqrtf(fmaxf(var_avg, cost * cost));
// pearl_blend_formulas_must_have_permanent_floor — max, not blend.
const float sl_distance = fmaxf(fmaxf(ema_loss, atr), trade_vol);
const float trail_distance = fmaxf(fmaxf(ema_win, atr), trade_vol);
const bool is_long = pos.position_lots > 0;
const Book& bk = books[b];
const float close_px = is_long ? bk.bid_px[0] : bk.ask_px[0];
const float entry_px = pos.vwap_entry;
const float unrealized_pl_per_lot =
is_long ? (close_px - entry_px) : (entry_px - close_px);
const bool sl_fired = unrealized_pl_per_lot <= -sl_distance;
const float new_hwm = fmaxf(trail_hwm[b], unrealized_pl_per_lot);
trail_hwm[b] = new_hwm;
const bool trail_fired =
(new_hwm > trail_distance)
&& (unrealized_pl_per_lot <= new_hwm - trail_distance);
if (sl_fired || trail_fired) {
// §7 force-flat encoding.
market_targets[b * 2 + 0] = 3;
market_targets[b * 2 + 1] = 0;
return 1;
}
return 0;
}
// CRT.1 C1.2 (anti-calibration fix 2026-05-22): Wiener-α adaptive EMA on
// the SIGNED multi-horizon ISV-weighted conviction scalar (spec §4.4). The
// prior magnitude-only EMA had a structural anti-calibration bug:
//
// conv_ema_mag = EMA(|conviction_signed|) // magnitude smoothing
// target_lots = sign(conviction_signed) // INSTANTANEOUS sign
// × conv_ema_mag × max_lots // × SMOOTHED magnitude
//
// When per-horizon directions disagreed event-to-event, the magnitude EMA
// stayed high (|x| EMA cannot cancel) but the sign flipped on noise →
// big trades in random direction. Empirically: higher reported conviction
// correlated with MORE negative PnL (-15.6 → -145.2 in smoke ckpts).
//
// The signed-EMA fix collapses both sign and magnitude from the same
// smoothed signal: directions that disagree event-to-event cancel naturally
// in EMA(signed_conviction) → 0, which collapses |signed_ema| → 0 → size 0.
//
// Updates per-backtest second-order EMAs of sample variance + difference
// variance (now both operating on the signed conviction), derives adaptive
// α with floor 0.4 per pearl_wiener_alpha_floor_for_nonstationary, and
// blends. First-observation bootstrap (prev_ema == 0): replace directly
// per pearl_first_observation_bootstrap.
//
// Writes new SIGNED EMA to conv_signed_ema[b]. Caller derives both
// direction and magnitude from `fabsf(returned_signed_ema)` and the sign
// of the returned signed EMA — guaranteeing they come from the SAME
// smoothed signal. The sample-variance EMA tracks E[signed_conviction²]
// = Var[signed_conviction] (since the signal is bounded in [-1, +1] and
// the variance-centering Welford pass would be a no-op against zero in
// the steady disagreement-cancellation case the EMA targets).
//
// Single-thread-per-backtest invariant: caller must already have
// gated on threadIdx.x == 0.
// CRT.1 C1.4: composite exit_signal safety circuit-breaker per spec §4.3.
// Backup exit path for cases the primary `target → 0 from collapsed
// conviction` mechanism (C1.2) misses — most importantly when conviction
// stays bounded above zero but the trade is deep in unrealized loss.
//
// Three risk dimensions, max-of-three composite:
// degradation_term = (1 conv_ema_now / conviction_at_entry) / 2.0
// (50% conviction decay relative to entry fires)
// disagreement_term = disagree_consecutive_events / 32.0
// (32 events of short/long horizon disagreement)
// pnl_decay_term = max(pnl_adjusted_conv_ema, 0) / 1.0
// (pnl-adjusted conviction flips and grows < 1)
//
// Reads the trajectory fields populated by the decision kernel above.
// Writes force-flat (3, 0) on fire. Returns 1 if fired, 0 otherwise.
//
// Thresholds (2.0 / 32.0 / 1.0) are spec-bootstrap defaults; they migrate
// to ISV-driven values in CRT.2 alongside the rest of the controller anchors
// (pearl_controller_anchors_isv_driven). Single-thread-per-backtest invariant.
__device__ static int composite_exit_check(
int b,
const Pos* __restrict__ positions,
const unsigned char* __restrict__ open_trade_state,
int* __restrict__ market_targets
) {
if (positions[b].position_lots == 0) return 0;
const unsigned char* st_ro = open_trade_state + (size_t)b * 64;
const float conviction_at_entry = *reinterpret_cast<const float*>(st_ro + 20);
const float conv_ema_now = *reinterpret_cast<const float*>(st_ro + 24);
const float pnl_adjusted_conv_ema = *reinterpret_cast<const float*>(st_ro + 44);
const unsigned int disagree_n = *reinterpret_cast<const unsigned int*>(st_ro + 56);
const float degradation_factor = 2.0f;
const float disagreement_factor = 32.0f;
const float pnl_decay_factor = 1.0f;
const float degradation_term = (conviction_at_entry > 1e-6f)
? (1.0f - conv_ema_now / conviction_at_entry) / degradation_factor
: 0.0f;
const float disagreement_term = (float)disagree_n / disagreement_factor;
const float pnl_decay_term = fmaxf(-pnl_adjusted_conv_ema, 0.0f) / pnl_decay_factor;
float exit_signal = degradation_term;
if (disagreement_term > exit_signal) exit_signal = disagreement_term;
if (pnl_decay_term > exit_signal) exit_signal = pnl_decay_term;
if (exit_signal >= 1.0f) {
market_targets[b * 2 + 0] = 3;
market_targets[b * 2 + 1] = 0;
return 1;
}
return 0;
}
// CRT.1 C1.4: update intra-trade trajectory fields in open_trade_state.
// Called by the decision kernel each event AFTER the multi-horizon
// conviction is computed and EMA-smoothed. Writes the four offsets the
// composite_exit_check reads on the next stop_check pass:
// offset 24: conviction_ema_mag (magnitude of conv_signed_ema_per_b[b];
// composite_exit_check uses the ratio conv_ema_now /
// conviction_at_entry which only makes sense as magnitudes)
// offset 44: pnl_adjusted_conviction_ema (signed by sign of unrealized)
// offset 48: peak_unrealized_pnl (max tracker)
// offset 56: disagreement_consecutive_events (short vs long horizon)
//
// Skipped when no position is open — the composite check is a guard
// against losing-trade edge cases and trajectory state is meaningless
// pre-entry. Single-thread-per-backtest invariant: caller must already
// have gated on threadIdx.x == 0.
__device__ static void update_open_trade_trajectory(
int b,
const Pos* __restrict__ positions,
const Book* __restrict__ books,
const float* __restrict__ alpha_probs,
float conv_ema_mag,
unsigned char* __restrict__ open_trade_state
) {
const Pos& pos = positions[b];
if (pos.position_lots == 0) return;
unsigned char* st = open_trade_state + (size_t)b * 64;
// Mirror the current smoothed conviction MAGNITUDE into open_trade_state.
// composite_exit_check forms `1 conv_ema_now / conviction_at_entry`
// which is a degradation ratio — only magnitude (not signed) makes
// sense there, because the signed EMA can flip sign on a deep-loss
// reversal which would invert the ratio meaning.
*reinterpret_cast<float*>(st + 24) = conv_ema_mag;
// Unrealized P&L = (close_px vwap_entry) × dir × |lots|.
// Long: close at best bid; short: close at best ask. Matches the
// close_px used by stop_check_isv's SL/trail computation.
const Book& bk = books[b];
const bool is_long = pos.position_lots > 0;
const float close_px = is_long ? bk.bid_px[0] : bk.ask_px[0];
const float lots_abs = is_long ? (float)pos.position_lots
: -(float)pos.position_lots;
const float dir_long = is_long ? 1.0f : -1.0f;
const float unrealized = (close_px - pos.vwap_entry) * dir_long * lots_abs;
// pnl-adjusted conviction: sign of unrealized × conv_ema_mag. EMA on
// top with the same fixed-α=0.1 used by the conviction second-order
// EMAs in update_signed_conviction_ema. First-observation bootstrap
// per pearl_first_observation_bootstrap (prev == 0 ⇒ replace directly).
const float pnl_sign = (unrealized >= 0.0f) ? 1.0f : -1.0f;
const float prev_pnl_adj = *reinterpret_cast<const float*>(st + 44);
const float new_pnl_adj_inst = pnl_sign * conv_ema_mag;
const float new_pnl_adj = (prev_pnl_adj == 0.0f)
? new_pnl_adj_inst
: (0.9f * prev_pnl_adj + 0.1f * new_pnl_adj_inst);
*reinterpret_cast<float*>(st + 44) = new_pnl_adj;
// peak_unrealized_pnl tracker.
const float prev_peak = *reinterpret_cast<const float*>(st + 48);
if (unrealized > prev_peak) {
*reinterpret_cast<float*>(st + 48) = unrealized;
}
// disagreement_consecutive_events — short-horizon (h=0) vs long-horizon
// (h=N_HORIZONS-1) directional sign disagreement. Reset to 0 when they
// align, increment when they oppose. Used by composite_exit_check's
// disagreement_term.
const float p_short = alpha_probs[0];
const float p_long = alpha_probs[N_HORIZONS - 1];
const bool dir_short_up = (p_short > 0.5f);
const bool dir_long_up = (p_long > 0.5f);
const unsigned int prev_disagree = *reinterpret_cast<const unsigned int*>(st + 56);
if (dir_short_up != dir_long_up) {
*reinterpret_cast<unsigned int*>(st + 56) = prev_disagree + 1u;
} else {
*reinterpret_cast<unsigned int*>(st + 56) = 0u;
}
}
__device__ static float update_signed_conviction_ema(
int b,
float conviction_signed,
float* __restrict__ conv_signed_ema,
float* __restrict__ conv_signed_diff_var_ema,
float* __restrict__ conv_signed_sample_var_ema
) {
// Defensive clamp to [-1, +1] — conviction is bounded by construction
// (weighted_sum / total_abs_weight with per-horizon magnitudes ∈ [0, 1]
// and per-horizon directions ∈ {1, +1}), but bound it before feeding
// into a variance EMA in case of NaN propagation through near-zero
// total_abs_weight. NaN guard is reflexive — isnan(x) is true when
// x compares unequal with itself; the bilateral clamp below catches it.
if (!(conviction_signed == conviction_signed)) conviction_signed = 0.0f; // NaN
if (conviction_signed > 1.0f) conviction_signed = 1.0f;
if (conviction_signed < -1.0f) conviction_signed = -1.0f;
// Update second-order EMAs (diff_var, sample_var) with fixed α=0.1.
// These track the variance of the signed conviction and the variance
// of its changes, and are themselves bootstrapped with the first
// observation. sample_var uses x² which is non-negative regardless of
// the sign of x, so the zero-sentinel bootstrap pattern still works
// (sample_var_prev == 0 ⇔ no observation seen yet, and a first signed
// observation produces a strictly positive x² that escapes the
// sentinel — assuming the multi-horizon formula returns non-zero on
// the first event, which is the normal case; the degenerate all-zero
// case stays bootstrapped which is correct).
const float prev_ema = conv_signed_ema[b];
const float diff = conviction_signed - prev_ema;
const float diff_var_prev = conv_signed_diff_var_ema[b];
const float new_diff_var = (diff_var_prev == 0.0f)
? (diff * diff) // first-observation bootstrap
: (0.9f * diff_var_prev + 0.1f * diff * diff);
conv_signed_diff_var_ema[b] = new_diff_var;
const float sample_var_prev = conv_signed_sample_var_ema[b];
const float new_sample_var = (sample_var_prev == 0.0f)
? (conviction_signed * conviction_signed) // first-observation bootstrap
: (0.9f * sample_var_prev + 0.1f * conviction_signed * conviction_signed);
conv_signed_sample_var_ema[b] = new_sample_var;
// Wiener α with floor at 0.4 (pearl_wiener_alpha_floor_for_nonstationary).
// Floor is critical here: the signed signal can drift around zero in
// the disagreement-cancellation regime where diff_var → 0 (consecutive
// events near the same near-zero value), which would push α_raw to
// ~0 and lock the EMA at its prev value. Floor 0.4 ensures the EMA
// continues to respond to genuine signal recovery.
const float eps_v = 1e-6f;
const float alpha_raw = new_diff_var / (new_diff_var + new_sample_var + eps_v);
const float alpha_active = (alpha_raw > 0.4f) ? alpha_raw : 0.4f;
// First-observation bootstrap: prev_ema==0 → replace directly per
// pearl_first_observation_bootstrap. Otherwise blend with Wiener-α.
// Note: a SIGNED 0.0 prev_ema is the sentinel (zero-init by host alloc),
// not a real observation. A real observation that happens to evaluate
// to exactly 0.0 (total_abs_weight==0 or perfect cancellation) is
// indistinguishable from the sentinel — but a perfect-cancellation
// event already produces the desired EMA=0 outcome via the bootstrap
// replace branch, so the ambiguity is harmless.
const float new_ema = (prev_ema == 0.0f)
? conviction_signed
: (alpha_active * conviction_signed + (1.0f - alpha_active) * prev_ema);
conv_signed_ema[b] = new_ema;
return new_ema;
}
// CRT.1 C1.2: Compute multi-horizon ISV-weighted conviction (spec §4.4).
// Replaces the scalar `max(|p-0.5|)` approach used in v2 A2 (1d889d2de) +
// A2.1 (fe2498769) which failed Gate 1 catastrophically. Direction comes
// from the per-horizon SIGNED sum, so disagreeing horizons cancel — the
// structural fix the scalar EMA could not provide.
//
// Per-horizon weight = max(net_edge_h, eps_edge) / (var_h + cost²) with
// `eps_edge = cost · 0.01` (spec §4.4: ε = cost / 100). The cost² term
// in the denominator bootstraps variance pre-trade per
// pearl_trade_level_vol_for_stop_distance; net_edge is one-sided positive
// per pearl_audit_unboundedness_for_implicit_asymmetry so losing-edge
// horizons drop out instead of flipping the sign. Normalizing by
// `total_abs_weight` makes the formula scale-invariant per
// pearl_zscore_normalization_for_magnitude_asymmetric_signals.
//
// Returns conviction_signed ∈ [-1, +1]. The caller passes this signed
// scalar directly into update_signed_conviction_ema; sign and magnitude
// both come from the resulting smoothed signed EMA so the magnitude/sign
// mismatch documented in update_signed_conviction_ema's header cannot
// recur.
__device__ static float compute_multi_horizon_conviction(
const float* __restrict__ alpha_probs,
const IsvKellyState* __restrict__ isv,
float cost
) {
const float eps_edge = cost * 0.01f; // spec §4.4 ε floor on net_edge
float weighted_sum_signed = 0.0f;
float total_abs_weight = 0.0f;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p_h = alpha_probs[h];
const float direction_h = (p_h > 0.5f) ? 1.0f : -1.0f;
const float magnitude_h = fabsf(p_h - 0.5f) * 2.0f;
const float net_edge_h = fmaxf(isv[h].pnl_ema_win - isv[h].pnl_ema_loss, eps_edge);
const float weight_h = net_edge_h / (isv[h].realised_return_var + cost * cost);
weighted_sum_signed += magnitude_h * weight_h * direction_h;
total_abs_weight += fabsf(weight_h);
}
return (total_abs_weight > 1e-9f)
? (weighted_sum_signed / total_abs_weight)
: 0.0f;
}
// CRT.diag.2: per-horizon Wiener-α adaptive EMA on RAW alpha probabilities.
// Smooths alpha_probs[h] BEFORE compute_multi_horizon_conviction consumes
// it — testing whether per-event output noise is hiding a slower signal
// (ffr59 found all 5 horizons flip every 2.5 events, win rate flat ~24%,
// mean PnL anti-correlated with conviction; either smoothing recovers
// a signal, or the per-event output is genuinely noisy and the hypothesis
// is wrong).
//
// Pattern mirrors update_signed_conviction_ema (which works on the
// multi-horizon scalar). Per pearl_wiener_optimal_adaptive_alpha +
// pearl_first_observation_bootstrap.
// Floor at 0.1 — stronger smoothing than the 0.4 conviction-level floor,
// testing whether INPUT-side smoothing differs from OUTPUT-side.
//
// Writes alpha_smoothed[h] for h in 0..N_HORIZONS; updates the three per-
// horizon EMA-state slots in place. Single-thread-per-backtest invariant:
// caller must already have gated on threadIdx.x == 0.
__device__ static void compute_alpha_ema_per_horizon(
int b,
const float* __restrict__ alpha_probs,
float* __restrict__ alpha_ema_per_b_per_h,
float* __restrict__ alpha_diff_var_per_b_per_h,
float* __restrict__ alpha_sample_var_per_b_per_h,
float (&alpha_smoothed)[N_HORIZONS]
) {
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p_raw = alpha_probs[h];
const float prev_ema = alpha_ema_per_b_per_h[b * N_HORIZONS + h];
const float diff = p_raw - prev_ema;
const float diff_var_prev = alpha_diff_var_per_b_per_h[b * N_HORIZONS + h];
const float new_diff_var = (diff_var_prev == 0.0f)
? (diff * diff) // first-observation bootstrap
: (0.9f * diff_var_prev + 0.1f * diff * diff);
alpha_diff_var_per_b_per_h[b * N_HORIZONS + h] = new_diff_var;
// sample_var approximates the variance of p around its natural
// no-edge mean (0.5). Centering on 0.5 (instead of the running
// mean) gives a Wiener-α that reads "how far from the no-edge
// anchor the signal sits" rather than "how dispersed it is".
const float sample_var_prev = alpha_sample_var_per_b_per_h[b * N_HORIZONS + h];
const float p_centered = p_raw - 0.5f;
const float new_sample_var = (sample_var_prev == 0.0f)
? (p_centered * p_centered) // first-observation bootstrap
: (0.9f * sample_var_prev + 0.1f * p_centered * p_centered);
alpha_sample_var_per_b_per_h[b * N_HORIZONS + h] = new_sample_var;
// Wiener α with FLOOR 0.1 (stronger smoothing than the 0.4 floor
// on the output-side conviction EMA — testing input-side smoothing
// differs from output-side).
const float eps_v = 1e-6f;
const float alpha_raw = new_diff_var / (new_diff_var + new_sample_var + eps_v);
const float alpha_active = (alpha_raw > 0.1f) ? alpha_raw : 0.1f;
const float new_ema = (prev_ema == 0.0f)
? p_raw // first-observation bootstrap
: (alpha_active * p_raw + (1.0f - alpha_active) * prev_ema);
alpha_ema_per_b_per_h[b * N_HORIZONS + h] = new_ema;
alpha_smoothed[h] = new_ema;
}
}
// CRT.diag.2: variant of compute_multi_horizon_conviction that reads from
// a thread-local alpha_smoothed[h] array instead of the broadcast
// alpha_probs[h] pointer. The §4.4 weight math is identical — only the
// per-horizon p_h source differs.
__device__ static float compute_multi_horizon_conviction_smoothed(
const float (&alpha_smoothed)[N_HORIZONS],
const IsvKellyState* __restrict__ isv,
float cost
) {
const float eps_edge = cost * 0.01f;
float weighted_sum_signed = 0.0f;
float total_abs_weight = 0.0f;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p_h = alpha_smoothed[h];
const float direction_h = (p_h > 0.5f) ? 1.0f : -1.0f;
const float magnitude_h = fabsf(p_h - 0.5f) * 2.0f;
const float net_edge_h = fmaxf(isv[h].pnl_ema_win - isv[h].pnl_ema_loss, eps_edge);
const float weight_h = net_edge_h / (isv[h].realised_return_var + cost * cost);
weighted_sum_signed += magnitude_h * weight_h * direction_h;
total_abs_weight += fabsf(weight_h);
}
return (total_abs_weight > 1e-9f)
? (weighted_sum_signed / total_abs_weight)
: 0.0f;
}
// CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip + mean-run-length
// tracking. Mirrors Group A (which reads RAW alpha_probs[h]) but reads
// alpha_smoothed[h]. Single-writer-per-block; caller must already have
// gated on threadIdx.x == 0. Skips the 5-bucket histogram (Group A keeps
// the histogram; Group E only needs flip count + mean run length for the
// head-to-head comparison).
__device__ static void update_smoothed_flip_counters(
int b,
const float (&alpha_smoothed)[N_HORIZONS],
unsigned int* __restrict__ diag_smoothed_flip_count,
unsigned int* __restrict__ diag_smoothed_current_run_length,
unsigned long long* __restrict__ diag_smoothed_sum_run_length,
signed char* __restrict__ diag_smoothed_prev_dir
) {
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p = alpha_smoothed[h];
const signed char new_dir = (p > 0.5f) ? (signed char)1
: ((p < 0.5f) ? (signed char)-1 : (signed char)0);
const signed char prev_dir = diag_smoothed_prev_dir[b * N_HORIZONS + h];
const unsigned int prev_run = diag_smoothed_current_run_length[b * N_HORIZONS + h];
if (prev_dir != 0 && new_dir != 0 && prev_dir != new_dir) {
diag_smoothed_flip_count[b * N_HORIZONS + h] += 1u;
diag_smoothed_sum_run_length[b * N_HORIZONS + h] += (unsigned long long)prev_run;
diag_smoothed_current_run_length[b * N_HORIZONS + h] = 1u;
} else {
diag_smoothed_current_run_length[b * N_HORIZONS + h] = prev_run + 1u;
}
diag_smoothed_prev_dir[b * N_HORIZONS + h] = new_dir;
}
}
extern "C" __global__ void decision_policy_default(
const float* __restrict__ alpha_probs, // [N_HORIZONS] broadcast
const unsigned char* __restrict__ isv_kelly_base, // [n_backtests * N_HORIZONS * sizeof(IsvKellyState)]
const Pos* __restrict__ positions, // [n_backtests]
const unsigned int* __restrict__ program_lens, // [n_backtests] — non-zero ⇒ skip (program kernel handles it)
int* __restrict__ market_targets, // [n_backtests * 2] — written
unsigned int* __restrict__ open_horizon_masks, // [n_backtests] — written (entry attribution)
// Per-backtest envelope cap (post-fix 2026-05-22: target = conv_signed_ema × max_lots).
const int* __restrict__ max_lots_per_b, // [n_backtests]
// Threshold gate on the smoothed multi-horizon conviction (spec §5.2).
const float* __restrict__ threshold_per_b, // [n_backtests]
// CRT.1 C1.2: cost feeds the §4.4 conviction weight denominator
// (cost² bootstrap per pearl_trade_level_vol_for_stop_distance) AND
// stop_check_isv's SL/trail-distance floor (round-trip cost).
const float* __restrict__ cost_per_lot_per_side_per_b, // [n_backtests]
// Stop-controller state (ISV-driven stop controller spec §3 + §5).
const float* __restrict__ atr_mid_ema_per_b, // [n_backtests]
float* __restrict__ trail_hwm_per_b, // [n_backtests] — read + write
const Book* __restrict__ books_per_b, // [n_backtests * BOOK_LEVELS] (via lob_state.cuh)
// S2.1: mh_kernel_calls diagnostic counter (non-zero-position entries).
unsigned int* __restrict__ mh_kernel_calls_per_b, // [n_backtests]
// Anti-calibration fix (2026-05-22): Wiener-α conviction-EMA state on
// the SIGNED multi-horizon conviction. The buffer name carries `_signed_`
// so call sites can't accidentally re-introduce the magnitude-only
// misuse that produced the higher-conviction-→-more-negative-PnL
// anti-calibration. Same three-slot layout as before (sample / diff /
// value EMAs); the kernel-internal update routine now operates on the
// signed signal end-to-end.
float* __restrict__ conv_signed_ema_per_b, // [n_backtests] — read + write
float* __restrict__ conv_signed_diff_var_ema_per_b, // [n_backtests] — read + write
float* __restrict__ conv_signed_sample_var_ema_per_b, // [n_backtests] — read + write
// CRT.1 C1.4: trajectory state + composite exit_signal circuit-breaker.
// pnl_track populated conviction_at_entry on the open transition;
// this kernel updates the intra-trade fields each event so the composite
// exit check has fresh inputs on the next pass.
unsigned char* __restrict__ open_trade_state_per_b, // [n_backtests * 64]
// CRT.diag Group A: per-horizon signal persistence counters.
// Observe-only; zero behavior impact. Single-writer-per-block
// (caller already gated on threadIdx.x == 0). End-of-run dump only.
unsigned int* __restrict__ diag_flip_count, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_current_run_length, // [n_backtests * N_HORIZONS]
unsigned long long* __restrict__ diag_sum_run_length, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_run_length_hist, // [n_backtests * N_HORIZONS * 5]
signed char* __restrict__ diag_prev_dir_signed, // [n_backtests * N_HORIZONS]
// CRT.diag Group B: conviction-EMA histogram.
unsigned int* __restrict__ diag_conv_hist, // [n_backtests * 10]
// CRT.diag.2: per-horizon alpha-input EMA state. Smooths raw
// alpha_probs[h] BEFORE the §4.4 conviction formula consumes it.
// Three slots mirror the conviction-level EMA pattern.
float* __restrict__ alpha_ema_per_b_per_h, // [n_backtests * N_HORIZONS]
float* __restrict__ alpha_diff_var_per_b_per_h, // [n_backtests * N_HORIZONS]
float* __restrict__ alpha_sample_var_per_b_per_h, // [n_backtests * N_HORIZONS]
// CRT.diag.2 Group E: smoothed-direction-flip counters (mirror Group A
// but reading alpha_smoothed[h] instead of alpha_probs[h]). No histogram.
unsigned int* __restrict__ diag_smoothed_flip_count, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_smoothed_current_run_length, // [n_backtests * N_HORIZONS]
unsigned long long* __restrict__ diag_smoothed_sum_run_length, // [n_backtests * N_HORIZONS]
signed char* __restrict__ diag_smoothed_prev_dir, // [n_backtests * N_HORIZONS]
int n_backtests
) {
int b = blockIdx.x;
if (b >= n_backtests || threadIdx.x != 0) return;
if (program_lens[b] != 0u) return; // backtest b uses a custom bytecode program; skip default
// Stop-check (spec §3) — pre-VM. Returns 1 if force-flat was written.
if (stop_check_isv(b, positions, isv_kelly_base, open_horizon_masks,
atr_mid_ema_per_b, trail_hwm_per_b, books_per_b,
cost_per_lot_per_side_per_b,
market_targets,
mh_kernel_calls_per_b)) {
return;
}
// CRT.diag.2: smooth RAW alpha_probs[h] per-horizon BEFORE the §4.4
// conviction formula consumes it. Wiener-α EMA with floor 0.1 (stronger
// than the 0.4 output-side floor). The alpha_smoothed[] array stays in
// registers — fed into both compute_multi_horizon_conviction_smoothed
// and the Group E flip counter. RAW alpha_probs[] is still passed
// through to Group A so the head-to-head comparison is apples-to-apples.
float alpha_smoothed[N_HORIZONS];
compute_alpha_ema_per_horizon(
b, alpha_probs,
alpha_ema_per_b_per_h,
alpha_diff_var_per_b_per_h,
alpha_sample_var_per_b_per_h,
alpha_smoothed);
// CRT.1 C1.2: multi-horizon ISV-weighted conviction (spec §4.4).
// Replaces v2 A2 (1d889d2de) + A2.1 (fe2498769) scalar approach that
// failed Gate 1 catastrophically on smokes vjmwc + lkrdf (155k trades,
// 9100% drawdown, Sharpe -15.7). Direction now comes from the
// per-horizon SIGNED sum so disagreeing horizons cancel — the
// structural fix the scalar EMA could not provide.
//
// CRT.diag.2: the §4.4 formula now reads alpha_smoothed[] (per-horizon
// EMA) instead of alpha_probs[]. Hypothesis test: does input smoothing
// recover signal hidden under per-event noise?
const IsvKellyState* isv = reinterpret_cast<const IsvKellyState*>(
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
);
const float cost = cost_per_lot_per_side_per_b[b];
const float conviction_signed = compute_multi_horizon_conviction_smoothed(
alpha_smoothed, isv, cost);
// Anti-calibration fix (2026-05-22): Wiener-α adaptive EMA on the
// SIGNED conviction so disagreement-driven sign flips cancel in the
// EMA → |EMA| → 0 → no trade. Magnitude AND direction both come from
// the same smoothed signal — eliminates the previous magnitude/sign
// mismatch where size stayed large while sign flipped on noise.
//
// Runs ahead of the threshold gate so the EMA tracks all events
// (gating off an unsmoothed tick would let the EMA grow stale on long
// gated stretches).
const float conv_signed_ema = update_signed_conviction_ema(
b, conviction_signed,
conv_signed_ema_per_b,
conv_signed_diff_var_ema_per_b,
conv_signed_sample_var_ema_per_b);
const float conv_ema_mag = fabsf(conv_signed_ema);
const float conviction_dir = (conv_signed_ema >= 0.0f) ? 1.0f : -1.0f;
// CRT.diag Group A: per-horizon direction-flip + run-length tracking.
// Observe-only. Single-writer-per-block (already gated on thread 0).
// Reads RAW alpha_probs[h] — head-to-head comparison with Group E
// (which reads alpha_smoothed[h]). Apples-to-apples vs ffr59 baseline.
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p = alpha_probs[h];
const signed char new_dir = (p > 0.5f) ? (signed char)1
: ((p < 0.5f) ? (signed char)-1 : (signed char)0);
const signed char prev_dir = diag_prev_dir_signed[b * N_HORIZONS + h];
const unsigned int prev_run = diag_current_run_length[b * N_HORIZONS + h];
if (prev_dir != 0 && new_dir != 0 && prev_dir != new_dir) {
// Direction flipped — record the prev run length.
diag_flip_count[b * N_HORIZONS + h] += 1u;
diag_sum_run_length[b * N_HORIZONS + h] += (unsigned long long)prev_run;
int bk;
if (prev_run < 10u) bk = 0;
else if (prev_run < 100u) bk = 1;
else if (prev_run < 1000u) bk = 2;
else if (prev_run < 10000u) bk = 3;
else bk = 4;
diag_run_length_hist[(b * N_HORIZONS + h) * 5 + bk] += 1u;
diag_current_run_length[b * N_HORIZONS + h] = 1u; // start new run
} else {
diag_current_run_length[b * N_HORIZONS + h] = prev_run + 1u;
}
diag_prev_dir_signed[b * N_HORIZONS + h] = new_dir;
}
// CRT.diag.2 Group E: per-horizon SMOOTHED direction-flip + mean-run-length.
// Mirrors Group A reading alpha_smoothed[] instead of alpha_probs[].
update_smoothed_flip_counters(
b, alpha_smoothed,
diag_smoothed_flip_count,
diag_smoothed_current_run_length,
diag_smoothed_sum_run_length,
diag_smoothed_prev_dir);
// CRT.diag Group B: smoothed-conviction MAGNITUDE histogram (10 buckets
// over [0, 1)). Magnitude (not signed) because the histogram tracks
// "strength of opinion" not directional bias — symmetric +0.7 vs 0.7
// both belong in the same bucket.
{
float c = conv_ema_mag;
if (c < 0.0f) c = 0.0f;
if (c >= 1.0f) c = 0.999999f;
int bk = (int)(c * 10.0f);
if (bk < 0) bk = 0;
if (bk > 9) bk = 9;
diag_conv_hist[b * 10 + bk] += 1u;
}
// CRT.1 C1.4: refresh intra-trade trajectory state (offsets 24, 44, 48,
// 56) for the composite exit_signal. Skipped internally when flat.
// NOTE: keeps RAW alpha_probs for the short-vs-long disagreement
// counter (offset 56) — composite exit's disagreement signal is meant
// to react fast (32-event window), opposite of the smoothing rationale.
// Passes the MAGNITUDE of the smoothed signed EMA — composite_exit_check
// forms degradation ratios that only make sense between two magnitudes.
update_open_trade_trajectory(b, positions, books_per_b, alpha_probs,
conv_ema_mag, open_trade_state_per_b);
// CRT.1 C1.4: composite exit_signal safety circuit-breaker (spec §4.3).
// Catches the edge case where conviction stays bounded above zero but
// the trade is deep in unrealized loss — the primary `target → 0 from
// collapsed conviction` path (C1.2) cannot reach.
if (composite_exit_check(b, positions, open_trade_state_per_b, market_targets)) {
return;
}
// Threshold gate on the smoothed multi-horizon conviction MAGNITUDE.
// Per spec §5.2: gating on the smoothed signal rather than the raw
// signal makes the pre-registered percentile thresholds robust to
// event-rate jitter. Magnitude (not signed) because the pre-registered
// thresholds are |conviction| percentiles — sign-agnostic by design.
if (conv_ema_mag < threshold_per_b[b]) {
market_targets[b * 2 + 0] = 2;
market_targets[b * 2 + 1] = 0;
open_horizon_masks[b] = 0u;
return;
}
// Build per-horizon attribution mask: every horizon whose signed
// contribution agrees with the multi-horizon direction gets credit.
// This replaces the v2 sharpe-weight attribution because the v3
// weights come from the §4.4 formula directly. CRT.diag.2: reads
// alpha_smoothed[] so attribution matches the smoothed conviction the
// size formula used.
unsigned int attribution_mask = 0u;
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float dir_h = (alpha_smoothed[h] > 0.5f) ? 1.0f : -1.0f;
if (dir_h == conviction_dir) attribution_mask |= (1u << h);
}
// Anti-calibration fix (2026-05-22): target = SIGNED smoothed conviction
// × envelope_max. Both sign AND magnitude come from `conv_signed_ema`
// — the SAME smoothed scalar. When per-horizon directions disagree
// event-to-event, conv_signed_ema → 0 collapses size to zero naturally,
// unlike the prior `sign(instantaneous) × EMA(|instantaneous|)` which
// multiplied a smoothed magnitude by an event-rate sign flip.
const int max_lots = max_lots_per_b[b];
const float envelope_max = (float)max_lots;
const float target_lots_f = conv_signed_ema * envelope_max;
const int target_lots = (int)truncf(
target_lots_f + (target_lots_f >= 0.0f ? 0.5f : -0.5f));
if (target_lots == 0) {
// No new lots ordered. Use force-flat encoding if currently open
// so the harness reduces toward zero; use no-op when already flat.
const int code = (positions[b].position_lots == 0) ? 2 : 3;
market_targets[b * 2 + 0] = code;
market_targets[b * 2 + 1] = 0;
return;
}
// If currently flat, this is an opening trade: record attribution mask
// so pnl_track + isv_kelly_update can credit the right horizons on close.
if (positions[b].position_lots == 0) {
open_horizon_masks[b] = attribution_mask;
}
const int side = (target_lots > 0) ? 0 : 1;
const int abs_sz = (target_lots > 0) ? target_lots : -target_lots;
market_targets[b * 2 + 0] = side;
market_targets[b * 2 + 1] = abs_sz;
}
// Bytecode VM — interprets the flat Program emitted by Strategy::flatten()
// (Rust src/policy/mod.rs) against current alpha probs + per-horizon
// IsvKellyState. Replaces the hardcoded default policy when the host
// has uploaded a non-empty program for backtest b.
//
// Stack layout: parallel float-value + u32-attribution-mask stacks.
// Each EmitPerHorizonSize pushes its (signed_size, mask=(1<<h)). The
// aggregators pop n values + n masks, push the aggregated value with
// OR'd mask. The terminal WriteOrder pops one value+mask, converts to
// {side, abs_size} in market_targets[b] and records the attribution
// mask in open_horizon_masks[b] for entry-time crediting.
//
// Layout MUST stay in sync with src/policy/mod.rs::{OpCode, Instruction}.
#define OP_NOOP 0
#define OP_EVAL_REGIME 1
#define OP_EMIT_PER_HORIZON_SIZE 2
#define OP_AGG_WEIGHTED_SHARPE 3
#define OP_AGG_MEAN 4
#define OP_AGG_MAX_CONFIDENCE 5
#define OP_APPLY_CONFLICT 6
#define OP_WRITE_ORDER 7
#define OP_PUSH_SCALAR 8
#define OP_BRANCH_IF_REGIME 9
#define STACK_CAP 32
struct Instruction {
unsigned char op;
unsigned char arg0;
unsigned short arg1;
unsigned int arg2;
};
extern "C" __global__ void decision_policy_program(
const float* __restrict__ alpha_probs,
const unsigned char* __restrict__ isv_kelly_base,
const Pos* __restrict__ positions,
const Instruction* __restrict__ programs, // [n_backtests * max_instructions]
const unsigned int* __restrict__ program_lens, // [n_backtests]
const unsigned int* __restrict__ regimes, // [n_backtests] — current regime id
int* __restrict__ market_targets, // [n_backtests * 2]
unsigned int* __restrict__ open_horizon_masks,
// P1: per-backtest sim parameter arrays. See decision_policy_default header for contract.
const float* __restrict__ target_annual_vol_units_per_b,
const float* __restrict__ annualisation_factor_per_b,
int max_instructions,
const int* __restrict__ max_lots_per_b,
const float* __restrict__ kelly_frac_floor_per_b,
const float* __restrict__ sharpe_weight_floor_per_b,
// P4: threshold gate (same as decision_policy_default).
const float* __restrict__ threshold_per_b,
const float* __restrict__ cost_per_lot_per_side_per_b, // [n_backtests] — round-trip cost floor for SL/trail
// Stop-controller state (ISV-driven stop controller spec §3 + §5).
const float* __restrict__ atr_mid_ema_per_b, // [n_backtests]
float* __restrict__ trail_hwm_per_b, // [n_backtests] — read + write
const Book* __restrict__ books_per_b, // [n_backtests * BOOK_LEVELS]
// S2.1: mh_kernel_calls diagnostic counter (non-zero-position entries).
unsigned int* __restrict__ mh_kernel_calls_per_b, // [n_backtests]
// Anti-calibration fix (2026-05-22): SIGNED conviction-EMA state.
// Mirrors decision_policy_default — see that kernel for the contract.
float* __restrict__ conv_signed_ema_per_b, // [n_backtests] — read + write
float* __restrict__ conv_signed_diff_var_ema_per_b, // [n_backtests] — read + write
float* __restrict__ conv_signed_sample_var_ema_per_b, // [n_backtests] — read + write
// CRT.1 C1.4: trajectory state + composite exit_signal circuit-breaker.
// Mirrors decision_policy_default — see that kernel for the contract.
unsigned char* __restrict__ open_trade_state_per_b, // [n_backtests * 64]
// CRT.diag Group A: per-horizon signal persistence counters (observe-only).
unsigned int* __restrict__ diag_flip_count, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_current_run_length, // [n_backtests * N_HORIZONS]
unsigned long long* __restrict__ diag_sum_run_length, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_run_length_hist, // [n_backtests * N_HORIZONS * 5]
signed char* __restrict__ diag_prev_dir_signed, // [n_backtests * N_HORIZONS]
// CRT.diag Group B: conviction-EMA histogram.
unsigned int* __restrict__ diag_conv_hist, // [n_backtests * 10]
// CRT.diag.2: per-horizon alpha-input EMA state.
float* __restrict__ alpha_ema_per_b_per_h, // [n_backtests * N_HORIZONS]
float* __restrict__ alpha_diff_var_per_b_per_h, // [n_backtests * N_HORIZONS]
float* __restrict__ alpha_sample_var_per_b_per_h, // [n_backtests * N_HORIZONS]
// CRT.diag.2 Group E: smoothed-direction-flip counters.
unsigned int* __restrict__ diag_smoothed_flip_count, // [n_backtests * N_HORIZONS]
unsigned int* __restrict__ diag_smoothed_current_run_length, // [n_backtests * N_HORIZONS]
unsigned long long* __restrict__ diag_smoothed_sum_run_length, // [n_backtests * N_HORIZONS]
signed char* __restrict__ diag_smoothed_prev_dir, // [n_backtests * N_HORIZONS]
int n_backtests
) {
int b = blockIdx.x;
if (b >= n_backtests || threadIdx.x != 0) return;
const unsigned int plen = program_lens[b];
if (plen == 0u) {
// Skip — this backtest uses the hardcoded default kernel separately.
return;
}
// Stop-check (spec §3).
if (stop_check_isv(b, positions, isv_kelly_base, open_horizon_masks,
atr_mid_ema_per_b, trail_hwm_per_b, books_per_b,
cost_per_lot_per_side_per_b,
market_targets,
mh_kernel_calls_per_b)) {
return;
}
// CRT.diag.2: smooth RAW alpha_probs[h] per-horizon BEFORE the §4.4
// conviction formula consumes it. Mirrors decision_policy_default.
float alpha_smoothed[N_HORIZONS];
compute_alpha_ema_per_horizon(
b, alpha_probs,
alpha_ema_per_b_per_h,
alpha_diff_var_per_b_per_h,
alpha_sample_var_per_b_per_h,
alpha_smoothed);
// CRT.1 C1.2: multi-horizon ISV-weighted conviction (spec §4.4).
// Mirrors decision_policy_default — see that kernel for the full
// rationale. The VM's per-horizon emit + aggregate opcodes still
// execute, but the terminal OP_WRITE_ORDER ignores the stack's
// signed `final_size` and uses the §4.4 conviction-driven target
// instead. The attribution mask from the stack IS still consumed
// at OP_WRITE_ORDER so the VM continues to credit horizons on entry.
//
// CRT.diag.2: feeds the §4.4 formula alpha_smoothed[] instead of
// alpha_probs[].
const Instruction* prog = programs + (size_t)b * max_instructions;
const IsvKellyState* isv = reinterpret_cast<const IsvKellyState*>(
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
);
const unsigned int my_regime = regimes[b];
const float cost = cost_per_lot_per_side_per_b[b];
const float conviction_signed = compute_multi_horizon_conviction_smoothed(
alpha_smoothed, isv, cost);
// Anti-calibration fix (2026-05-22): SIGNED conviction EMA. Sign and
// magnitude both come from the same smoothed scalar — directions that
// disagree event-to-event cancel in the EMA → size collapses to zero
// naturally. See update_signed_conviction_ema header for the bug this
// resolves.
const float conv_signed_ema = update_signed_conviction_ema(
b, conviction_signed,
conv_signed_ema_per_b,
conv_signed_diff_var_ema_per_b,
conv_signed_sample_var_ema_per_b);
const float conv_ema_mag = fabsf(conv_signed_ema);
const float conviction_dir = (conv_signed_ema >= 0.0f) ? 1.0f : -1.0f;
// CRT.diag Group A: per-horizon direction-flip + run-length tracking.
// Mirrors decision_policy_default. Observe-only; single-writer-per-block.
// Reads RAW alpha_probs[h] — head-to-head comparison with Group E.
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
const float p = alpha_probs[h];
const signed char new_dir = (p > 0.5f) ? (signed char)1
: ((p < 0.5f) ? (signed char)-1 : (signed char)0);
const signed char prev_dir = diag_prev_dir_signed[b * N_HORIZONS + h];
const unsigned int prev_run = diag_current_run_length[b * N_HORIZONS + h];
if (prev_dir != 0 && new_dir != 0 && prev_dir != new_dir) {
diag_flip_count[b * N_HORIZONS + h] += 1u;
diag_sum_run_length[b * N_HORIZONS + h] += (unsigned long long)prev_run;
int bk;
if (prev_run < 10u) bk = 0;
else if (prev_run < 100u) bk = 1;
else if (prev_run < 1000u) bk = 2;
else if (prev_run < 10000u) bk = 3;
else bk = 4;
diag_run_length_hist[(b * N_HORIZONS + h) * 5 + bk] += 1u;
diag_current_run_length[b * N_HORIZONS + h] = 1u;
} else {
diag_current_run_length[b * N_HORIZONS + h] = prev_run + 1u;
}
diag_prev_dir_signed[b * N_HORIZONS + h] = new_dir;
}
// CRT.diag.2 Group E: smoothed-direction-flip + mean-run-length.
update_smoothed_flip_counters(
b, alpha_smoothed,
diag_smoothed_flip_count,
diag_smoothed_current_run_length,
diag_smoothed_sum_run_length,
diag_smoothed_prev_dir);
// CRT.diag Group B: smoothed-conviction MAGNITUDE histogram.
{
float c = conv_ema_mag;
if (c < 0.0f) c = 0.0f;
if (c >= 1.0f) c = 0.999999f;
int bk = (int)(c * 10.0f);
if (bk < 0) bk = 0;
if (bk > 9) bk = 9;
diag_conv_hist[b * 10 + bk] += 1u;
}
// CRT.1 C1.4: refresh intra-trade trajectory state for composite exit.
// RAW alpha_probs kept here — disagreement signal is meant to be fast.
// Passes the magnitude of the signed EMA — composite_exit_check forms
// degradation ratios that only make sense between magnitudes.
update_open_trade_trajectory(b, positions, books_per_b, alpha_probs,
conv_ema_mag, open_trade_state_per_b);
// CRT.1 C1.4: composite exit_signal safety circuit-breaker (spec §4.3).
if (composite_exit_check(b, positions, open_trade_state_per_b, market_targets)) {
return;
}
// Threshold gate on the smoothed multi-horizon conviction MAGNITUDE
// (spec §5.2). Pre-registered percentile thresholds are sign-agnostic.
if (conv_ema_mag < threshold_per_b[b]) {
market_targets[b * 2 + 0] = 2;
market_targets[b * 2 + 1] = 0;
open_horizon_masks[b] = 0u;
return;
}
// Per-backtest scalar reads for VM opcodes (Kelly/Sharpe aggregations).
const float target_annual_vol_units = target_annual_vol_units_per_b[b];
const float annualisation_factor = annualisation_factor_per_b[b];
const int max_lots = max_lots_per_b[b];
const float kelly_frac_floor = kelly_frac_floor_per_b[b];
const float sharpe_weight_floor = sharpe_weight_floor_per_b[b];
float stack_val[STACK_CAP];
unsigned int stack_mask[STACK_CAP];
int sp = 0;
unsigned int pc = 0;
while (pc < plen && pc < (unsigned int)max_instructions) {
const Instruction ins = prog[pc];
pc += 1;
switch (ins.op) {
case OP_NOOP: break;
case OP_PUSH_SCALAR: {
if (sp >= STACK_CAP) break;
const float v = __int_as_float((int)ins.arg2);
stack_val[sp] = v;
stack_mask[sp] = 0u;
sp += 1;
break;
}
case OP_EVAL_REGIME: {
if (sp >= STACK_CAP) break;
stack_val[sp] = (my_regime == (unsigned int)ins.arg0) ? 1.0f : 0.0f;
stack_mask[sp] = 0u;
sp += 1;
break;
}
case OP_BRANCH_IF_REGIME: {
if (my_regime != (unsigned int)ins.arg0) {
pc = ins.arg2;
}
break;
}
case OP_EMIT_PER_HORIZON_SIZE: {
if (sp >= STACK_CAP) break;
const int h = (int)ins.arg0;
const int leaf_max_lots = (int)ins.arg1;
if (h < 0 || h >= N_HORIZONS) { stack_val[sp]=0.0f; stack_mask[sp]=0u; sp+=1; break; }
const float p_h = alpha_probs[h];
const IsvKellyState& s = isv[h];
const float sig_mag = fabsf(p_h - 0.5f) * 2.0f;
const float dir = (p_h > 0.5f) ? 1.0f : -1.0f;
float kelly_frac;
if (s.pnl_ema_win > 1e-9f) {
float kf = (s.win_rate_ema * s.pnl_ema_win
- (1.0f - s.win_rate_ema) * s.pnl_ema_loss)
/ s.pnl_ema_win;
kf = fmaxf(0.0f, fminf(1.0f, kf));
kelly_frac = fmaxf(kelly_frac_floor, kf);
} else {
kelly_frac = kelly_frac_floor;
}
const float effective_cap = fminf((float)leaf_max_lots, (float)max_lots);
// Same MIN_TRADES_FOR_VAR_CAP gate as decision_policy_default.
float cap_lots;
if (s.n_trades_seen >= MIN_TRADES_FOR_VAR_CAP
&& s.realised_return_var > 1e-9f)
{
const float cap_units = target_annual_vol_units
/ sqrtf(s.realised_return_var * annualisation_factor);
cap_lots = fminf(cap_units, effective_cap);
} else {
cap_lots = effective_cap;
}
const float ss = dir * sig_mag * kelly_frac * cap_lots;
stack_val[sp] = ss;
stack_mask[sp] = 1u << h;
sp += 1;
break;
}
case OP_AGG_MEAN: {
const int n = (int)ins.arg0;
if (n <= 0 || sp < n) break;
float sum = 0.0f;
unsigned int mask_or = 0u;
for (int i = sp - n; i < sp; ++i) {
sum += stack_val[i];
mask_or |= stack_mask[i];
}
sp -= n;
stack_val[sp] = sum / (float)n;
stack_mask[sp] = mask_or;
sp += 1;
break;
}
case OP_AGG_WEIGHTED_SHARPE: {
const int n = (int)ins.arg0;
if (n <= 0 || sp < n) break;
float w_sum = 0.0f, wx_sum = 0.0f;
unsigned int mask_or = 0u;
for (int i = sp - n; i < sp; ++i) {
const unsigned int m = stack_mask[i];
// Look up recent_sharpe for the single horizon this slot
// attributes to. If the mask has multiple bits set (a
// nested aggregator already merged horizons) we fall back
// to uniform weight 1.0 because per-horizon attribution
// collapsed.
float w;
if (m != 0u && (m & (m - 1u)) == 0u) {
// Single-bit mask — recover horizon index.
int h = 0;
unsigned int mm = m;
while ((mm & 1u) == 0u && h < N_HORIZONS) { mm >>= 1; h += 1; }
// Floor lets cold-start aggregate; matches the
// decision_policy_default weight pattern.
w = fmaxf(sharpe_weight_floor, isv[h].recent_sharpe);
} else {
w = 1.0f;
}
w_sum += w;
wx_sum += w * stack_val[i];
mask_or |= m;
}
sp -= n;
stack_val[sp] = (w_sum > 1e-9f) ? (wx_sum / w_sum) : 0.0f;
stack_mask[sp] = mask_or;
sp += 1;
break;
}
case OP_AGG_MAX_CONFIDENCE: {
const int n = (int)ins.arg0;
if (n <= 0 || sp < n) break;
int best = sp - n;
for (int i = sp - n + 1; i < sp; ++i) {
if (fabsf(stack_val[i]) > fabsf(stack_val[best])) best = i;
}
const float bv = stack_val[best];
const unsigned int bm = stack_mask[best];
sp -= n;
stack_val[sp] = bv;
stack_mask[sp] = bm;
sp += 1;
break;
}
case OP_APPLY_CONFLICT: {
// v1: no-op (mean of children already aggregated; conflict
// resolution only matters when multiple cells share state,
// which v1 doesn't). Preserved for v2 Portfolio mode.
break;
}
case OP_WRITE_ORDER: {
if (sp <= 0) break;
sp -= 1;
// Pop the VM stack's aggregated value (unused under v3 §4.4
// sizing) and keep the attribution mask for entry crediting.
const unsigned int attribution = stack_mask[sp];
// Anti-calibration fix (2026-05-22): target = SIGNED smoothed
// conviction × envelope_max. Sign AND magnitude both come from
// conv_signed_ema — eliminates the prior magnitude/sign mismatch
// where size stayed large while direction flipped on noise.
const float envelope_max = (float)max_lots;
const float target_lots_f = conv_signed_ema * envelope_max;
const int target_lots = (int)truncf(
target_lots_f + (target_lots_f >= 0.0f ? 0.5f : -0.5f));
if (target_lots == 0) {
const int code = (positions[b].position_lots == 0) ? 2 : 3;
market_targets[b * 2 + 0] = code;
market_targets[b * 2 + 1] = 0;
} else {
if (positions[b].position_lots == 0) {
open_horizon_masks[b] = attribution;
}
const int side = (target_lots > 0) ? 0 : 1;
const int abs_sz = (target_lots > 0) ? target_lots : -target_lots;
market_targets[b * 2 + 0] = side;
market_targets[b * 2 + 1] = abs_sz;
}
break;
}
default: break;
}
}
}
// Updates per-horizon IsvKellyState[h] for every horizon flagged in
// `closed_horizon_mask[b]`. Called by the host after pnl_track_step
// detects close transitions. `realised_return[b]` is the closed-trade
// per-lot return in price units (positive = win, negative = loss).
//
// Wiener-α floor 0.4 per pearl_wiener_alpha_floor_for_nonstationary.md.
// Welford variance updates a running estimate around mean 0 (simplified;
// v2 may switch to ema-centered).
//
// recent_sharpe composite re-computed from the freshly updated EMAs.
extern "C" __global__ void isv_kelly_update_on_close(
unsigned char* isv_kelly_base,
const unsigned int* closed_horizon_mask, // [n_backtests]
const float* realised_return, // [n_backtests]
int n_backtests
) {
int b = blockIdx.x;
if (b >= n_backtests || threadIdx.x != 0) return;
const unsigned int mask = closed_horizon_mask[b];
if (mask == 0u) return;
const float ret = realised_return[b];
IsvKellyState* isv = reinterpret_cast<IsvKellyState*>(
isv_kelly_base + (size_t)b * N_HORIZONS * sizeof(IsvKellyState)
);
const float alpha = 0.4f; // Wiener-α floor (non-stationary policy P&L)
#pragma unroll
for (int h = 0; h < N_HORIZONS; ++h) {
if (!(mask & (1u << h))) continue;
IsvKellyState& s = isv[h];
const bool win = ret > 0.0f;
if (s.n_trades_seen == 0u) {
// First-observation bootstrap: replace EMAs directly (no zero-bias warmup).
s.pnl_ema_win = win ? ret : 0.0f;
s.pnl_ema_loss = win ? 0.0f : -ret;
s.win_rate_ema = win ? 1.0f : 0.0f;
s.realised_return_var = ret * ret; // single-sample variance proxy
} else {
if (win) {
s.pnl_ema_win = (1.0f - alpha) * s.pnl_ema_win + alpha * ret;
s.win_rate_ema = (1.0f - alpha) * s.win_rate_ema + alpha * 1.0f;
} else {
s.pnl_ema_loss = (1.0f - alpha) * s.pnl_ema_loss + alpha * (-ret);
s.win_rate_ema = (1.0f - alpha) * s.win_rate_ema + alpha * 0.0f;
}
// Welford-ish running variance around 0.
const float prev_n = (float)s.n_trades_seen;
s.realised_return_var =
(prev_n * s.realised_return_var + ret * ret) / (prev_n + 1.0f);
}
s.n_trades_seen += 1u;
// Composite recent_sharpe = (mean_winning_contribution mean_losing_contribution)
// / sqrt(realised_return_var)
const float numerator = s.pnl_ema_win * s.win_rate_ema
- s.pnl_ema_loss * (1.0f - s.win_rate_ema);
const float denom = sqrtf(fmaxf(s.realised_return_var, 1e-9f));
s.recent_sharpe = numerator / denom;
}
}