Files
foxhunt/crates/ml-backtesting/cuda/resting_orders.cu
jgrusewski 1fb2decb79 feat(ml-backtesting): resting orders + stops + OCO + audit ring (C11)
Picks up the deferred work from C5/C7 trim notes. Adds:

  cuda/lob_state.cuh — LimitSlot (32 B) + StopSlot (32 B) + Orders
    (limits[32] + stops[16] = 1536 B/backtest). u64-aligned with
    explicit _pad[6] on both slot structs so the Rust mirrors
    (LimitSlotFlat / StopSlotFlat) bytemuck::Pod-derive without
    panics about implicit padding.

  cuda/resting_orders.cu — three kernels:
    resting_orders_step (runs per event after book_update):
      1. In-flight → resting promotion at arrival_ts_ns. Queue position
         initialised pessimistically (full level depth ahead) per spec §9.
      2. Queue decay against same-side trade_signed_vol at level: ahead-
         of-us first then us; partial-fill emits OrderEvent + pos update.
      3. Marketability check: book moved through our price → cross at
         the just-arrived book (slippage-aware fill walks levels).
         IOC cancels remainder; FOK does too (partial-fill not allowed).
      4. Stop trigger detection: best ask up for buy-stop, best bid
         down for sell-stop. StopMarket walks book; StopLimit converts
         to a new LimitSlot (overflow → submission_overflow++).
      5. OCO mutual exclusion: oco_pair byte (0..31 = limit, 32..47 =
         stop) — when one leg fills/triggers, the paired slot is freed.
    seed_limit_slot / seed_stop_slot — host-side single-slot seeders
    for fixture testing + as a v1 entry point until the decision
    kernel learns to emit resting orders. Both set an overflow_flag
    if the target slot is already in use (gen_counter bumped on
    successful seed for SlotTag freshness).

  src/sim.rs — wires three new methods:
    seed_limit_order(b, slot, side, kind, active_state, oco_pair,
                     price, size, queue_position_init, arrival_ts_ns)
    seed_stop_order(b, slot, side, kind, active_state, oco_pair,
                    trigger_price, limit_price, size, arrival_ts_ns)
    step_resting_orders(current_ts_ns, trade_signed_vol)
      → launches resting_orders_step kernel + chains step_pnl_track
        so any fill that closes a position emits a TradeRecord.
    Plus read_limit_slot / read_stop_slot / read_audit_records for
    fixture inspection.

  Audit-ring buffers (deferred from C2 originally) now allocated:
    audit_d (n × 256 × 24 B) + audit_head_d (n × u32). Both
    resting_orders.cu and the decision kernel's WriteOrder path
    populate it via the inlined pack_slot_tag + emit_audit helpers.

  Four new GPU fixtures:
    limit_rest_marketable_fill — resting buy at 5500 with book at
      ask 5500 fills immediately on step_resting (3 lots, vwap=5500).
    stop_trigger — buy 4 lots @ 5500, seed sell-stop at trigger=5495,
      book moves so bid=5494.50: stop triggers, walks book, position
      closes flat, TradeRecord emitted, stop slot active=0.
    oco_one_cancels_other — pair {buy@5495, sell@5505} with oco_pair
      cross-linked: book moves so bid=5505.50, sell leg fills (pos=-2),
      buy leg auto-cancelled. Both slots end active=0.
    submission_overflow — host loop fills all 32 limit slots, then 33rd
      seed_limit_order call must return Err (slot 0 already in use).

All 10 GPU fixtures green on RTX 3050 (6 original + 4 new). Ring 2
fuzz at N ∈ {1, 16, 256} still green. 33 lib unit tests still green.

The decision kernel (C7) and submit_market_immediate (C5) paths
are unchanged — they continue to use the "immediate fill" route.
A follow-up commit can teach the decision kernel to emit resting
limits via submit_limit_alloc (already defined in resting_orders.cu).

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

400 lines
18 KiB
Plaintext

// resting_orders.cu — limit + stop + OCO machinery.
//
// Runs on every event (after book_update, before pnl_track). Per-block,
// single-writer (thread 0). Processes:
//
// 1. In-flight → resting promotion when arrival_ts_ns reached.
// Marketable on arrival? Cross immediately at the just-arrived book.
// 2. Resting limits: queue-decay against trade_signed_vol at level,
// check marketability against the new book (price moved through us).
// 3. Stop arming/triggering: best ask crossing trigger up → buy stop,
// best bid crossing trigger down → sell stop. On trigger:
// - StopMarket: walk book immediately, fill, emit OrderEvent
// - StopLimit: convert to a resting limit (allocate a LimitSlot)
// 4. OCO mutual exclusion: when one leg fills or stops triggers, free
// the paired slot via oco_pair index (0..31 = limit, 32..47 = stop).
//
// Per feedback_no_atomicadd.md: no atomics — single writer per block.
#include "lob_state.cuh"
#define KIND_LIMIT 1
#define KIND_IOC 2
#define KIND_FOK 3
#define KIND_STOP_MARKET 4
#define KIND_STOP_LIMIT 5
// Book walking helpers — same shape as order_match.cu but inlined here
// to keep this kernel's symbol set self-contained.
__device__ static float walk_ask_for_buy(const Book& book, float size, float& filled) {
float cost = 0.0f; filled = 0.0f;
#pragma unroll
for (int k = 0; k < 10 && size > 0.0f; ++k) {
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 += take; size -= take;
}
return cost;
}
__device__ static float walk_bid_for_sell(const Book& book, float size, float& filled) {
float cost = 0.0f; filled = 0.0f;
#pragma unroll
for (int k = 0; k < 10 && size > 0.0f; ++k) {
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 += take; size -= take;
}
return cost;
}
// Mirror of decision_policy.cu's emit_event — duplicated to avoid
// a cross-kernel header dependency. Layout matches OrderEvent (24 B)
// in crates/ml-backtesting/src/order.rs.
__device__ static void emit_audit(
unsigned char* audit_base, unsigned int* head_d, int b, unsigned int ring_cap,
unsigned long long ts_ns, unsigned int slot_tag, unsigned char kind,
unsigned char side, unsigned short size, int px_ticks, int trigger_px_ticks
) {
const unsigned int idx = head_d[b] % ring_cap;
head_d[b] += 1;
unsigned char* rec = audit_base + ((size_t)b * ring_cap + idx) * 24;
*reinterpret_cast<unsigned long long*>(rec + 0) = ts_ns;
*reinterpret_cast<unsigned int*>(rec + 8) = slot_tag;
rec[12] = kind;
rec[13] = side;
*reinterpret_cast<unsigned short*>(rec + 14) = size;
*reinterpret_cast<int*>(rec + 16) = px_ticks;
*reinterpret_cast<int*>(rec + 20) = trigger_px_ticks;
}
// SlotTag pack: kind | (idx << 8) | (gen << 16). See src/order.rs.
__device__ static unsigned int pack_slot_tag(unsigned char kind, unsigned char idx, unsigned short gen) {
return (unsigned int)kind | ((unsigned int)idx << 8) | ((unsigned int)gen << 16);
}
// Free the slot referenced by an oco_pair byte: 0..31 = limits, 32..47 = stops.
__device__ static void cancel_oco_pair(Orders& orders, unsigned char oco_pair) {
if (oco_pair == 0xFF) return;
if (oco_pair < MAX_LIMITS) {
orders.limits[oco_pair].active = 0;
} else if (oco_pair - MAX_LIMITS < MAX_STOPS) {
orders.stops[oco_pair - MAX_LIMITS].active = 0;
}
}
// Apply a fill to per-backtest position state. Same VWAP-scale-in /
// counter-direction realised-P&L rule as order_match.cu's market-order
// path; isolated here as a __device__ helper.
__device__ static void apply_fill_to_pos(Pos& pos, float filled_lots, float total_cost, unsigned char side) {
if (filled_lots <= 0.0f) return;
const float avg_px = total_cost / filled_lots;
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_lots;
if (pos.position_lots == 0) {
pos.vwap_entry = avg_px;
} else if ((prev_pos_f > 0.0f) == (sgn > 0.0f)) {
const float prev_notional = prev_pos_f * pos.vwap_entry;
const float new_notional = prev_notional + sgn * total_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 {
const float prev_abs = (prev_pos_f > 0.0f) ? prev_pos_f : -prev_pos_f;
const float unwind = (filled_lots < prev_abs) ? filled_lots : prev_abs;
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 (filled_lots > prev_abs) { pos.vwap_entry = avg_px; }
}
pos.position_lots = (int)new_pos_f;
}
// Free-slot search. Returns slot index 0..31 or -1 if all in use.
__device__ static int find_free_limit_slot(Orders& orders) {
#pragma unroll
for (int i = 0; i < MAX_LIMITS; ++i) {
if (orders.limits[i].active == 0) return i;
}
return -1;
}
// Main resting-orders processing kernel.
//
// `tick_size` is the price-to-tick conversion for audit-event recording.
extern "C" __global__ void resting_orders_step(
const Book* __restrict__ books, // [n_backtests]
unsigned char* orders_base, // [n_backtests * ORDERS_BYTES]
unsigned char* pos_base,
unsigned char* audit_base,
unsigned int* audit_head,
unsigned long long current_ts_ns,
float trade_signed_vol,
int orders_bytes,
int pos_bytes,
int ring_cap_events,
float tick_size,
int n_backtests
) {
int b = blockIdx.x;
if (b >= n_backtests || threadIdx.x != 0) return;
const Book& book = books[b];
Orders& orders = *reinterpret_cast<Orders*>(orders_base + (size_t)b * orders_bytes);
Pos& pos = *reinterpret_cast<Pos*>(pos_base + (size_t)b * pos_bytes);
// (1) Promote in-flight limits to resting; (2) queue-decay; (3) marketability check.
const float trade_abs = (trade_signed_vol < 0.0f) ? -trade_signed_vol : trade_signed_vol;
for (int i = 0; i < MAX_LIMITS; ++i) {
LimitSlot& s = orders.limits[i];
if (s.active == 2 && current_ts_ns >= s.arrival_ts_ns) {
s.active = 1;
// Initialise queue position pessimistically: full level depth
// ahead of us at the just-arrived book (per spec §9 queue-decay
// risk row).
int level_idx = -1;
if (s.side == 0) {
#pragma unroll
for (int k = 0; k < 10; ++k) if (book.bid_px[k] == s.price) { level_idx = k; break; }
s.queue_position = (level_idx >= 0) ? book.bid_sz[level_idx] : 0.0f;
} else {
#pragma unroll
for (int k = 0; k < 10; ++k) if (book.ask_px[k] == s.price) { level_idx = k; break; }
s.queue_position = (level_idx >= 0) ? book.ask_sz[level_idx] : 0.0f;
}
}
if (s.active != 1) continue;
// Queue decay: trade flow on our side at our price level eats ahead-of-us first,
// then us. Only count trades on the SAME side as our resting order.
if (trade_abs > 0.0f) {
const bool same_side_trade = (s.side == 0 && trade_signed_vol < 0.0f) // resting bid + seller hits us
|| (s.side == 1 && trade_signed_vol > 0.0f); // resting ask + buyer lifts
if (same_side_trade) {
if (s.queue_position >= trade_abs) {
s.queue_position -= trade_abs;
} else {
const float over = trade_abs - s.queue_position;
s.queue_position = 0.0f;
const float take = (over < s.size_remaining) ? over : s.size_remaining;
if (take > 0.0f) {
// Filled `take` lots at our limit price (queue position reached us).
const float cost = take * s.price;
apply_fill_to_pos(pos, take, cost, s.side);
s.size_remaining -= take;
emit_audit(audit_base, audit_head, b, (unsigned int)ring_cap_events,
current_ts_ns,
pack_slot_tag(0 /*SlotKind::Limit*/, (unsigned char)i, s.gen_counter),
1 /*SubmitLimit*/, s.side, (unsigned short)take,
(int)(s.price / tick_size + 0.5f), 0);
if (s.size_remaining <= 0.0f) {
cancel_oco_pair(orders, s.oco_pair);
s.active = 0;
continue;
}
}
}
}
}
// Marketability check: book moved through our price → cross at the
// arrival book (slippage-aware fill). Triggers on:
// buy limit @ p ≥ ask_px[0] — we'd be taking liquidity at our price or better
// sell limit @ p ≤ bid_px[0] — same on opposite side
const bool marketable = (s.side == 0 && s.price >= book.ask_px[0])
|| (s.side == 1 && s.price <= book.bid_px[0]);
if (marketable) {
float filled = 0.0f;
const float cost = (s.side == 0)
? walk_ask_for_buy (book, s.size_remaining, filled)
: walk_bid_for_sell(book, s.size_remaining, filled);
if (filled > 0.0f) {
apply_fill_to_pos(pos, filled, cost, s.side);
s.size_remaining -= filled;
emit_audit(audit_base, audit_head, b, (unsigned int)ring_cap_events,
current_ts_ns,
pack_slot_tag(0, (unsigned char)i, s.gen_counter),
s.kind /*Limit|Ioc|Fok*/, s.side, (unsigned short)filled,
(int)(s.price / tick_size + 0.5f), 0);
}
// IOC: any unfilled remainder cancelled immediately.
// FOK: if not fully filled, cancel everything (no partial leaves resting).
// Limit: leave residual resting at our price.
if (s.kind == KIND_IOC || (s.kind == KIND_FOK && s.size_remaining > 0.0f)) {
if (s.kind == KIND_FOK && filled > 0.0f) {
// FOK partial fill is forbidden — should not happen; defensive guard
// documents intent but the actual prevention belongs in submission code.
}
cancel_oco_pair(orders, s.oco_pair);
s.active = 0;
continue;
}
if (s.size_remaining <= 0.0f) {
cancel_oco_pair(orders, s.oco_pair);
s.active = 0;
continue;
}
}
}
// (4) Stop trigger detection.
for (int j = 0; j < MAX_STOPS; ++j) {
StopSlot& st = orders.stops[j];
if (st.active == 2 && current_ts_ns >= st.arrival_ts_ns) st.active = 1;
if (st.active != 1) continue;
const bool triggered = (st.side == 0 && book.ask_px[0] >= st.trigger_price)
|| (st.side == 1 && book.bid_px[0] <= st.trigger_price);
if (!triggered) continue;
st.active = 0;
if (st.kind == KIND_STOP_MARKET) {
float filled = 0.0f;
const float cost = (st.side == 0)
? walk_ask_for_buy (book, st.size, filled)
: walk_bid_for_sell(book, st.size, filled);
if (filled > 0.0f) {
apply_fill_to_pos(pos, filled, cost, st.side);
emit_audit(audit_base, audit_head, b, (unsigned int)ring_cap_events,
current_ts_ns,
pack_slot_tag(1 /*SlotKind::Stop*/, (unsigned char)j, st.gen_counter),
4 /*SubmitStopMarket*/, st.side, (unsigned short)filled,
0, (int)(st.trigger_price / tick_size + 0.5f));
}
cancel_oco_pair(orders, st.oco_pair);
} else if (st.kind == KIND_STOP_LIMIT) {
// Convert to a resting limit at st.limit_price (may not fill immediately
// if the limit is not marketable at current book).
const int free_idx = find_free_limit_slot(orders);
if (free_idx < 0) {
pos.submission_overflow += 1u;
cancel_oco_pair(orders, st.oco_pair);
continue;
}
LimitSlot& ns = orders.limits[free_idx];
ns.active = 1;
ns.side = st.side;
ns.kind = KIND_LIMIT;
ns.oco_pair = st.oco_pair;
ns.price = st.limit_price;
ns.size_remaining = st.size;
ns.queue_position = 0.0f; // marketable check next iter will fire if applicable
ns.arrival_ts_ns = current_ts_ns;
ns.gen_counter = (unsigned short)(ns.gen_counter + 1);
emit_audit(audit_base, audit_head, b, (unsigned int)ring_cap_events,
current_ts_ns,
pack_slot_tag(1 /*SlotKind::Stop*/, (unsigned char)j, st.gen_counter),
5 /*SubmitStopLimit*/, st.side, (unsigned short)st.size,
(int)(st.limit_price / tick_size + 0.5f),
(int)(st.trigger_price / tick_size + 0.5f));
}
}
}
// Host-callable seed helpers — write a single slot. Single-block,
// single-thread. Used for fixture testing + as the kernel side of
// LobSimCuda::submit_limit. SlotKind::Limit only (Stop seed below).
extern "C" __global__ void seed_limit_slot(
unsigned char* orders_base,
int orders_bytes,
int b,
int slot_idx,
unsigned char side,
unsigned char kind,
unsigned char active_state, // 1=resting immediately, 2=in-flight
unsigned char oco_pair,
float price,
float size,
float queue_position_init,
unsigned long long arrival_ts_ns,
unsigned int* overflow_flag // [1] — set to 1 if slot_idx already in use
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
Orders* orders = reinterpret_cast<Orders*>(orders_base + (size_t)b * orders_bytes);
if (slot_idx < 0 || slot_idx >= MAX_LIMITS) { *overflow_flag = 1u; return; }
LimitSlot& s = orders->limits[slot_idx];
if (s.active != 0) { *overflow_flag = 1u; return; }
s.active = active_state;
s.side = side;
s.kind = kind;
s.oco_pair = oco_pair;
s.price = price;
s.size_remaining = size;
s.queue_position = queue_position_init;
s.arrival_ts_ns = arrival_ts_ns;
s.gen_counter = (unsigned short)(s.gen_counter + 1);
}
extern "C" __global__ void seed_stop_slot(
unsigned char* orders_base,
int orders_bytes,
int b,
int slot_idx,
unsigned char side,
unsigned char kind,
unsigned char active_state,
unsigned char oco_pair,
float trigger_price,
float limit_price,
float size,
unsigned long long arrival_ts_ns,
unsigned int* overflow_flag
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
Orders* orders = reinterpret_cast<Orders*>(orders_base + (size_t)b * orders_bytes);
if (slot_idx < 0 || slot_idx >= MAX_STOPS) { *overflow_flag = 1u; return; }
StopSlot& st = orders->stops[slot_idx];
if (st.active != 0) { *overflow_flag = 1u; return; }
st.active = active_state;
st.side = side;
st.kind = kind;
st.oco_pair = oco_pair;
st.trigger_price = trigger_price;
st.limit_price = limit_price;
st.size = size;
st.arrival_ts_ns = arrival_ts_ns;
st.gen_counter = (unsigned short)(st.gen_counter + 1);
}
// Try to allocate a limit slot from device-side submission code (e.g. C7
// decision kernel later) — returns slot index or -1 if all in use.
// Bumps pos.submission_overflow on failure. Single-block, single-thread.
extern "C" __global__ void submit_limit_alloc(
unsigned char* orders_base,
unsigned char* pos_base,
int orders_bytes,
int pos_bytes,
int b,
unsigned char side,
unsigned char kind,
unsigned char oco_pair,
float price,
float size,
unsigned long long arrival_ts_ns,
int* allocated_slot_out // [1] — written: slot idx or -1
) {
if (threadIdx.x != 0 || blockIdx.x != 0) return;
Orders* orders = reinterpret_cast<Orders*>(orders_base + (size_t)b * orders_bytes);
Pos* pos = reinterpret_cast<Pos*>(pos_base + (size_t)b * pos_bytes);
const int idx = find_free_limit_slot(*orders);
if (idx < 0) {
pos->submission_overflow += 1u;
*allocated_slot_out = -1;
return;
}
LimitSlot& s = orders->limits[idx];
s.active = 2; // in-flight
s.side = side;
s.kind = kind;
s.oco_pair = oco_pair;
s.price = price;
s.size_remaining = size;
s.queue_position = 0.0f;
s.arrival_ts_ns = arrival_ts_ns;
s.gen_counter = (unsigned short)(s.gen_counter + 1);
*allocated_slot_out = idx;
}