Files
foxhunt/crates/ml-backtesting/cuda/pnl_track.cu
jgrusewski b44a97ff94 diag(crt): empirical measurement battery for signal × controller × cost analysis
Pure-instrumentation commit. Zero behavior change. The Gate CRT.1 smoke
p9cnk (commit 1e656948b) produced 222k trades and 29688% drawdown,
worse than the failed CRT.A. The structural fixes (multi-horizon
conviction, no-trade band, composite exit) all work as designed — but
the trade cadence is fundamentally too fast for the signal/cost ratio.
Before another round of threshold tuning, measure the structural truth.

Adds device-side counters dumped at smoke close (single end-of-run
memcpy_dtoh batch in read_diagnostics(), NOT per-event):

Group A — per-horizon signal persistence:
  - flip_count[h]:        total direction-sign flips
  - sum_run_length[h]:    cumulative events between flips (u64)
  - run_length_hist[h]:   5-bucket log-scale histogram (1-9, 10-99,
                          100-999, 1k-9.9k, 10k+)
  Output: mean_run_len per horizon = signal half-life proxy

Group B — conviction smoothed-EMA distribution:
  - conv_hist[10]:        10-bucket histogram over [0, 1)
  Output: shape of conviction distribution at decision time

Group C — hold-time distribution:
  - hold_hist[6]:         <1s, 1-10s, 10-60s, 1-10m, 10-60m, >1h
  Output: empirical distribution of trade hold times

Group D — outcome by entry-conviction:
  - outcome_n[10]:        trades per conviction bucket
  - outcome_sum_pnl[10]:  cumulative realized PnL per bucket (price-units)
  - outcome_n_wins[10]:   wins per bucket
  Output: validates "high conviction = better trades" hypothesis OR
          surfaces signal miscalibration

Per-backtest memory: ~330 bytes. Negligible vs existing state.

All counters single-writer-per-block (threadIdx.x == 0 convention; no
atomicAdd per feedback_no_atomicadd). All updates kernel-internal (no
host roundtrip per event). The end-of-run dump uses memcpy_dtoh in
read_diagnostics() — called once at harness termination from the
post-stop_ctrl_counters block, never per-event. Per
feedback_no_htod_htoh_only_mapped_pinned, the hot path is untouched.

N_HORIZONS = 5 (heads.rs canonical {30, 100, 300, 1000, 6000}); the
plan text says 4 but the workspace constant is 5 — used the code value.

Output at end of cluster smoke as a `crt_diag` log block:
  crt_diag h30: flips=X mean_run_len=Y events
  crt_diag h30 run_length_hist: 1-9:N 10-99:N 100-999:N ...
  ...
  crt_diag conv_ema_hist: [0.0-0.1]:N [0.1-0.2]:N ...
  crt_diag hold_time_hist: <1s:N 1-10s:N ...
  crt_diag outcome_by_entry_conv[0.0-0.1]: n=N win_rate=X.X% mean_pnl_pu=Y.YYY
  ...

The output drives the next round of CRT.1 threshold design (delta_floor,
composite exit factors, min-hold cooldown) from data instead of guesses.

Per pearl_adaptive_not_tuned and pearl_controller_anchors_isv_driven:
thresholds will become ISV-derived in CRT.2; CRT.1 must first establish
defensible defaults from the empirical signal characteristics.

Verified: stop_controller (22/22 pass), decision_floor_coldstart (3/3
pass), threshold_and_cost (3/3 pass), parallel_sim_correctness (1/1
pass), lob_sim_integrated_fuzz (3/3 pass). Two lob_sim_fixtures
failures (fix_decision_alpha_buy_close, fix_decision_program_h4_only)
were pre-existing at HEAD 1e656948b — not caused by this commit.

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

261 lines
14 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.
// 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) ← NEW in CRT.1
// 24 4 conviction_ema (f32) ← NEW in CRT.1
// 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
// CRT.1 C1.4: current smoothed multi-horizon conviction (from
// decision_policy kernels). Read on open transition to snapshot
// conviction_at_entry + initialize conviction_ema mirror in
// open_trade_state.
const float* __restrict__ conviction_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 2843, degradation counter at 52, horizon_idx at 60)
// remain zero from the close-branch reset / alloc_zeros init.
const float conv_now = conviction_ema_per_b[b];
*reinterpret_cast<float*>(st + 20) = conv_now; // conviction_at_entry
*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;
}