Pure-instrumentation commit. Zero behavior change. The Gate CRT.1 smoke p9cnk (commit1e656948b) 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 HEAD1e656948b— not caused by this commit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
261 lines
14 KiB
Plaintext
261 lines
14 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) ← 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 28–43, 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;
|
||
}
|