From 344d7a67d758a559fc1c33a4eca66a4d573cefdd Mon Sep 17 00:00:00 2001 From: jgrusewski Date: Mon, 18 May 2026 09:20:03 +0200 Subject: [PATCH] feat(ml-backtesting): wire --latency-ns end-to-end + fixture (C12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- bin/fxt-backtest/src/main.rs | 9 +- crates/ml-backtesting/src/harness.rs | 16 ++- crates/ml-backtesting/src/sim.rs | 134 +++++++++++++++--- .../fixtures/latency_in_flight_miss.json | 59 ++++++++ .../ml-backtesting/tests/lob_sim_fixtures.rs | 6 + 5 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json diff --git a/bin/fxt-backtest/src/main.rs b/bin/fxt-backtest/src/main.rs index e09c8d045..c52d2cf2b 100644 --- a/bin/fxt-backtest/src/main.rs +++ b/bin/fxt-backtest/src/main.rs @@ -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()))?; diff --git a/crates/ml-backtesting/src/harness.rs b/crates/ml-backtesting/src/harness.rs index 26bc6c715..c3162940c 100644 --- a/crates/ml-backtesting/src/harness.rs +++ b/crates/ml-backtesting/src/harness.rs @@ -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; diff --git a/crates/ml-backtesting/src/sim.rs b/crates/ml-backtesting/src/sim.rs index e2d0ce86a..d5a1922f1 100644 --- a/crates/ml-backtesting/src/sim.rs +++ b/crates/ml-backtesting/src/sim.rs @@ -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) diff --git a/crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json b/crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json new file mode 100644 index 000000000..602753265 --- /dev/null +++ b/crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json @@ -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 } + ] +} diff --git a/crates/ml-backtesting/tests/lob_sim_fixtures.rs b/crates/ml-backtesting/tests/lob_sim_fixtures.rs index 95a724777..fdbe10ee2 100644 --- a/crates/ml-backtesting/tests/lob_sim_fixtures.rs +++ b/crates/ml-backtesting/tests/lob_sim_fixtures.rs @@ -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(); +}