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()))?;