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.
A fill model answers one question: given a resting order and the event that just arrived, at what
raw price does it fill, if at all. crates/vike-fills/src/fill_model.rs ships three tiers,
selected by the data available, and its header states the boundary every tier respects: each
returns a raw price, and the engine still applies adverse_fill_price slippage and routes the
fill through compute_fill, so parity is preserved. The models live in vike-fills rather than
in the backtester because the paper exchange is built from the
same primitives.
The three tiers
BarFillModel — no ticks. It delegates to order_fill_price, the fill-trigger oracle in
vike-model that the live conditional-order emulator shares, and is the pre-tick behaviour.
TickFillModel — L1 quote bars. Market orders cross the real spread — buy at the ask, sell at
the bid — whenever the bar carries bid and ask; single-quote-tick bars (high == low) trigger
resting orders off the quote side; consolidated bars delegate to the bar oracle.
L2BookFillModel — L1 plus an L2 book. A taker is priced by consuming the resting book,
not by reading one tick price. The header calls it the R8 tier the sim-broker docs had named as
the follow-up since the queue model landed.
Why the book tier exists
The header argues it as an engine defect, not one strategy's bug. Every tier above the L2 one
prices a taker at a price nobody was offering: TickFillModel fills a market buy at
bar.ask — the top of book, at any size — and on a trade-tick tape with no quotes it falls
through to the print, the price at which somebody else already took the liquidity. A taker can
obtain neither. The error is one-directional, always in the strategy's favour, and it scales with
size, because top-of-book is exactly the cheapest share on the ladder.
The measurement: on the Polymarket btc-updown-5m tape over 835 entries (cheap_np_depth), the
tape print averaged 0.2683 while the resting best ask at the same instant averaged 0.3063,
and in 738 of 835 cases there was no resting ask at the printed price at all — roughly 4 cents
per share on a strategy whose whole edge bar is 5.5 cents. Any strategy that takes liquidity on
the tick path is mispriced the same way, which is why the fix lives in the engine.
What the L2 tier changes, and what it deliberately does not
L2BookFillModel's doc splits the question in two. When an order fills is unchanged:
TickFillModel decides it, including the stop and trailing trigger arithmetic and its extreme
bookkeeping. What price it fills at is re-derived by book_taker_price: the VWAP of
consuming the resting book for the order's own size, best level outward. A resting limit
additionally caps the walk at its own limit price, so it can only be filled by liquidity it
would genuinely have crossed.
Two properties follow. No book, no change: with no book this tier is TickFillModel, byte
for byte, so mixing a book-carrying symbol and a bookless one in a single run is well-defined
rather than silently lossy. And insufficient depth does not fill: when the displayed book
cannot cover the order's size — within its limit, if it has one — the order does not fill on
this event and stays resting, waiting for liquidity. That is the one behavioural difference a
strategy will notice, and the honest one; the alternative is to fill size the market never
showed. It also gives the tier the venue semantics it was built for: an order that is marketable
when the strategy decides and no longer marketable when the matching engine looks at it rests
on the book rather than filling or vanishing. Composed with a
latency model, the "decide at T, match at T + Δ" rule falls
out of machinery that already exists.
book_taker_price itself is hoisted to vike_model::book_taker_price. Its doc gives the
reason: the taker-price law is a property of an order book, not of a simulation, and the live
path needs the same rule — pre-trade sizing, a paper executor on a live feed, comparing realised
fills against the model. vike-backtest is a sibling of the live path, not below it, so the
shared home in vike-model is what lets both sides share one definition instead of drifting
apart silently.
Selecting a tier
FillModelKind in crates/vike-backtest/src/engine/sim_broker.rs is the selector: Bar (the
EngineParams default), Tick, and L2Book. The L2Book variant's doc states its requirement
and its guarantee: it needs a tape carrying Tick::Book events, degrades to Tick wherever no
book exists, and is reached only by asking for it, so selecting Bar or Tick is byte-identical
to the engine before the variant existed. The book it prices from is SimBroker's per-symbol
replay book, which is empty except while run_ticks replays a tape carrying book events and is
folded under L2Book's delta law — see tick replay.
Pair the book walk with zero flat slippage
The walk is the slippage. FillModelKind::L2Book's doc and EngineCfg::fill_model's both
say to set slippage = 0.0 beside it: stacking the flat adverse_fill_price haircut on top
would charge the same cost twice.
In a run profile the knob is fill_model = "l2book" in
[engine] — the one accepted value, case-insensitive, tick mode only. Absent means Tick,
byte-identical. EngineCfg::fill_model_kind treats any other spelling as a fail-fast validation
error at load, for a reason its doc names: a typo like "l2_book" must not silently select the
optimistic Tick model and undo the depth-cap it was meant to enable. Whether a resting limit
at a touched price has actually reached the front of the queue is a separate opt-in on the same
lane — the queue lane.
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.
Fill resolution
What happens when several resting orders trigger inside one bar — adverse-first ordering, the stop/take-profit bracket cap, and the both-hit count the result carries so the ambiguity is reported rather than hidden.