feat(ml-backtesting): wire --latency-ns end-to-end + fixture (C12)

The in-flight machinery from C11 is now reachable from the harness +
CLI. New step_decision_with_latency on LobSimCuda:

  latency_ns == 0  → existing immediate-match path (submit_market_immediate
                      kernel fills against current book).
  latency_ns > 0   → host reads market_targets, converts each non-noop
                      into seed_limit_order(active=2, price=very-aggressive,
                      arrival_ts_ns = current + latency_ns). The
                      resting_orders kernel promotes + fills at arrival
                      time, so the order sees whatever book exists then —
                      not the book at decision time.

Aggressive-price heuristic: buy at 1e9 / sell at 0 — the marketability
check (price ≥ best ask for buy, ≤ best bid for sell) unconditionally
crosses at arrival; the kernel then walks book levels for the actual
fill price. This models "market order with latency" correctly because
slippage emerges from the book-walking at arrival, not from a limit
price boundary.

BacktestHarness gains a latency_ns field; harness::run() always calls
step_decision_with_latency now. Also calls sim.step_resting_orders
per event (with trade_signed_vol=0.0 — the Mbp10RawInput trade-flow
hookup is deferred to a trades-feed integration commit) so in-flight
orders get a chance to promote on every snapshot.

bin/fxt-backtest no longer ignores --latency-ns; the flag value
propagates through BacktestHarnessConfig.latency_ns into the kernel
path. Default stays at 100_000_000 (IBKR + Scaleway baseline per
spec §4). Setting --latency-ns 0 selects the legacy immediate path.

Adds latency_in_flight_miss GPU fixture: limit at 5510 submitted
active=2 at T=1s with arrival_ts=T+100ms. step_resting at T+50ms
sees no promotion (still in-flight). Book moves +5 against buyer
during the 100ms window. step_resting at T+150ms promotes the
limit and fills it at the WORSE post-move book (vwap=5505 vs the
original 5500 it would have hit without latency). Slot ends active=0.

11/11 GPU fixtures green on RTX 3050. 33 lib tests still green.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-05-18 09:20:03 +02:00
parent 1fb2decb79
commit 344d7a67d7
5 changed files with 197 additions and 27 deletions

View File

@@ -55,9 +55,10 @@ struct RunArgs {
/// Decide every Sth event (matches training stride; default 4).
#[arg(long, default_value_t = 4)]
decision_stride: usize,
/// Total latency budget in nanoseconds (IBKR+Scaleway baseline = 100ms).
/// Not currently consumed by the v1 immediate-match path; reserved for
/// follow-up commits that add resting-order in-flight promotion.
/// Total submission→fill latency in nanoseconds (IBKR + Scaleway
/// baseline = 100_000_000 = 100ms). Propagated to the resting-order
/// engine so in-flight orders wait for arrival_ts_ns before crossing.
/// 0 = immediate-match path (legacy).
#[arg(long, default_value_t = 100_000_000)]
latency_ns: u32,
/// Target annual volatility in price-unit lot-equivalents
@@ -147,9 +148,9 @@ fn run(args: RunArgs) -> Result<()> {
annualisation_factor: args.annualisation_factor,
max_lots: args.max_lots,
max_events: args.max_events,
latency_ns: args.latency_ns,
};
let _ = args.latency_ns; // reserved for follow-up resting-limit work
std::fs::create_dir_all(&args.out)
.with_context(|| format!("create out dir {}", args.out.display()))?;

View File

@@ -30,6 +30,12 @@ pub struct BacktestHarnessConfig {
pub max_lots: u16,
/// Max number of inference inputs to consume. 0 = exhaust loader.
pub max_events: u64,
/// Total submission→fill latency in nanoseconds. 0 = immediate
/// (legacy path). 100_000_000 = 100ms IBKR + Scaleway baseline.
/// When non-zero, the orchestrator submits aggressive IOC limits
/// with `active=2`/`arrival_ts_ns = current + latency_ns` so the
/// book can move under them during the in-flight window.
pub latency_ns: u32,
}
pub struct BacktestHarness {
@@ -96,16 +102,24 @@ impl BacktestHarness {
// Apply snapshot to every backtest's book.
self.sim.apply_snapshot(&raw.bid_px, &raw.bid_sz, &raw.ask_px, &raw.ask_sz)?;
// Process resting orders (in-flight promotion, queue decay,
// marketability check, stop triggers, OCO). Trade-flow signal
// is currently unavailable from Mbp10RawInput (deferred to a
// trades-feed integration); pass 0.0 so only the price-cross
// marketability branch fires.
self.sim.step_resting_orders(raw.ts_ns, 0.0)?;
// At decision-stride boundaries: run trunk inference + sim decision.
if self.event_count % stride == 0 {
self.trunk.update_input_buffers(&raw)?;
let (probs, _proj) = self.trunk.perception_forward_captured()?;
self.sim.broadcast_alpha(&probs)?;
self.sim.step_decision(
self.sim.step_decision_with_latency(
raw.ts_ns,
self.cfg.target_annual_vol_units,
self.cfg.annualisation_factor,
self.cfg.max_lots,
self.cfg.latency_ns,
)?;
self.decision_count += 1;
total_decisions += 1;

View File

@@ -405,24 +405,49 @@ impl LobSimCuda {
Ok(())
}
/// Run the C7 decision pipeline:
/// 1. decision_policy_default kernel — alpha × per-horizon ISV-Kelly
/// → market target (side, size) per backtest + open_horizon_mask
/// attribution if currently flat.
/// 2. submit_market_immediate — execute the targets against the book.
/// 3. pnl_track_step — detect close → emit TradeRecord.
/// 4. isv_kelly_update_on_close — for closed trades, update per-horizon
/// EMAs + variance + recent_sharpe using the realised return per lot.
///
/// The decision kernel reads `open_horizon_mask_d` for new opens. The
/// close-time mask is captured from `Pos.open_horizon_mask` BEFORE the
/// matching kernel zeros it; here we snapshot via a small host copy.
/// Backward-compat wrapper for the no-latency immediate-match path.
/// Equivalent to `step_decision_with_latency(.., 0)`.
pub fn step_decision(
&mut self,
current_ts_ns: u64,
target_annual_vol_units: f32,
annualisation_factor: f32,
max_lots: u16,
) -> Result<()> {
self.step_decision_with_latency(
current_ts_ns,
target_annual_vol_units,
annualisation_factor,
max_lots,
0,
)
}
/// Run the C7 decision pipeline with optional latency simulation:
/// 1. decision_policy_default kernel — alpha × per-horizon ISV-Kelly
/// → market target (side, size) per backtest + open_horizon_mask
/// attribution if currently flat.
/// 2a. If latency_ns == 0: submit_market_immediate kernel fills
/// against the current book.
/// 2b. If latency_ns > 0: each non-noop target is converted host-side
/// to an in-flight aggressive Limit (active=2, price=very-high
/// for buy / very-low for sell so the marketability check
/// always crosses, arrival_ts_ns = current + latency_ns).
/// The resting_orders kernel promotes + fills at arrival time
/// against the (possibly-moved) book.
/// 3. pnl_track_step — detect close → emit TradeRecord (immediate path only;
/// in-flight path defers close detection to step_resting_orders).
/// 4. isv_kelly_update_on_close — for closed trades, update per-horizon
/// state. Currently runs only on the immediate path; the latency
/// path triggers it via step_resting_orders → step_pnl_track once
/// the fill lands.
pub fn step_decision_with_latency(
&mut self,
current_ts_ns: u64,
target_annual_vol_units: f32,
annualisation_factor: f32,
max_lots: u16,
latency_ns: u32,
) -> Result<()> {
// Step 1: decision kernel writes market_targets + open_horizon_mask.
let cfg = LaunchConfig {
@@ -469,17 +494,24 @@ impl LobSimCuda {
let prev_pos_per_block = self.snapshot_position_lots()?;
let prev_mask_per_block = self.snapshot_open_horizon_mask()?;
// Step 3: execute targets against book.
let mut launch = self.stream.launch_builder(&self.submit_market_fn);
unsafe {
launch
.arg(&self.books_d)
.arg(&self.market_targets_d)
.arg(&mut self.pos_d)
.arg(&n)
.launch(cfg)?;
if latency_ns == 0 {
// Step 3a (legacy, no-latency): execute immediately.
let mut launch = self.stream.launch_builder(&self.submit_market_fn);
unsafe {
launch
.arg(&self.books_d)
.arg(&self.market_targets_d)
.arg(&mut self.pos_d)
.arg(&n)
.launch(cfg)?;
}
self.stream.synchronize()?;
} else {
// Step 3b (latency path): convert each non-noop market_target
// to an in-flight aggressive Limit. Read targets back to host,
// submit one seed_limit_alloc per backtest.
self.dispatch_latent_market_orders(current_ts_ns, latency_ns)?;
}
self.stream.synchronize()?;
// Step 4: pnl_track — detect close, emit TradeRecord.
let cap = crate::lob::TRADE_LOG_CAP as i32;
@@ -535,6 +567,64 @@ impl LobSimCuda {
Ok(())
}
/// For each backtest whose market_targets[b] is a non-noop, allocate
/// an in-flight LimitSlot with arrival_ts_ns = current + latency_ns.
/// Price is set very-aggressive (mid ± 1000) so the marketability
/// check unconditionally crosses at arrival; this is the simplest
/// way to model a "market order with latency" — the slippage that
/// matters is which book the order arrives against, not the limit
/// price boundary.
fn dispatch_latent_market_orders(
&mut self,
current_ts_ns: u64,
latency_ns: u32,
) -> Result<()> {
let n = self.n_backtests;
let mut targets = vec![0i32; n * 2];
self.stream.memcpy_dtoh(&self.market_targets_d, targets.as_mut_slice())?;
for b in 0..n {
let side = targets[b * 2];
let size = targets[b * 2 + 1];
if side == 2 || size <= 0 {
continue; // no-op
}
// Find a free slot via the existing seed path (which sets
// overflow_flag if all slots are in use, surfaced as Err).
let mut placed = false;
for slot in 0..crate::lob::MAX_LIMITS {
if self.seed_limit_order(
b,
slot,
side as u8,
1, // Limit
2, // in-flight
0xFF,
if side == 0 { 1.0e9 } else { 0.0 },
size as f32,
0.0,
current_ts_ns + latency_ns as u64,
).is_ok() {
placed = true;
break;
}
}
if !placed {
// All 32 slots in use; record overflow on the Pos counter.
// (seed_limit_order errored on each attempt; here we'd want
// pos.submission_overflow++ but doing it via host is wasteful
// when the device-side path already increments naturally for
// in-kernel allocations. v1 simply drops the order silently
// and the test fixture for submission_overflow already
// exercises the device-side counter.)
tracing::warn!(
backtest_idx = b,
"dispatch_latent_market_orders: all limit slots in use; dropping"
);
}
}
Ok(())
}
fn merge_open_mask_into_pos(&mut self) -> Result<()> {
// Read open_horizon_mask, OR into pos[b].open_horizon_mask field, push back.
// Pos field layout: position_lots(4) vwap_entry(4) realized_pnl(4)

View File

@@ -0,0 +1,59 @@
{
"name": "latency_in_flight_miss",
"n_backtests": 1,
"comment": "Demonstrates the spec §4 latency model: a limit submitted with active=2 (in-flight) at ts=T waits for arrival_ts_ns. The book moves against the buyer during the in-flight window; on arrival, the limit is now marketable against a worse book and crosses at the new (worse) prices. Without the latency model, the same order would have filled at the original book.",
"events": [
{
"type": "snapshot",
"bid_px": [5499.75, 5499.50, 5499.25, 5499.00, 5498.75, 5498.50, 5498.25, 5498.00, 5497.75, 5497.50],
"bid_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
"ask_px": [5500.00, 5500.25, 5500.50, 5500.75, 5501.00, 5501.25, 5501.50, 5501.75, 5502.00, 5502.25],
"ask_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]
},
{
"type": "seed_limit",
"comment": "Submit a buy limit at 5510 (well above current ask 5500 — always marketable). active=2 means in-flight; arrival at T+100ms.",
"backtest_idx": 0,
"slot_idx": 0,
"side": "buy",
"kind": "Limit",
"active_state": 2,
"oco_pair": 255,
"price": 5510.00,
"size": 4.0,
"queue_position_init": 0.0,
"arrival_ts_ns": 1100000000
},
{
"type": "step_resting",
"comment": "ts=T+50ms — limit still in-flight (arrival is at T+100ms). Nothing happens.",
"ts_ns": 1050000000,
"trade_signed_vol": 0.0
},
{
"type": "snapshot",
"comment": "Book moves +5 against the buyer during the 100ms in-flight window.",
"bid_px": [5504.75, 5504.50, 5504.25, 5504.00, 5503.75, 5503.50, 5503.25, 5503.00, 5502.75, 5502.50],
"bid_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
"ask_px": [5505.00, 5505.25, 5505.50, 5505.75, 5506.00, 5506.25, 5506.50, 5506.75, 5507.00, 5507.25],
"ask_sz": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0]
},
{
"type": "step_resting",
"comment": "ts=T+150ms — limit promoted (active=2→1). Marketability check: price 5510 ≥ ask 5505, so cross at the WORSE post-move book. 4 lots fill at 5505.00 (ask top has 100), avg_px=5505.00.",
"ts_ns": 1150000000,
"trade_signed_vol": 0.0
}
],
"expected_pos": [
{
"position_lots": 4,
"vwap_entry": 5505.00,
"vwap_tol": 0.01,
"realized_pnl_tol": 0.001
}
],
"expected_limit_slot_states": [
{ "backtest_idx": 0, "slot_idx": 0, "active": 0 }
]
}

View File

@@ -463,3 +463,9 @@ fn fix_oco_one_cancels_other() {
fn fix_submission_overflow() {
run_book_fixture(Path::new("tests/fixtures/submission_overflow.json")).unwrap();
}
#[test]
#[ignore = "requires CUDA"]
fn fix_latency_in_flight_miss() {
run_book_fixture(Path::new("tests/fixtures/latency_in_flight_miss.json")).unwrap();
}