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 driveon_depth_changeat 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.
The queue lane
The opt-in queue-position fill lane of the tick replay — the queue-gated twins of the frozen per-tick fill pass, entered only when a queue model is configured, where a price touch no longer fills a resting limit on its own.
Fill models
The tiered fill-price models — bar, L1 spread-crossing and the L2 book walk — selected by the data a tape carries, with the measurement that shows why the book tier exists.