Files
foxhunt/crates/ml-backtesting/cuda/order_match.cu
jgrusewski da2ad438ca feat(rl): R2 — fee model + loader pair API
Ports two scope-complete fixes from the ml-alpha-phase-f-g-flawed
reference branch:

A7 fee model:
- order_match.cu::submit_market_immediate gains 2 new kernel args
  (cost_per_lot_per_side, total_fees_per_b) mirroring the per-fill
  fee deduction in resting_orders.cu::apply_fill_to_pos:213-219.
  Fee deducts from pos.realized_pnl on EVERY fill (open, scale-in,
  counter); total_fees_per_b accumulates for telemetry.
  Single-writer-per-block plain += is safe — line 79 has
  `threadIdx.x != 0 → return` (feedback_no_atomicadd).
- LobSimCuda::submit_market launch updated to pass the 2 new args.
- LobSimCuda::upload_cost_per_lot_per_side host API lets callers
  configure ES-realistic fees (≈$1.25/contract/side). Default
  alloc_zeros = $0; production decision-policy path is unchanged
  (uploads its own cost via step_decision_with_latency).

A8 loader pair API:
- MultiHorizonLoader::next_sequence_pair returns
  (LabeledSequence, LabeledSequence) at adjacent anchors in the
  same source file. anchor_t sampled from [min_anchor, max_anchor−1)
  so anchor+1 also fits the upper-bound. Counts as ONE yielded
  sequence against n_max_sequences.
- next_sequence_random and next_sequence_pair share a new private
  helper build_sequence_at(lf, anchor) -> LabeledSequence that
  contains the multi-resolution windowing logic. Single source of
  truth for the build (feedback_single_source_of_truth_no_duplicates).
- next_sequence's caller-facing contract (random anchor, one
  sequence per call) is unchanged — alpha_train.rs supervised
  pipeline keeps working as-is.

No new local tests this phase per the rebuild plan (R2): the fee
deduction with default cost=0 is a no-op for existing callers, and
G7 in R6 covers the with-fees path via a rebuilt reward_calibration
test driving LobSimCuda directly (no LobEnv adapter).

cargo check -p ml-alpha -p ml-backtesting + cargo build --tests on
ml-backtesting both green; baseline test suite unaffected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 09:48:38 +02:00

149 lines
6.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).
//
// Phase R2 (was F.3a): cost_per_lot_per_side + total_fees_per_b mirror
// the per-fill fee deduction in resting_orders.cu::apply_fill_to_pos
// (lines 213-219). Fee is deducted from pos.realized_pnl on every fill
// (open, scale-in, counter) and accumulated to total_fees_per_b for
// telemetry. Default cost is zero (LobSimCuda::new), so this is a
// no-op for callers that don't call upload_cost_per_lot_per_side.
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]
const float* __restrict__ cost_per_lot_per_side, // [n_backtests]
float* __restrict__ total_fees_per_b, // [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;
// Phase R2 (was F.3a): per-fill cost deduction
// (single-writer-per-block, plain += safe per feedback_no_atomicadd).
// Mirrors apply_fill_to_pos at resting_orders.cu:213-219 so the
// simple submit_market path matches the production resting-order
// path's economic model.
const float fill_cost = filled * cost_per_lot_per_side[b];
pos.realized_pnl -= fill_cost;
total_fees_per_b[b] += fill_cost;
}