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>
This commit is contained in:
jgrusewski
2026-05-18 08:37:24 +02:00
parent 78f0618294
commit 1b679b5e40
5 changed files with 315 additions and 14 deletions

View File

@@ -0,0 +1,105 @@
// 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.
}

View File

@@ -5,6 +5,13 @@ pub const BOOK_LEVELS: usize = 10;
pub const N_HORIZONS: usize = 5;
pub const MAX_LIMITS: usize = 32;
pub const MAX_STOPS: usize = 16;
/// Max closed-trade records buffered per backtest before the host
/// must drain. Sized for a single fixture day at ~30s decision cadence.
pub const TRADE_LOG_CAP: usize = 1024;
/// Bytes per TradeRecord (must match crates/ml-backtesting/src/order.rs).
pub const TRADE_RECORD_BYTES: usize = 40;
/// Bytes per OpenTradeState (kernel-side tracking of currently-open entry).
pub const OPEN_TRADE_STATE_BYTES: usize = 24;
/// Flat host-side mirror of `cuda/lob_state.cuh::Book`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]

View File

@@ -20,26 +20,28 @@ const BOOK_UPDATE_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/book_update.cubin"));
const ORDER_MATCH_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/order_match.cubin"));
const PNL_TRACK_CUBIN: &[u8] =
include_bytes!(concat!(env!("OUT_DIR"), "/pnl_track.cubin"));
pub struct LobSimCuda {
n_backtests: usize,
stream: Arc<CudaStream>,
book_update_fn: cudarc::driver::CudaFunction,
submit_market_fn: cudarc::driver::CudaFunction,
pnl_track_fn: cudarc::driver::CudaFunction,
// Per-backtest book state on device. Laid out
// [n_backtests, 4 fields × 10 levels] = N × 40 floats.
books_d: CudaSlice<f32>,
// Per-backtest Pos state. Pod struct = 24 bytes; allocated as u8 buffer.
pos_d: CudaSlice<u8>,
// Mapped-pinned input snapshot buffers (size 10 each) — broadcast
// across all N backtests in v1.
bid_px_d: CudaSlice<f32>,
bid_sz_d: CudaSlice<f32>,
ask_px_d: CudaSlice<f32>,
ask_sz_d: CudaSlice<f32>,
// Per-backtest market-order target {side, size} buffer.
market_targets_d: CudaSlice<i32>,
// Trade-log + open-trade-state buffers (C6).
open_trade_state_d: CudaSlice<u8>, // [n_backtests * 24]
trade_log_d: CudaSlice<u8>, // [n_backtests * TRADE_LOG_CAP * 40]
trade_log_head_d: CudaSlice<u32>, // [n_backtests]
}
impl LobSimCuda {
@@ -60,6 +62,12 @@ impl LobSimCuda {
let submit_market_fn = match_module
.load_function("submit_market_immediate")
.context("load submit_market_immediate")?;
let pnl_track_module = ctx
.load_cubin(PNL_TRACK_CUBIN.to_vec())
.context("load pnl_track cubin")?;
let pnl_track_fn = pnl_track_module
.load_function("pnl_track_step")
.context("load pnl_track_step")?;
let books_d = stream
.alloc_zeros::<f32>(n_backtests * BOOK_FIELDS * BOOK_LEVELS)
@@ -75,11 +83,22 @@ impl LobSimCuda {
.alloc_zeros::<i32>(n_backtests * 2)
.context("alloc market_targets_d")?;
let open_trade_state_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::OPEN_TRADE_STATE_BYTES)
.context("alloc open_trade_state_d")?;
let trade_log_d = stream
.alloc_zeros::<u8>(n_backtests * crate::lob::TRADE_LOG_CAP * crate::lob::TRADE_RECORD_BYTES)
.context("alloc trade_log_d")?;
let trade_log_head_d = stream
.alloc_zeros::<u32>(n_backtests)
.context("alloc trade_log_head_d")?;
Ok(Self {
n_backtests,
stream,
book_update_fn,
submit_market_fn,
pnl_track_fn,
books_d,
pos_d,
bid_px_d,
@@ -87,6 +106,9 @@ impl LobSimCuda {
ask_px_d,
ask_sz_d,
market_targets_d,
open_trade_state_d,
trade_log_d,
trade_log_head_d,
})
}
@@ -131,22 +153,29 @@ impl LobSimCuda {
}
/// Submit a market order to one specific backtest. side: 0=buy, 1=sell.
/// Walks the book + updates Pos. Convenience entry for fixture testing;
/// production path goes through the decision-policy kernel (C7).
pub fn submit_market(&mut self, backtest_idx: usize, side: u8, size: u16) -> Result<()> {
/// Walks the book + updates Pos, then runs pnl_track to detect
/// segment_complete and emit TradeRecord if the position closed.
/// Convenience entry for fixture testing; production path goes
/// through the decision-policy kernel (C7).
pub fn submit_market(
&mut self,
backtest_idx: usize,
side: u8,
size: u16,
current_ts_ns: u64,
) -> Result<()> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
// Build targets array: side+size for the target backtest, no-op (side=2) for others.
let mut targets = vec![0i32; self.n_backtests * 2];
for b in 0..self.n_backtests {
targets[b * 2 + 0] = 2; // no-op
targets[b * 2] = 2; // no-op
targets[b * 2 + 1] = 0;
}
targets[backtest_idx * 2 + 0] = side as i32;
targets[backtest_idx * 2] = side as i32;
targets[backtest_idx * 2 + 1] = size as i32;
self.stream
.memcpy_htod(targets.as_slice(), &mut self.market_targets_d)?;
@@ -167,9 +196,73 @@ impl LobSimCuda {
.launch(cfg)?;
}
self.stream.synchronize()?;
// Run pnl_track to detect close + emit TradeRecord.
self.step_pnl_track(current_ts_ns)?;
Ok(())
}
/// Run pnl_track_step kernel: detect segment_complete, emit TradeRecord.
/// Called automatically by submit_market; exposed for callers that
/// orchestrate matching themselves.
pub fn step_pnl_track(&mut self, current_ts_ns: u64) -> Result<()> {
let cfg = LaunchConfig {
grid_dim: (self.n_backtests as u32, 1, 1),
block_dim: (32, 1, 1),
shared_mem_bytes: 0,
};
let n = self.n_backtests as i32;
let cap = crate::lob::TRADE_LOG_CAP as i32;
let mut launch = self.stream.launch_builder(&self.pnl_track_fn);
unsafe {
launch
.arg(&mut self.pos_d)
.arg(&mut self.open_trade_state_d)
.arg(&mut self.trade_log_d)
.arg(&mut self.trade_log_head_d)
.arg(&current_ts_ns)
.arg(&cap)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
Ok(())
}
/// Read all closed-trade records for a backtest. Returns up to
/// TRADE_LOG_CAP records (oldest dropped if the ring wrapped, which
/// the v1 head counter exposes as `head_count`).
pub fn read_trade_records(
&self,
backtest_idx: usize,
) -> Result<Vec<crate::order::TradeRecord>> {
anyhow::ensure!(
backtest_idx < self.n_backtests,
"backtest_idx {} >= n_backtests {}",
backtest_idx,
self.n_backtests
);
let mut heads = vec![0u32; self.n_backtests];
self.stream.memcpy_dtoh(&self.trade_log_head_d, heads.as_mut_slice())?;
let head = heads[backtest_idx] as usize;
if head == 0 {
return Ok(vec![]);
}
let n_to_read = head.min(crate::lob::TRADE_LOG_CAP);
let rec_bytes = crate::lob::TRADE_RECORD_BYTES;
let off = backtest_idx * crate::lob::TRADE_LOG_CAP * rec_bytes;
let mut raw = vec![0u8; self.n_backtests * crate::lob::TRADE_LOG_CAP * rec_bytes];
self.stream.memcpy_dtoh(&self.trade_log_d, raw.as_mut_slice())?;
let mut out = Vec::with_capacity(n_to_read);
for i in 0..n_to_read {
let rec_off = off + i * rec_bytes;
let r: crate::order::TradeRecord =
bytemuck::pod_read_unaligned(&raw[rec_off..rec_off + rec_bytes]);
out.push(r);
}
Ok(out)
}
/// Read back the Pos state for a specific backtest.
pub fn read_pos(&self, backtest_idx: usize) -> Result<PosFlat> {
anyhow::ensure!(

View File

@@ -0,0 +1,50 @@
{
"name": "pnl_accounting_buy_close",
"n_backtests": 1,
"events": [
{
"type": "snapshot",
"bid_px": [5499.75, 5499.50, 5499.25, 5499.00, 5498.75, 5498.50, 5498.25, 5498.00, 5497.75, 5497.50],
"bid_sz": [12.0, 18.0, 24.0, 30.0, 36.0, 42.0, 48.0, 54.0, 60.0, 66.0],
"ask_px": [5500.00, 5500.25, 5500.50, 5500.75, 5501.00, 5501.25, 5501.50, 5501.75, 5502.00, 5502.25],
"ask_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]
},
{
"type": "submit_market",
"backtest_idx": 0,
"side": "buy",
"size": 4,
"ts_ns": 1000000000
},
{
"type": "snapshot",
"bid_px": [5505.00, 5504.75, 5504.50, 5504.25, 5504.00, 5503.75, 5503.50, 5503.25, 5503.00, 5502.75],
"bid_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
"ask_px": [5505.25, 5505.50, 5505.75, 5506.00, 5506.25, 5506.50, 5506.75, 5507.00, 5507.25, 5507.50],
"ask_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]
},
{
"type": "submit_market",
"backtest_idx": 0,
"side": "sell",
"size": 4,
"ts_ns": 2000000000
}
],
"expected_pos": [
{
"position_lots": 0,
"realized_pnl": 20.0,
"realized_pnl_tol": 0.01
}
],
"expected_trade_records": [
{
"entry_ts_ns": 1000000000,
"exit_ts_ns": 2000000000,
"size_lots": 4,
"realised_pnl_usd_fp": 100000,
"realised_pnl_tol_usd_fp": 100
}
]
}

View File

@@ -23,9 +23,13 @@ enum FixtureEvent {
backtest_idx: usize,
side: String,
size: u16,
#[serde(default = "default_ts_ns")]
ts_ns: u64,
},
}
fn default_ts_ns() -> u64 { 1 }
#[derive(Debug, Deserialize)]
struct ExpectedPos {
position_lots: i32,
@@ -49,6 +53,17 @@ struct FixtureBook {
ask_sz: [f32; BOOK_LEVELS],
}
#[derive(Debug, Deserialize)]
struct ExpectedTradeRecord {
entry_ts_ns: u64,
exit_ts_ns: u64,
size_lots: i32,
realised_pnl_usd_fp: i32,
#[serde(default = "default_pnl_fp_tol")]
realised_pnl_tol_usd_fp: i32,
}
fn default_pnl_fp_tol() -> i32 { 100 }
#[derive(Debug, Deserialize)]
struct Fixture {
name: String,
@@ -60,6 +75,8 @@ struct Fixture {
expected_books_apply_to_all: bool,
#[serde(default)]
expected_pos: Vec<ExpectedPos>,
#[serde(default)]
expected_trade_records: Vec<ExpectedTradeRecord>,
}
fn run_book_fixture(path: &Path) -> Result<()> {
@@ -77,13 +94,13 @@ fn run_book_fixture(path: &Path) -> Result<()> {
FixtureEvent::Snapshot { bid_px, bid_sz, ask_px, ask_sz } => {
sim.apply_snapshot(bid_px, bid_sz, ask_px, ask_sz)?;
}
FixtureEvent::SubmitMarket { backtest_idx, side, size } => {
FixtureEvent::SubmitMarket { backtest_idx, side, size, ts_ns } => {
let side_u8 = match side.as_str() {
"buy" => 0u8,
"sell" => 1u8,
other => anyhow::bail!("submit_market: unknown side {other}"),
};
sim.submit_market(*backtest_idx, side_u8, *size)?;
sim.submit_market(*backtest_idx, side_u8, *size, *ts_ns)?;
}
}
}
@@ -112,6 +129,29 @@ fn run_book_fixture(path: &Path) -> Result<()> {
}
}
if !fx.expected_trade_records.is_empty() {
let records = sim.read_trade_records(0)?;
assert_eq!(
records.len(),
fx.expected_trade_records.len(),
"trade record count mismatch: got {}, want {}",
records.len(),
fx.expected_trade_records.len()
);
for (i, want) in fx.expected_trade_records.iter().enumerate() {
let got = &records[i];
assert_eq!(got.entry_ts_ns, want.entry_ts_ns, "trade[{i}].entry_ts_ns");
assert_eq!(got.exit_ts_ns, want.exit_ts_ns, "trade[{i}].exit_ts_ns");
assert_eq!(got.size_lots, want.size_lots, "trade[{i}].size_lots");
let diff = (got.realised_pnl_usd_fp - want.realised_pnl_usd_fp).abs();
assert!(
diff <= want.realised_pnl_tol_usd_fp,
"trade[{i}].realised_pnl_usd_fp got {} want {} (tol {})",
got.realised_pnl_usd_fp, want.realised_pnl_usd_fp, want.realised_pnl_tol_usd_fp
);
}
}
if !fx.expected_pos.is_empty() {
anyhow::ensure!(
fx.expected_pos.len() == fx.n_backtests,
@@ -168,3 +208,9 @@ fn fix_book_update_n_backtests() {
fn fix_market_order_consumes_top() {
run_book_fixture(Path::new("tests/fixtures/market_order_consumes_top.json")).unwrap();
}
#[test]
#[ignore = "requires CUDA"]
fn fix_pnl_accounting_buy_close() {
run_book_fixture(Path::new("tests/fixtures/pnl_accounting_buy_close.json")).unwrap();
}