Files
foxhunt/crates/ml-backtesting/cuda/pnl_track.cu
jgrusewski 1b679b5e40 feat(ml-backtesting): pnl_track kernel + segment_complete TradeRecord emission
pnl_track_step runs after each matching pass, compares per-block
position-state-now against persisted OpenTradeState (24 B scratch)
and either:
  - records entry context (entry_ts_ns, entry_px_x100, entry_size,
    realised_at_open) on open transition (prev==0, now!=0); or
  - emits a 40-byte TradeRecord into the per-block trade-log buffer
    on close transition (prev!=0, now==0), reconstructing implied
    exit_px from the realized P&L delta and converting to USD ×100
    fixed-point ($50/index-point × 100 = ×5000 multiplier).

Multi-fill averaging (scale-in then partial close) deferred to v2 —
v1 covers the clean open→close case the spec calls out as primary.

LobSimCuda owns three new buffers: open_trade_state_d (n × 24),
trade_log_d (n × TRADE_LOG_CAP × 40), trade_log_head_d (n × u32).
submit_market now takes current_ts_ns and chains pnl_track_step
internally; step_pnl_track() exposed for caller-driven orchestration.

read_trade_records(backtest_idx) drains the per-block ring as
Vec<TradeRecord>; LSP-pinned 40-byte Pod struct from C2 lines up
1:1 with the kernel's hand-rolled byte writes.

pnl_accounting_buy_close fixture: buy 4 lots @ ask top (5500.00),
book moves +5 to bid top 5505.00, sell 4 to close. Expected
realized_pnl = (5505 − 5500.00) × 4 = $20 in price-units, which is
$20 × $50/contract × 100 = 100000 USD ×100 fixed-point. PASS within
$1 fixed-point tolerance.

All 5 Ring 1 fixtures green on RTX 3050.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 08:37:24 +02:00

106 lines
5.3 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"
#define OPEN_TRADE_STATE_BYTES 24
#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 * 24 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
) {
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.
*reinterpret_cast<unsigned long long*>(st + 0) = current_ts_ns;
*reinterpret_cast<int*>(st + 8) = (int)(pos->vwap_entry * 100.0f + 0.5f);
*reinterpret_cast<int*>(st + 12) = now_size;
*reinterpret_cast<float*>(st + 16) = pos->realized_pnl;
st[20] = 0; // horizon_idx; set by decision kernel in C7
} 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);
const unsigned char horizon_idx = st[20];
// 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;
// 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;
const float exit_px = (size_abs > 0.0f) ? (entry_px + segment_realized * dir / size_abs) : entry_px;
*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 * 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_fp;
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;
}
// prev != 0 AND now != 0 (scale-in or partial close): leave entry
// scratch in place. Multi-fill averaging is a v2 refinement.
}