Files
foxhunt/crates/ml-backtesting/cuda/order_match.cu
jgrusewski 78f0618294 feat(ml-backtesting): order_match immediate market-fill kernel + Pos accounting
submit_market_immediate walks the ask/bid book for a buy/sell market
order, computes notional cost + filled lots across up to 10 levels,
and updates per-backtest Pos { position_lots, vwap_entry, realized_pnl }
with correct VWAP scale-in + counter-direction realized-P&L accounting.
Single-writer (thread 0) per block — no atomics per
feedback_no_atomicadd.md. Each backtest gets its own target slot
{ side, size } in the device-global targets array (side=2 = no-op).

Pos struct added to lob_state.cuh (24 bytes); host mirror PosFlat in
src/lob/mod.rs is repr(C) bytemuck::Pod for direct host↔device cast.

LobSimCuda gains submit_market(backtest_idx, side, size) +
read_pos(backtest_idx) -> PosFlat. Fixture harness extended with the
SubmitMarket event variant + ExpectedPos check (vwap_tol + realized_pnl_tol
for FP tolerance).

market_order_consumes_top fixture verifies a buy of 5 lots into
ask[3@5500.00, 10@5500.25] fills 3+2 → position_lots=5,
vwap_entry=5500.10 within 1e-3 tolerance. All 4 Ring 1 fixtures
(book_update × 3 + market_order × 1) green on RTX 3050.

Scope deliberately trimmed from plan C5: this commit lands market-fill
matching only. Queue decay on resting limits, in-flight latency
promotion, stop-trigger detection, and OCO leg-cancellation will land
in follow-up commits — each in its own kernel addition. This keeps the
incremental delta reviewable.

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

112 lines
4.4 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.
// order_match.cu — immediate market-order matching (C5 minimal).
//
// v1 scope: walk the book for an aggressive market order, update
// position + VWAP + realized P&L. Single-writer (thread 0) per block.
//
// Resting-limit machinery (queue decay, in-flight latency promotion,
// stop triggers, OCO link cancellation) is added in follow-up commits.
//
// Per feedback_no_atomicadd.md: no atomics — single-writer discipline.
#include "lob_state.cuh"
// Walk ask side for a buy of `size`. Returns total notional cost in
// price-units. Writes filled_lots out-param.
__device__ static float walk_ask_for_buy(
const Book& book, float size, float& filled_lots
) {
float cost = 0.0f;
filled_lots = 0.0f;
#pragma unroll
for (int k = 0; k < 10; ++k) {
if (size <= 0.0f) break;
const float lvl_sz = book.ask_sz[k];
if (lvl_sz <= 0.0f) continue;
const float take = (size < lvl_sz) ? size : lvl_sz;
cost += take * book.ask_px[k];
filled_lots += take;
size -= take;
}
return cost;
}
__device__ static float walk_bid_for_sell(
const Book& book, float size, float& filled_lots
) {
float cost = 0.0f;
filled_lots = 0.0f;
#pragma unroll
for (int k = 0; k < 10; ++k) {
if (size <= 0.0f) break;
const float lvl_sz = book.bid_sz[k];
if (lvl_sz <= 0.0f) continue;
const float take = (size < lvl_sz) ? size : lvl_sz;
cost += take * book.bid_px[k];
filled_lots += take;
size -= take;
}
return cost;
}
// Immediate market-order fill: walks the book, updates Pos.
// One block per backtest; thread 0 does all the work.
//
// `targets` is a [n_backtests, 2]-shaped i32 array: { side, size }.
// side: 0=buy, 1=sell, 2=no-op (skip this backtest this step).
extern "C" __global__ void submit_market_immediate(
const Book* __restrict__ books,
const int* __restrict__ targets, // [n_backtests, 2]
Pos* __restrict__ positions, // [n_backtests]
int n_backtests
) {
int b = blockIdx.x;
if (b >= n_backtests || threadIdx.x != 0) return;
const int side = targets[b * 2 + 0];
const int sz_in = targets[b * 2 + 1];
if (side == 2 || sz_in <= 0) return; // no-op for this backtest
const Book& book = books[b];
Pos& pos = positions[b];
float filled = 0.0f;
const float cost = (side == 0)
? walk_ask_for_buy (book, (float)sz_in, filled)
: walk_bid_for_sell(book, (float)sz_in, filled);
if (filled <= 0.0f) return;
const float avg_px = cost / filled;
const float sgn = (side == 0) ? 1.0f : -1.0f;
const float prev_pos_f = (float)pos.position_lots;
const float new_pos_f = prev_pos_f + sgn * filled;
if (pos.position_lots == 0) {
// Open position from flat.
pos.vwap_entry = avg_px;
} else if ((prev_pos_f > 0.0f) == (sgn > 0.0f)) {
// Same direction: scale-in. Update VWAP by lot-weighted average.
const float prev_notional = prev_pos_f * pos.vwap_entry; // signed notional ($ at entry-side)
const float new_notional = prev_notional + sgn * cost;
const float new_abs = (new_pos_f > 0.0f) ? new_pos_f : -new_pos_f;
pos.vwap_entry = (new_abs > 0.0f) ? (new_notional / (sgn * new_abs)) : 0.0f;
} else {
// Counter direction: realize P&L on the unwound portion.
const float prev_abs = (prev_pos_f > 0.0f) ? prev_pos_f : -prev_pos_f;
const float unwind = (filled < prev_abs) ? filled : prev_abs;
// Profit per lot for a closed long = exit_px entry_px
// Profit per lot for a closed short = entry_px exit_px
// prev_pos_f > 0 (long) closing via sell: side==1, sgn=1 → realised = (avg_px vwap) × unwind
// prev_pos_f < 0 (short) closing via buy: side==0, sgn=+1 → realised = (vwap avg_px) × unwind
const float dir_was_long = (prev_pos_f > 0.0f) ? 1.0f : -1.0f;
const float realised = (avg_px - pos.vwap_entry) * dir_was_long * unwind;
pos.realized_pnl += realised;
// If counter-fill exceeds the prior position, the residual opens
// a new position in the opposite direction at avg_px.
if (filled > prev_abs) {
pos.vwap_entry = avg_px;
}
// If position is now flat, vwap_entry becomes irrelevant.
}
pos.position_lots = (int)new_pos_f;
}