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>
400 lines
18 KiB
Plaintext
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;
|
|
}
|