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>
273 lines
15 KiB
Plaintext
273 lines
15 KiB
Plaintext
// pnl_track.cu — segment_complete detector + TradeRecord emission.
|
||
//
|
||
// Runs after every matching pass. Compares previous position state
|
||
// (persisted in OpenTradeState scratch) against current Pos.position_lots
|
||
// to detect transitions:
|
||
// prev == 0, now != 0 → entry: record entry context into scratch
|
||
// prev != 0, now == 0 → close: emit a TradeRecord with the spread
|
||
// between entry and exit P&L delta
|
||
//
|
||
// Single-writer (thread 0) per block.
|
||
//
|
||
// OpenTradeState layout (24 bytes per backtest):
|
||
// 0..8 entry_ts_ns (u64)
|
||
// 8..12 entry_px_x100 (i32 — entry VWAP × 100, fixed-point)
|
||
// 12..16 entry_size (i32 — signed, +long -short)
|
||
// 16..20 entry_realized_at_open (f32 — pos.realized_pnl when entry happened)
|
||
// 20..21 horizon_idx (u8)
|
||
// 21..24 padding
|
||
//
|
||
// TradeRecord layout (40 bytes — MUST match src/order.rs::TradeRecord):
|
||
// 0..8 entry_ts_ns
|
||
// 8..16 exit_ts_ns
|
||
// 16..20 entry_px_ticks (×100)
|
||
// 20..24 exit_px_ticks (×100)
|
||
// 24..28 size_lots (signed)
|
||
// 28..32 fees_usd_fp (placeholder, zero in v1)
|
||
// 32..36 realised_pnl_usd_fp (×100; price-units × $50 × 100 = ×5000)
|
||
// 36..37 horizon_idx
|
||
// 37..38 strategy_id
|
||
// 38..40 padding
|
||
|
||
#include "lob_state.cuh"
|
||
|
||
// CRT.1 C1.1 (spec §7, v3 architecture reset): 24 → 64 bytes for
|
||
// multi-horizon conviction trajectory tracking. Rust-side mirror at
|
||
// crates/ml-backtesting/src/lob.rs OPEN_TRADE_STATE_BYTES.
|
||
// Offset Size Field
|
||
// 0 8 entry_ts_ns (u64)
|
||
// 8 4 entry_px_x100 (i32)
|
||
// 12 4 entry_size (i32)
|
||
// 16 4 realized_at_open (f32)
|
||
// 20 4 conviction_at_entry (f32, magnitude — pnl_track
|
||
// takes fabsf of the SIGNED
|
||
// EMA so degradation ratios
|
||
// in composite_exit_check
|
||
// compare magnitudes)
|
||
// 24 4 conviction_ema (f32, magnitude — same)
|
||
// 28 16 conviction_per_horizon_ema [4 × f32] ← NEW in CRT.1
|
||
// 44 4 pnl_adjusted_conviction_ema (f32) ← NEW in CRT.1
|
||
// 48 4 peak_unrealized_pnl (f32) ← NEW in CRT.1
|
||
// 52 4 degradation_consecutive_events (u32) ← NEW in CRT.1
|
||
// 56 4 disagreement_consecutive_events (u32) ← NEW in CRT.1
|
||
// 60 1 horizon_idx_dominant_at_entry (u8) ← NEW in CRT.1
|
||
// 61 3 pad
|
||
#define OPEN_TRADE_STATE_BYTES 64
|
||
#define TRADE_RECORD_BYTES 40
|
||
|
||
extern "C" __global__ void pnl_track_step(
|
||
unsigned char* pos_base, // [n_backtests * 24 bytes]
|
||
unsigned char* open_trade_state, // [n_backtests * 64 bytes]
|
||
unsigned char* trade_log_base, // [n_backtests * cap * 40 bytes]
|
||
unsigned int* trade_log_head, // [n_backtests]
|
||
unsigned long long current_ts_ns,
|
||
int trade_log_cap,
|
||
int n_backtests,
|
||
float* trail_hwm, // [n_backtests] — reset to 0 on close
|
||
// S1.20: NaN instrumentation counters (per-backtest).
|
||
unsigned int* __restrict__ zero_vwap_at_open, // open branch: vwap_entry == 0
|
||
unsigned int* __restrict__ saturated_vwap_at_open, // open branch: |vwap_entry| > 21M or NaN/Inf
|
||
unsigned int* __restrict__ defensive_exit_clamp, // close branch: defensive clamp fired
|
||
// Post-fix 2026-05-22: current SIGNED smoothed multi-horizon conviction
|
||
// (from decision_policy kernels). Read on open transition to snapshot
|
||
// conviction_at_entry + initialize the in-trade conviction_ema mirror
|
||
// in open_trade_state. The kernel takes fabsf() before writing because
|
||
// composite_exit_check forms a degradation ratio (current/entry) that
|
||
// only makes sense as a ratio of magnitudes — a signed flip mid-trade
|
||
// would otherwise invert the ratio meaning.
|
||
const float* __restrict__ conv_signed_ema_per_b, // [n_backtests]
|
||
// CRT.diag Group C: hold-time histogram at trade close (6 log buckets).
|
||
unsigned int* __restrict__ diag_hold_hist, // [n_backtests * 6]
|
||
// CRT.diag Group D: outcome by entry-conviction (10 conviction buckets).
|
||
unsigned int* __restrict__ diag_outcome_n, // [n_backtests * 10]
|
||
float* __restrict__ diag_outcome_sum_pnl, // [n_backtests * 10] — segment_realized (price-units × lots)
|
||
unsigned int* __restrict__ diag_outcome_n_wins // [n_backtests * 10]
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
|
||
Pos* pos = reinterpret_cast<Pos*>(pos_base + (size_t)b * sizeof(Pos));
|
||
unsigned char* st = open_trade_state + (size_t)b * OPEN_TRADE_STATE_BYTES;
|
||
int prev_size = *reinterpret_cast<int*>(st + 12);
|
||
int now_size = pos->position_lots;
|
||
|
||
if (prev_size == 0 && now_size != 0) {
|
||
// Open entry — snapshot context.
|
||
// S1.20: instrument vwap_entry at open to catch zero/saturated sentinels.
|
||
const float vwap_at_open = pos->vwap_entry;
|
||
if (vwap_at_open == 0.0f) {
|
||
zero_vwap_at_open[b] += 1u;
|
||
} else if (!isfinite(vwap_at_open) || fabsf(vwap_at_open) > 21000000.0f) {
|
||
saturated_vwap_at_open[b] += 1u;
|
||
}
|
||
*reinterpret_cast<unsigned long long*>(st + 0) = current_ts_ns;
|
||
*reinterpret_cast<int*>(st + 8) = (int)(vwap_at_open * 100.0f + 0.5f);
|
||
*reinterpret_cast<int*>(st + 12) = now_size;
|
||
*reinterpret_cast<float*>(st + 16) = pos->realized_pnl;
|
||
// CRT.1 C1.4: snapshot conviction_at_entry + seed the intra-trade
|
||
// EMA mirrors that the composite exit_signal reads (spec §4.3).
|
||
// Only the four offsets actually consumed by stop_check_isv's
|
||
// composite check are written here — per feedback_no_hiding,
|
||
// don't populate fields nothing reads. Other slots (per-horizon
|
||
// EMA at 28–43, degradation counter at 52, horizon_idx at 60)
|
||
// remain zero from the close-branch reset / alloc_zeros init.
|
||
//
|
||
// Post-fix 2026-05-22: the live buffer holds a SIGNED EMA (anti-
|
||
// calibration fix in decision_policy.cu); composite_exit_check
|
||
// reads offsets 20 and 24 as magnitudes (current/entry ratio is
|
||
// only meaningful magnitude-to-magnitude), so we take fabsf here.
|
||
const float conv_now = fabsf(conv_signed_ema_per_b[b]);
|
||
*reinterpret_cast<float*>(st + 20) = conv_now; // conviction_at_entry (magnitude)
|
||
*reinterpret_cast<float*>(st + 24) = conv_now; // conviction_ema (init = entry)
|
||
*reinterpret_cast<float*>(st + 44) = 0.0f; // pnl_adjusted_conviction_ema
|
||
*reinterpret_cast<float*>(st + 48) = 0.0f; // peak_unrealized_pnl
|
||
} else if (prev_size != 0 && now_size == 0) {
|
||
// Close — emit TradeRecord.
|
||
const unsigned int idx = trade_log_head[b] % (unsigned int)trade_log_cap;
|
||
trade_log_head[b] += 1;
|
||
unsigned char* rec = trade_log_base + ((size_t)b * trade_log_cap + idx) * TRADE_RECORD_BYTES;
|
||
|
||
const unsigned long long entry_ts = *reinterpret_cast<unsigned long long*>(st + 0);
|
||
const int entry_px_x100 = *reinterpret_cast<int*>(st + 8);
|
||
const int entry_size = *reinterpret_cast<int*>(st + 12);
|
||
const float realized_at_open = *reinterpret_cast<float*>(st + 16);
|
||
// CRT.1 C1.4: horizon_idx_dominant_at_entry moved from offset 20
|
||
// (now conviction_at_entry, f32) to offset 60 in the 64-byte layout.
|
||
const unsigned char horizon_idx = st[60];
|
||
|
||
// Realized P&L delta = pos.realized_pnl_now − pos.realized_pnl_at_open.
|
||
// In price-units × lots.
|
||
const float segment_realized = pos->realized_pnl - realized_at_open;
|
||
|
||
// CRT.diag Group C: hold-time histogram. Observe-only. The buckets
|
||
// (< 1s, 1-10s, 10-60s, 1-10m, 10-60m, > 1h) capture log-scale
|
||
// distribution of how long trades stay open.
|
||
{
|
||
const unsigned long long hold_ns = current_ts_ns - entry_ts;
|
||
const double hold_s = (double)hold_ns / 1.0e9;
|
||
int bk;
|
||
if (hold_s < 1.0) bk = 0;
|
||
else if (hold_s < 10.0) bk = 1;
|
||
else if (hold_s < 60.0) bk = 2;
|
||
else if (hold_s < 600.0) bk = 3;
|
||
else if (hold_s < 3600.0) bk = 4;
|
||
else bk = 5;
|
||
diag_hold_hist[b * 6 + bk] += 1u;
|
||
}
|
||
|
||
// CRT.diag Group D: outcome by entry-conviction (10 conviction
|
||
// buckets over [0, 1)). Reads conviction_at_entry from open_trade_state
|
||
// (offset 20, populated by the open branch above on a prior call).
|
||
{
|
||
const float conv_at_entry = *reinterpret_cast<const float*>(st + 20);
|
||
float c = conv_at_entry;
|
||
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_outcome_n[b * 10 + bk] += 1u;
|
||
diag_outcome_sum_pnl[b * 10 + bk] += segment_realized;
|
||
if (segment_realized > 0.0f) {
|
||
diag_outcome_n_wins[b * 10 + bk] += 1u;
|
||
}
|
||
}
|
||
// Convert to USD ×100 fixed-point: × $50/index-point × 100 = ×5000.
|
||
const int realised_usd_fp = (int)(segment_realized * 5000.0f + (segment_realized >= 0.0f ? 0.5f : -0.5f));
|
||
// Exit price reconstructed from realized delta: realized = (exit − entry) × dir × |size|.
|
||
// For sanity we record pos.vwap_entry at close time (the residual avg if any),
|
||
// but for a clean open→close that's the same as entry_px. We store the
|
||
// implied exit price instead, derived from the realized delta.
|
||
const float entry_px = (float)entry_px_x100 / 100.0f;
|
||
const float size_abs = (entry_size > 0) ? (float)entry_size : (float)(-entry_size);
|
||
const float dir = (entry_size > 0) ? 1.0f : -1.0f;
|
||
float exit_px_safe = (size_abs > 0.0f)
|
||
? (entry_px + segment_realized * dir / size_abs)
|
||
: entry_px;
|
||
// Defensive: NaN/Inf/huge exit_px corrupts trade records. Source bug
|
||
// is likely a boundary case in the fill kernel (NaN in realized_pnl),
|
||
// but until that is traced, clamp to a reasonable range.
|
||
// If exit_px diverged from entry_px by more than 10% of entry_px,
|
||
// the segment_realized is corrupted. Fall back to entry_px so the
|
||
// record reports a sensible price. Also zero realised_pnl_usd_fp so
|
||
// summary metrics are not poisoned by the sentinel value.
|
||
int realised_usd_to_write = realised_usd_fp;
|
||
if (!isfinite(exit_px_safe)
|
||
|| fabsf(exit_px_safe - entry_px) > 0.1f * fabsf(entry_px))
|
||
{
|
||
defensive_exit_clamp[b] += 1u; // S1.20: defensive clamp fired
|
||
exit_px_safe = entry_px;
|
||
realised_usd_to_write = 0;
|
||
}
|
||
|
||
*reinterpret_cast<unsigned long long*>(rec + 0) = entry_ts;
|
||
*reinterpret_cast<unsigned long long*>(rec + 8) = current_ts_ns;
|
||
*reinterpret_cast<int*>(rec + 16) = entry_px_x100;
|
||
*reinterpret_cast<int*>(rec + 20) = (int)(exit_px_safe * 100.0f + 0.5f);
|
||
*reinterpret_cast<int*>(rec + 24) = entry_size;
|
||
*reinterpret_cast<int*>(rec + 28) = 0; // fees_usd_fp placeholder
|
||
*reinterpret_cast<int*>(rec + 32) = realised_usd_to_write;
|
||
rec[36] = horizon_idx;
|
||
rec[37] = 0; // strategy_id placeholder
|
||
rec[38] = 0;
|
||
rec[39] = 0;
|
||
|
||
// Reset open-trade scratch.
|
||
#pragma unroll
|
||
for (int i = 0; i < OPEN_TRADE_STATE_BYTES; ++i) st[i] = 0;
|
||
|
||
// Reset trail HWM so the next entry starts with a fresh ratchet.
|
||
trail_hwm[b] = 0.0f;
|
||
}
|
||
// prev != 0 AND now != 0 (scale-in or partial close): leave entry
|
||
// scratch in place. Multi-fill averaging is a v2 refinement.
|
||
}
|
||
|
||
// P3: GPU-side snapshot of pre-submit Pos state for close detection.
|
||
// Replaces 3 host memcpy_dtoh calls (snapshot_realized_pnl / position_lots /
|
||
// open_horizon_mask). One block per backtest, single-writer thread.
|
||
extern "C" __global__ void snapshot_pos_state(
|
||
const Pos* __restrict__ positions, // [n_backtests]
|
||
int* __restrict__ out_pos_lots, // [n_backtests]
|
||
float* __restrict__ out_realized_pnl, // [n_backtests]
|
||
unsigned int* __restrict__ out_open_horizon_mask, // [n_backtests]
|
||
int n_backtests
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
out_pos_lots[b] = positions[b].position_lots;
|
||
out_realized_pnl[b] = positions[b].realized_pnl;
|
||
out_open_horizon_mask[b] = positions[b].open_horizon_mask;
|
||
}
|
||
|
||
// P3: GPU-side close-transition detection. For each backtest, compare
|
||
// the snapshotted pre-submit pos vs current pos. If prev != 0 AND now == 0
|
||
// → close: write the prev open_horizon_mask + per-lot realised_return.
|
||
// Otherwise: write zeros.
|
||
//
|
||
// Replaces the host loop at sim.rs's Step 5 that called read_pos per
|
||
// close-eligible backtest. At n_parallel=140 that loop was ~140
|
||
// memcpy_dtoh per decision; now it's one kernel.
|
||
extern "C" __global__ void detect_close_transitions_batched(
|
||
const Pos* __restrict__ positions, // [n_backtests]
|
||
const int* __restrict__ prev_pos_lots, // [n_backtests]
|
||
const float* __restrict__ prev_realized_pnl, // [n_backtests]
|
||
const unsigned int* __restrict__ prev_open_horizon_mask, // [n_backtests]
|
||
unsigned int* __restrict__ closed_horizon_mask_out, // [n_backtests]
|
||
float* __restrict__ realised_return_out, // [n_backtests]
|
||
int n_backtests
|
||
) {
|
||
int b = blockIdx.x;
|
||
if (b >= n_backtests || threadIdx.x != 0) return;
|
||
const int prev_lots = prev_pos_lots[b];
|
||
if (prev_lots == 0 || positions[b].position_lots != 0) {
|
||
closed_horizon_mask_out[b] = 0u;
|
||
realised_return_out[b] = 0.0f;
|
||
return;
|
||
}
|
||
const float abs_size = (float)((prev_lots < 0) ? -prev_lots : prev_lots);
|
||
const float pnl_delta = positions[b].realized_pnl - prev_realized_pnl[b];
|
||
closed_horizon_mask_out[b] = prev_open_horizon_mask[b];
|
||
realised_return_out[b] = (abs_size > 0.0f) ? (pnl_delta / abs_size) : 0.0f;
|
||
}
|