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>
149 lines
6.4 KiB
Plaintext
149 lines
6.4 KiB
Plaintext
// 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;
|
||
}
|