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:
@@ -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()))?;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
59
crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json
vendored
Normal file
59
crates/ml-backtesting/tests/fixtures/latency_in_flight_miss.json
vendored
Normal 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 }
|
||||
]
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user