vike
Backtesting

Latency models

Order latency on the tick/book replay path — the entry and response legs, the constant and recorded-series models, the venue-enforced holds, what the response leg delays and what it deliberately does not.

Without a latency model a simulated order is submitted, matched and cancelled with zero delay. crates/vike-backtest/src/latency.rs states the consequence in its header: the strategy sees a tick and its order is already resting at the matching engine on that same tick, and a cancel issued on the tick that would have filled it always wins. That gives a maker impossible reflexes and is, the header says, the single largest source of optimism left in the replay path after the queue model, its natural partner.

It is opt-in and replay-only: EngineParams::latency_model = Some(kind) arms it, None (the default) means no in-flight queue is ever built and every code path is byte-identical, and only StrategyEngine::run_ticks arms it — SimBroker::latency's doc says the gate is installed for the duration of a tick replay and taken out at the end, which is what structurally guarantees the bar engine and the vector kernel can never consult one.

Two legs

  • Entry latency — a local submit at T becomes visible to the matching logic at T + entry(). Everything the strategy sends — new orders, cancels, amends — is held in an in-flight queue keyed by that delivery timestamp.
  • Response latency — an exchange-side event at exch_ts reaches the strategy at exch_ts + response(). Fills are delivered to Strategy::on_fill late.

Both are signed nanoseconds, while the engine's own timestamps are epoch milliseconds. The wire-in keys its queue in nanoseconds (ts_ms × 1 000 000 + latency_ns) so a sub-millisecond latency is not silently truncated; it simply means the action lands on the next tick whose millisecond stamp has caught up. A negative entry latency encodes a recorded rejection: the request never reached the matching engine, so the engine drops the action outright and records it in SimBroker::dropped under "latency_reject" — the channel the zero-trade analyzer reads, so a calibrated series with a rejection burst cannot silently swallow a maker's orders and leave "why did my maker stop quoting" undiagnosable.

The two shipped models

LatencyModel is the seam — entry(ts, order) and response(ts, order), pure functions with no I/O, no clock and no interior mutation, so a replay is reproducible. LatencyOrder is the deliberately tiny descriptor a model may condition on: side, quantity, optional price.

ConstantLatency is fixed nanoseconds on both legs, the calibration-free baseline. Its doc draws a distinction worth keeping: entry_ns: 0 / response_ns: 0 reproduces zero-latency behaviour while still exercising the in-flight queue, so it is not the same thing as latency_model: None — an action submitted at T still applies only at the next drain, the next tick at ts >= T.

IntpOrderLatency interpolates from a recorded series of round trips — LatencyRow { req_ts, exch_ts, resp_ts } in epoch nanoseconds, exch_ts <= 0 marking a rejection. Lookup is a binary search for the bracketing pair and a linear interpolation between them, along a different abscissa per leg: the entry leg in req_ts over every row, the response leg in exch_ts over a second index with rejection rows excluded. Outside the series the nearest end row's latency is used, never an extrapolation, and rejections are not interpolated across — if either bracketing row is one, the nearer row's latency is returned verbatim, so a recorded rejection is never blended into a merely slow accept. An empty series is inert.

The in-flight queue

LatencyGate holds the built model and the queue. Every price tick drains everything whose delivery stamp has arrived, so the queue only ever holds the actions issued within one latency window; the due slice is sorted by (delivery_ns, seq), where seq is a monotonic submission counter that keeps a batch sharing one delivery stamp in the order the strategy issued it — a cancel then a re-submit must not swap. InFlight::seq's doc names the precondition: both shipped models ignore the order descriptor, so that holds; a user model that gives a bare cancel a different entry latency than the sized re-submit after it can legitimately reorder the pair, and a maker whose requote's cancel lands last is left with its tag pulled rather than re-priced.

There is deliberately no end-of-run flush of the order queue: an action undelivered when the tape ends never reached the venue and must not retroactively apply. The response leg is flushed — flush_deferred_fills delivers every still-held fill, because a backtest must not end with fills the strategy was never told about.

What the response leg delays, precisely

Only the strategy's view is delayed, never the exchange's own state. SimBroker::apply_fill folds a fill into the position, cash and the equity curve the instant it matches, and BacktestResult is computed from that truth. What the response leg holds back is the Strategy::on_fill callback and the strategy-visible shadow position — a per-symbol signed size advanced only by delivered fills, which Broker::position and HftBroker::position read while the gate is armed. SimBroker::shadow_pos's doc says why it exists: a maker that polls its inventory rather than accumulating it in on_fill used to see every fill with zero latency, which made the leg inert for the spread maker, whose inventory skew and reservation price are driven by exactly that poll.

Still bypassing the model, by design: SimBroker::position_of, pending_of, equity_now / Broker::equity, drawdown_now and the target-percent verbs read exchange truth synchronously — they are the accounting surface, and shadowing them would make on_stop disagree with the result. A strategy that sizes off equity() still reacts to a fill with zero response latency.

An order in flight is invisible to pending_of until delivered. SimBroker::in_flight_of(symbol) is the read that sees it — always 0 when the gate is not armed — so a strategy checks pending_of(s).is_empty() && in_flight_of(s) == 0 rather than re-firing a duplicate on every tick of the entry-latency window. The pre-trade leverage cap does count in-flight market orders (LatencyGate::in_flight_market_signed): a local risk check must model the local view, or every submit inside one latency window would be granted the full leverage room in exactly the regime the model exists to make pessimistic.

Venue-enforced holds

Some delays are venue rules, not network estimates. VENUE_HOLD_POLYMARKET_UPDOWN_MS is 250 ms, verified against the venue's published order-lifecycle documentation on 2026-07-23: on exactly the crypto up/down markets the venue holds the order, validates it again, and only then matches or books it. VENUE_HOLD_POLYMARKET_SPORTS_GAME_MS is 3 000 ms, an independent mechanism measured live the same day — 400 of 400 open sports markets carrying a game_start_time declared seconds_delay: 3 — and its doc warns it is not universal, a later sweep having found the esports book declaring 1 000 ms. Both literals are owned by vike_model::venue_hold.

LatencyModelKind::venue_hold_ms(ms) models a hold on the entry leg only — a hold delays when the venue acts, not when the strategy hears about it — and a deployment pays it on top of its wire round trip. Better than a constant, since the hold is a per-market property and one tape can carry both kinds: SimBroker::taker_hold_table_ns resolves each symbol's SymbolProperties::taker_hold_ms once at run start through the same point-in-time properties seam the fill grid uses, and LatencyGate::submit adds it to the model's entry leg; an empty or all-zero table is byte-identical to no table. Paired with the L2 fill model, the venue's "matched or booked" second validation falls out: decided on the book at T, delivered at T + hold, matched against the book as it then stands, resting if it no longer crosses.

In a profile

A run profile exposes the constant model as two millisecond knobs in [engine], tick mode only: order_latency_ms is the entry leg on every order action — place, modify, cancel — and fill_latency_ms the response leg before the strategy learns of a fill. Either > 0 builds a LatencyModelKind::constant; both 0 is None, byte-identical. A recorded series is reached through EngineParams directly.

On this page