Files
foxhunt/crates/ml-backtesting/cuda/order_match.cu
jgrusewski 76ec68c1c9 fix(ml-backtesting): per-level price-range validation in walk_ask_for_buy/walk_bid_for_sell — eliminates sized-but-bad-priced sentinel propagation
v2 NaN instrumentation (S1.21) localized the bug to apply_fill_to_pos's
open-from-flat branch writing pos.vwap_entry = avg_px where avg_px was 0
(194 cases) or > 21M finite (216 cases). The arithmetic was clean — root
cause is walk_ask_for_buy/walk_bid_for_sell consuming size from deep
levels (k=1..9) that have lvl_sz > 0 but lvl_px = 0 (or huge sentinel).
MBP-10 fills empty depth slots beyond available levels with these
sentinels.

Existing per-level filter checked lvl_sz <= 0, !isfinite(lvl_sz),
!isfinite(lvl_px) — but allowed lvl_px = 0 and lvl_px > max range.

Fix: thread per-backtest min_reasonable_px / max_reasonable_px (already
uploaded for the top-of-book skip in book_update_apply_snapshot via
S1.19) through to walk_*, and reject any level whose price falls
outside [min_px, max_px]. Same fix shape as Bug C-b (top-of-book skip),
now applied at all 10 depths.

Changes: resting_orders.cu (walk_* signatures + kernel param + 2 call
sites), order_match.cu (walk_* signatures + kernel param + 1 call site),
sim/mod.rs (submit_market + step_resting_orders launch args). 19 CUDA
tests pass.

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

131 lines
5.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.
//
// min_px / max_px: per-backtest price-range bounds (from upload_price_range /
// S1.19). Levels with lvl_px outside [min_px, max_px] are MBP-10 sentinels
// for empty depth slots — consuming them writes avg_px=0 or avg_px>21M into
// pos.vwap_entry (S1.22 root-cause fix).
__device__ static float walk_ask_for_buy(
const Book& book, float size, float& filled_lots,
float min_px, float max_px
) {
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];
const float lvl_px = book.ask_px[k];
if (lvl_sz <= 0.0f || !isfinite(lvl_sz) || !isfinite(lvl_px)
|| lvl_px < min_px || lvl_px > max_px) continue;
const float take = (size < lvl_sz) ? size : lvl_sz;
cost += take * lvl_px;
filled_lots += take;
size -= take;
}
return cost;
}
__device__ static float walk_bid_for_sell(
const Book& book, float size, float& filled_lots,
float min_px, float max_px
) {
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];
const float lvl_px = book.bid_px[k];
if (lvl_sz <= 0.0f || !isfinite(lvl_sz) || !isfinite(lvl_px)
|| lvl_px < min_px || lvl_px > max_px) continue;
const float take = (size < lvl_sz) ? size : lvl_sz;
cost += take * lvl_px;
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).
//
// S1.22: min_reasonable_px / max_reasonable_px added so walk_* can reject
// sentinel-priced depth levels (zero-priced or huge-priced empty MBP-10 slots).
extern "C" __global__ void submit_market_immediate(
const Book* __restrict__ books,
const int* __restrict__ targets, // [n_backtests, 2]
Pos* __restrict__ positions, // [n_backtests]
const float* __restrict__ min_reasonable_px, // [n_backtests]
const float* __restrict__ max_reasonable_px, // [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];
const float min_px = min_reasonable_px[b];
const float max_px = max_reasonable_px[b];
float filled = 0.0f;
const float cost = (side == 0)
? walk_ask_for_buy (book, (float)sz_in, filled, min_px, max_px)
: walk_bid_for_sell(book, (float)sz_in, filled, min_px, max_px);
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;
}