vike
Backtesting

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.

On this page