vike
Backtesting

Queue models

The position-in-queue estimators for resting maker limits on the tick/book replay path — what they observe, how each attributes a depth change, how a new order is seeded, and why the probabilistic arm's transcendentals come from one libm.

crates/vike-backtest/src/queue_model.rs opens with the defect it exists to fix: the frozen crossing model fills a resting limit the instant price touches it — as if the order were always first in the queue. That over-fills makers. On a real venue an order at price P fills only after the resting size ahead of it at P has been consumed by trades, and this module supplies that missing estimate — an independent reimplementation of a mechanism common to queue-aware simulators, with no code taken from any of them.

It is opt-in and replay-only: EngineParams::queue_model = Some(kind) activates it, None (the default) leaves every existing path byte-identical, and when active it gates only the run_ticks tick/book replay lanes. The bar path never consults it. The lane that consumes these estimators is the queue lane; this page is the estimators.

The composition rule

The header states the rule the engine wire-in enforces, and every model below lives inside it:

  • the existing fill model still decides the price condition — limit crossed or touched;
  • a strict price cross (a trade or quote through the level) fills in full — everyone at the level, the queue ahead included, was consumed;
  • a touch (an event exactly at the order's price) is queue-gated: a trade at P first consumes the estimated front, and only its excess beyond the front fills the order — in full when that excess covers the whole order, partially otherwise; a quote touch fills only once the front is already cleared;
  • recorded L2 BookUpdates drive on_depth_change at each tracked level, so cancellations ahead of the order shorten — or, for the probabilistic model, statistically shorten — the wait. That is exactly the depth-consumption channel the crossing model ignored.

The state and the seam

QueueState is two numbers. front_qty is the estimated resting quantity ahead of the order — the simulated order itself is never part of the market book. cum_trade_qty accumulates trade quantity already applied to the front so that a later observed depth decrease at the level is not double-counted, because the venue's book update reflecting those same trades arrives after the trade prints. Only the probabilistic model consumes it, and it is cleared once the echo is netted out by a decrease or superseded by an increase — a level that grew past its pre-trade size has demonstrably already absorbed the print.

QueueModel is the estimator seam, and its verbs are pure f64 state folds with no I/O and no clock: on_new_order — a new order joins the back of the level, so everything currently resting is ahead of it; on_trade — a trade at the order's price consumed liquidity from the front; on_depth_change — the level's resting quantity changed from old_qty to new_qty, where increases join the back and decreases are attributed front-versus-back per the model after netting out cum_trade_qty; and is_front_cleared, true when nothing is left ahead.

Two estimators

RiskAdverseQueueModel is the conservative bound. Its doc: the front shrinks only on hard evidence — trades at the order's price, or the level itself shrinking below the current front estimate, since a depth decrease can never leave more ahead of the order than the whole level. Cancellations behind the order are never credited to the front, so this is the pessimistic, slowest-fill bound.

ProbQueueModel splits a net depth decrease chg — the observed decrease minus the cum_trade_qty already applied by trades — between the queue ahead and behind by prob = f(back) / (f(back) + f(front)), the probability the cancellation came from behind. The front estimate becomes front − (1 − prob)·chg + min(back − prob·chg, 0) — the last term folds back-overflow, change the back could not have supplied, into the front — clamped to [0, new_level_qty]. ProbFunc is the weight: Power(n) is f(x) = x^n, where n = 1 splits pro-rata by size and a larger n biases attribution toward the usually larger back of the queue; Log is f(x) = ln(1 + x), sub-linear, so large backs saturate.

The probabilistic arm calls libm, not the platform

ProbFunc::f's doc is a hazard note. IEEE 754 requires + − × ÷ and sqrt to be correctly rounded and requires nothing of pow or log, so the method spellings call the platform's own libm, and two operating systems disagree in the last bit. Here that last bit decides a fill: f weights the front-versus-back attribution, which moves the estimated queue ahead of a resting maker, which moves whether a trade's excess reaches the order at all — a backtest could fill on one box and not on the other. So both arms spell libm::pow / libm::log. The default "prob_power" (Power(1.0)) escapes because that pow returns x unchanged, but "prob_log" and any "prob_power:N" with N ≠ 1 would otherwise diverge unconditionally — and a site portable only for one value of its parameter is portable by accident, not by design.

Selecting one

QueueModelKind is the EngineParams selector — RiskAdverse, ProbPower(f64), ProbLog — and build constructs the model once, held by the tracker for the whole replay. In a run profile the spellings, case-insensitive, are "risk_adverse", "prob_power" / "prob_power:N" (default N = 1) and "prob_log"; an unknown string fails the profile at load.

Seeding a new order

QueueTracker is the replay-scoped queue engine — one model plus per-symbol state, built by StrategyEngine::new when queue_model is Some and absent otherwise. QueueTracker::seed's doc gives the chain for a new order: the replayed book's level quantity at its price → the matching side of the last L1 quote → the configured seed_depth. The quote match is a tick-grid compare, not bit-exact equality: same_level quantizes with round-half-to-even and compares tick indices, so a strategy price computed arithmetically — mid − k·tick accumulating an ulp — still matches the level it sits on; with no grid at all it falls back to a few-ulp relative epsilon. EngineParams::queue_seed_depth's default of 0.0 seeds an empty queue, so an order with no observed size-ahead starts at the front — the old optimistic behaviour, for that one order.

Book events are folded through two calls: pre_depth snapshots the tracked levels' resting quantity before a book event is applied, and apply_depth folds the applied event into every tracked state. A dropped book — a gap marker — leaves states untouched; an unknown old quantity with a known new one applies the conservative clamp front = min(front, new), because a fresh anchor after a gap can only bound the front.

SymQueue's doc carries the identity rule both lanes share: states are keyed by real per-order identity — WorkingOrder::qid for untagged orders, the tag plus its current qid for maker quotes — never by a (side, price) fingerprint, so a cancel_all followed by a re-submit at the same price re-seeds at the back rather than adopting the cancelled order's advanced front.

On this page