← Back to articles
July 16, 2026
5 min read

Fill simulation: the ladder from close-price fantasy to queue-aware reality

Fill simulation: the ladder from close-price fantasy to queue-aware reality
#fill simulation
#backtest
#limit orders
#queue position
#partial fills
#market making
#execution
#market microstructure

Your backtest contains two models: a model of your alpha and a model of your fills. Most people spend 95% of their effort on the first and inherit the second from whatever framework they happen to use. This is backwards. A mediocre signal with an honest fill model produces a mediocre but real PnL estimate. A great signal with fill_price = candle.close produces a number that is not an estimate of anything — it is the output of an assumption you never examined.

For taker strategies on liquid instruments, the fill model is a correction term. For anything involving resting limit orders — market making, passive entries, post-only rebate harvesting — the fill model is the strategy. Whether you get filled, when, in what quantity, and conditional on what subsequent price move determines the sign of the PnL, not just its magnitude.

In Backtest-live parity we mapped the full taxonomy of backtest-live divergences and rated execution divergence 5/5 severity — the worst class. That article gave fill simulation three coarse accuracy levels and moved on. This one is the depth companion: the complete ladder, rung by rung, with the order lifecycle as a state machine, fill-probability bounds you can actually compute, and a measured experiment showing exactly where on the ladder a "profitable" maker strategy dies.

The ladder

Five-rung fill simulation fidelity ladder

Each rung requires more data and more code, and each rung removes a specific systematic bias. The rungs are ordered by what they get wrong, not just by cost.

Rung 0: close-price fill

fill_price = bar.close

The order fills instantly, fully, at the close of the bar that generated the signal. The signal was computed from that same close, so you are trading on data that did not exist when the price was printed: look-ahead bias wearing an execution costume. Any strategy with turnover looks good here.

Rung 1: next-bar open

fill_price = next_bar.open

The minimum honest model for taker logic on bars. The signal is computed on bar tt, the fill happens at the first observable price of bar t+1t+1. This kills the look-ahead but still assumes zero spread, zero impact, infinite liquidity at the open print, and 100% fill certainty. For limit orders it degenerates into touch-fill logic (more on why that is poison below).

Rung 2: spread + fixed slippage

half_spread = mid * spread_bps / 2e4
slip        = mid * slippage_bps / 1e4
fill_price  = mid + side * (half_spread + slip)   # side: +1 buy, -1 sell

Now every taker trade pays half the spread plus a calibrated constant. This is the first rung where a high-turnover strategy can die in backtest — which is the point. The residual error: slippage is not constant. It scales with order size relative to displayed depth and explodes exactly when your strategy most wants to trade. A fixed 5 bps is an average over regimes; your strategy does not trade the average regime.

Rung 3: L2 depth-walk for market orders

With order book snapshots you stop guessing slippage and compute it. A market buy of size QQ walks the ask side level by level; the fill price is the volume-weighted average across consumed levels:

Pfill(Q)=1Qipimin ⁣(qi,  Qj<iqj)P_{\text{fill}}(Q) = \frac{1}{Q}\sum_{i} p_i \cdot \min\!\left(q_i,\; Q - \textstyle\sum_{j<i} q_j\right)

def depth_walk(levels: list[tuple[float, float]], qty: float) -> tuple[float, float]:
    """levels: [(price, size), ...] sorted best-first. Returns (vwap, filled_qty)."""
    remaining, cost = qty, 0.0
    for price, size in levels:
        take = min(size, remaining)
        cost += take * price
        remaining -= take
        if remaining <= 0:
            break
    filled = qty - remaining
    return (cost / filled if filled > 0 else float("nan"), filled)

Two corrections make this materially more honest. First, latency: walk the book as it stood Δt\Delta t after your decision timestamp, where Δt\Delta t is your measured signal-to-exchange latency — the book you saw is not the book you hit. Second, partial marketable fills: if displayed depth within your limit-through price is less than QQ, the model must return a partial fill and leave a resting remainder, which hands the problem to the state machine below.

The residual error at rung 3 is impact and refill: you consume the book as a static object, but real books partially replenish (and real counterparties react). For clip sizes below a few percent of top-of-book depth this error is small; for larger clips you need an impact model (Almgren-Chriss 2001) layered on top.

Rung 4: probabilistic queue-position fills for limit orders

Rungs 0-3 answer "at what price does my aggressive order fill." Rung 4 answers the harder question: does my passive order fill at all — and it is the only rung that can price a maker strategy. A resting limit order at price pp fills when the cumulative traded volume at pp exceeds the queue volume that was ahead of it. That requires tracking your position in a FIFO queue you cannot directly observe.

The queue-position estimation machinery — initial position, update rules on trades vs. cancels, the f(x)f(x) probability family for allocating unobserved cancellations — is the primitive we built in Queue inside the wall. I will not re-derive it here; the simulator consumes it. What rung 4 adds is the fill decision rule on top of the estimate, covered in the fill-probability section below.

Why this rung matters is quantified in the literature: Moallemi and Yuan (2016), "A Model for Queue Position Valuation in a Limit Order Book," show that for large-tick instruments the economic value of a front-of-queue position versus back-of-queue is comparable to the half-spread — the same order of magnitude as the entire theoretical edge of a market-making strategy. A fill model that ignores queue position doesn't misestimate a maker's PnL; it estimates a different strategy's PnL.

There is a rung 5 — full agent-based simulation where the market reacts to your orders (queue-reactive models in the sense of Huang, Lehalle and Rosenbaum 2015; multi-agent frameworks like ABIDES, Byrd et al. 2020). Historical replay, even queue-aware, assumes your order changes nothing about everyone else's behavior. That assumption is fine at retail size and increasingly wrong as your quotes become a visible fraction of the level. Rung 5 is out of scope here; know that the ladder does not end at 4.

Partial fills as a state machine

Rungs 0-2 can pretend an order is a function call: submit, get price, done. From rung 3 up, an order is a process with a lifecycle, and the simulator must model it as a state machine or it will silently mishandle the cases that matter most.

Order lifecycle state machine with queue position annotations

enum OrderState {
    PendingNew,                                  // sent, not yet acked (latency window)
    Resting     { remaining: f64, q_ahead: f64 }, // in book, queue position estimated
    PartialFill { remaining: f64, q_ahead: f64 }, // some qty done, rest still queued
    PendingAmend,                                 // amend in flight
    PendingCancel,                                // cancel in flight
    Filled,
    Canceled    { filled_qty: f64 },              // may be partially filled at cancel time
    Rejected,
}

The transitions carry the economics:

  • PendingNewResting: the order joins the queue behind everything present at ack time, not at decision time. Your queue position is seeded with the level volume as of t+Δtackt + \Delta t_{\text{ack}}. Simulators that seed at decision time systematically overestimate queue priority — by exactly the volume that arrived during your latency window, which is most volume during bursts.
  • RestingPartialFill: a trade at your level larger than the queue ahead fills you partially. The remainder keeps its (now front-of-queue) position. Partial fills are not noise — they are information: getting filled 0.3 of 1.0 and watching price bounce away is a different PnL event than a full fill, and a maker's inventory process is built from these fragments.
  • RestingPendingAmendResting: the trap. On virtually every crypto venue, an amend is a cancel/replace — Binance's cancelReplace is atomic against double-execution but returns a new order ID at the back of the queue. Even venues with native modify semantics (CME Globex) preserve time priority only for a quantity decrease; a price change or quantity increase forfeits it. So in the simulator: any price amend resets q_ahead to the full current level volume. A quoting engine that re-pegs every 500ms is not "maintaining a quote" — it is perpetually re-entering at the back of the queue, and its realistic fill profile is almost pure adverse selection. Combined with the Moallemi-Yuan result: re-quoting has a price, and the price is your queue position.
  • PendingCancelPartialFillCanceled: cancels take latency too. In the window between deciding to pull a quote and the cancel reaching the matching engine, you can still get filled — and those fills are the worst fills you will ever receive, because the reason you were pulling the quote is that the market was about to run you over. A simulator without cancel latency deletes precisely the most toxic fills from your history.

The state machine is also what makes the simulator's accounting honest: fees accrue per fill event, inventory updates per fill event, and time-in-state statistics (how long orders rest before filling vs. being canceled) become directly comparable with live logs — which is what the calibration loop at the end feeds on.

Limit-fill probability: three models and a bracket

Given an order resting at price pp (say a bid), when does the simulator declare a fill? Three decision rules, in increasing honesty:

1. Touch-fill (naive). Fill if the price touches your level: lowtp\text{low}_t \le p. This is a first-passage-time rule, and its failure was measured a quarter-century ago: Lo, MacKinlay and Zhang (2002), "Econometric Models of Limit-Order Executions" (Journal of Financial Economics 65), fit survival models to actual limit order data and conclude that hypothetical executions constructed from first-passage times "are very poor proxies for actual limit-order executions." The failure mode is structural: when price touches your level and bounces, the touch consumed the front of the queue — the traders who quoted before you. Touch-fill awards you their fills. Worse, it awards exactly the good fills (touch-and-bounce is the profitable scenario for a maker), while your real fill set is skewed toward touch-and-run-through — the adversely selected ones.

2. Trade-through (conservative bound). Fill only if price trades strictly through your level: lowt<pϵ\text{low}_t < p - \epsilon, or on tick data, cumulative traded volume at pp exceeds the entire level. If price traded through, the whole queue at pp was consumed, so you were filled regardless of position. This never awards a fill you wouldn't have gotten. Its bias is the mirror image of touch-fill's: it denies you every fill where the queue depleted to your position without full penetration, and the fills it does grant are disproportionately the run-through (adversely selected) ones. A maker backtest under trade-through is a stress test, not an estimate.

3. Queue-depletion estimate. Track cumulative traded volume MtM_t at your price from the trade tape and canceled volume CtC_t inferred from L2 deltas. Your estimated queue ahead:

Q^ahead(t)=max ⁣(Q0MtϕCt,  0)\hat{Q}_{\text{ahead}}(t) = \max\!\Big(Q_0 - M_t - \phi \cdot C_t,\; 0\Big)

where Q0Q_0 is the level volume at (ack-time) order entry and ϕ[0,1]\phi \in [0,1] is the fraction of cancellations assumed to come from ahead of you — the knob whose principled form (the f(x)f(x) family) is derived in the queue-position article. Fill begins when Q^ahead=0\hat{Q}_{\text{ahead}} = 0; your filled quantity is the traded volume in excess of it, which yields partial fills naturally:

filled(t)=min ⁣(S,  max(MtQ0+ϕCt,0))\text{filled}(t) = \min\!\Big(S,\; \max(M_t - Q_0 + \phi\, C_t,\, 0)\Big)

Setting ϕ=1\phi = 1 (all cancels ahead of you) gives the optimistic edge of this model; ϕ=0\phi = 0 the pessimistic edge. When you have only sparse L2 snapshots and no trade tape at the level — common with 100ms-throttled crypto feeds — you can fall back on a model-based prior: Cont, Stoikov and Talreja (2010), "A Stochastic Model for Order Book Dynamics" (Operations Research 58), model each price level as a birth-death queue and compute, via Laplace transforms, the probability that an order at the bid executes before the mid moves, conditional on current queue sizes. It is an analytic fill-probability oracle: crude relative to tape replay, far better than touch-fill, and cheap enough to evaluate inside a hot backtest loop.

The bracket discipline

The three rules are not competitors — they are an ordering:

fillstrade-through    fillsqueue    fillstouch\text{fills}_{\text{trade-through}} \;\subseteq\; \text{fills}_{\text{queue}} \;\subseteq\; \text{fills}_{\text{touch}}

which induces a PnL bracket. Run every maker backtest three times and report the interval:

PnLtrade-through    PnLlivePnLtouch\text{PnL}_{\text{trade-through}} \;\le\; \text{PnL}_{\text{live}} \lesssim \text{PnL}_{\text{touch}}

(the upper inequality is approximate — touch-fill can misrank strategies, not just inflate them, because it hands you counterfactual good fills). The decision rule that follows: a maker strategy is deployable only if it survives at the conservative edge of the bracket, and the bracket is narrow enough that the point estimate means something. A strategy showing +9ktouch/9k touch / -3k trade-through has a 12kwidebracket;youknownothingexceptthatyoursimulatorsassumptiondominatesyouralpha.Astrategyshowing+12k-wide bracket; you know nothing except that your simulator's assumption dominates your alpha. A strategy showing +2.1k / +$0.4k is telling you something real.

The experiment: walking one maker strategy down the ladder

Maker strategy PnL collapsing rung by rung down the fill-model ladder

Take one deliberately plain maker — symmetric quotes at best bid/ask, fixed 0.05 BTC clips, inventory-capped at ±0.5 BTC with taker flattening at the cap. One month of BTCUSDT perpetual data: 1m bars for the low rungs, 100ms L2 diffs plus the trade tape for the high rungs; maker fee 1.0 bps, taker 4.0 bps. Same signal code at every rung (shared core, so the only variable is the fill model). The numbers below are one representative run of ours — your magnitudes will differ by venue, month, and size; the shape will not:

Rung Fill model Quote fill rate Fills Month PnL Verdict
0 close-fill 98% 41,200 +$14,800 fantasy
1 next-bar-open / touch on 1m bars 89% 37,400 +$9,600 fantasy with latency
2 touch + spread & fixed slippage on taker flattens 89% 37,400 +$7,100 costs modeled, fills still fictional
3 + L2 depth-walk on taker flattens 89% 37,400 +$6,400 exits honest, entries still fictional
4a trade-through (conservative) 21% 8,900 -$3,900 stress bound
4b queue-depletion, ϕ\phi calibrated 37% 15,600 -$700 best estimate
4c queue-depletion, ϕ=1\phi = 1 (optimistic) 44% 18,700 +$1,900 upper edge
live shadow run, same month 35% 14,100 -$1,150 reality

Read the table top to bottom and watch where the strategy dies. It is not rung 2 — fees and slippage shave 26% off and the strategy still looks robustly profitable. It dies between rung 3 and rung 4, and it dies for a reason no cost model can capture: fill selection. Touch-fill granted 37,400 fills of which the majority were touch-and-bounce — pure spread capture. The queue-aware model deleted 58% of those fills, and the fills it deleted were disproportionately the profitable ones: when the level is tapped lightly, the queue ahead of a re-quoting retail-latency maker absorbs everything. The fills that survive into rung 4 are skewed toward level-clearing sweeps — the fills where the price is already moving through you. Fill rate dropped 2.4x; PnL flipped sign. That asymmetry — losing the good fills, keeping the bad — is adverse selection made mechanically explicit, and it is invisible at every rung below 4.

Note also what the bracket did: [-3,900,+3,900, +1,900] straddles zero with the calibrated estimate at -700andliveat700 and live at -1,150. The simulator did not nail live PnL to the dollar — it got the sign, the magnitude, and the fill rate within 2 points. That is what a fill model is for. The rung-1 backtest missed live PnL by $10,750 on a strategy whose entire monthly gross edge was a few thousand dollars: the fill-model error was roughly 3x the size of the alpha. Hence the thesis: your fill model is a bigger assumption than your alpha.

One caveat for the low rungs: if you must live on bar data (rungs 0-2), at minimum resolve intrabar ambiguity with adaptive drill-down — drilling from 1m to 1s/100ms/trades where SL, TP, or quote levels fall inside the bar range. Drill-down fixes sequencing errors (which level was hit first) but cannot fix queue errors; it is the data-resolution companion to this article, not a substitute for rung 4.

The calibration loop: closing against live fills

A rung-4 simulator has free parameters — ϕ\phi, latency Δt\Delta t, cancel-latency, the level-refill assumption. Uncalibrated, it is just a differently shaped guess. The loop that turns it into an instrument:

1. Log everything live. Every order event with exchange timestamps: submit, ack, each partial fill, amend acks, cancel acks. Plus the L2 state at submit time. This is the same logging discipline backtest-live parity demands for its DivergenceMonitor — the fill-model calibration is that monitor's deepest layer.

2. Replay the same orders through the simulator. Feed the recorded market data and the recorded order instructions (not the fills) into the simulator. Now you have paired outcomes: for each live order, a simulated fate.

3. Compare distributions, not averages, in buckets. A single global fill-rate match can hide compensating errors (too optimistic in quiet regimes, too pessimistic in bursts — netting to "calibrated"). Bucket by the drivers:

import numpy as np
from scipy.stats import ks_2samp

def calibration_report(pairs, bucket_key):
    """pairs: [{'bucket':…, 'live_filled':bool, 'sim_filled':bool,
                'live_ttf':float|None, 'sim_ttf':float|None}, …]"""
    out = {}
    for b in sorted({p['bucket'] for p in pairs}):
        grp = [p for p in pairs if p['bucket'] == b]
        live_fr = np.mean([p['live_filled'] for p in grp])
        sim_fr  = np.mean([p['sim_filled']  for p in grp])
        live_ttf = [p['live_ttf'] for p in grp if p['live_ttf'] is not None]
        sim_ttf  = [p['sim_ttf']  for p in grp if p['sim_ttf']  is not None]
        ks = ks_2samp(live_ttf, sim_ttf) if len(live_ttf) > 20 and len(sim_ttf) > 20 else None
        out[b] = {
            'n': len(grp),
            'fill_rate_live': live_fr,
            'fill_rate_sim':  sim_fr,
            'fill_rate_gap':  sim_fr - live_fr,      # signed: + means sim optimistic
            'ttf_ks_pvalue':  ks.pvalue if ks else None,
        }
    return out

Two statistics per bucket: the signed fill-rate gap (simulator minus live) and a KS test on time-to-fill distributions among filled orders. The time-to-fill comparison is the sharp one — a simulator can match fill rates while filling at systematically wrong times, which corrupts every downstream inventory and adverse-selection statistic. This is precisely the lesson of Lo-MacKinlay-Zhang's survival-analysis framing: execution is a time-to-event problem, so validate it as one.

4. Fit the knobs, in order of identifiability. Latency first (directly measured from ack timestamps — not fitted). Then ϕ\phi by minimizing the fill-rate gap across queue-depth buckets. Then check the vol-regime buckets: a persistent optimistic gap concentrated in burst buckets usually means your simulator under-models cancel-latency toxic fills or level refill, not ϕ\phi.

5. Re-run the bracket. After calibration, the queue-depletion estimate should sit inside the bracket near live, and — the real acceptance test — the ranking of strategy variants under the simulator should match their ranking in shadow mode. Then freeze the parameters and recalibrate on a schedule; fill dynamics drift with the venue's fee tiers, tick-size changes, and the HFT population, and a ϕ\phi calibrated in March is a hypothesis by July.

Convergence expectations from our runs: an uncalibrated rung-4 simulator typically lands within ±10-15 points of live fill rate; after one calibration pass, ±3-5 points, with time-to-fill KS p-values no longer uniformly rejecting. You will not do better than that with historical replay — the residual is the market's reaction to you, which is rung 5's problem.

What to take away

  1. Name your rung. Every backtest sits on this ladder whether you chose the rung or your framework chose it for you. If you cannot name your fill model's rung and its known biases, your PnL number has error bars you have not seen.
  2. Takers can stop at rung 3. Depth-walk plus measured latency prices aggressive execution honestly at retail size. Spend the saved effort on data quality.
  3. Makers start at rung 4. Below it, limit-order fill logic is not approximate — it selects a counterfactual set of fills with opposite adverse-selection skew. Touch-fill backtests of maker strategies are the single most reliable generator of strategies that die on contact with production.
  4. Model the lifecycle, not the fill. Partial fills, amend-resets-queue, cancel latency — the state machine is where the toxic fills live, and toxic fills are where maker PnL goes to die.
  5. Report the bracket. Conservative and optimistic bounds cost two extra backtest runs and convert "my backtest says +7k"into"realityissomewherein[7k" into "reality is somewhere in [-3.9k, +$1.9k]" — which is a different, and better, decision.
  6. Calibrate against live fills in buckets. A fill model validated only in aggregate is a fill model with hidden compensating errors. Bucketed fill-rate gaps plus time-to-fill KS tests, recalibrated quarterly.

The ladder's rungs are not academic gradations — each one is a specific lie your backtest stops telling you. Climb until the lies are smaller than your edge.

Useful links

  1. Moallemi, C., Yuan, K. — A Model for Queue Position Valuation in a Limit Order Book (2016)
  2. Cont, R., Stoikov, S., Talreja, R. — A Stochastic Model for Order Book Dynamics, Operations Research 58(3), 549-563 (2010)
  3. Lo, A., MacKinlay, C., Zhang, J. — Econometric Models of Limit-Order Executions, Journal of Financial Economics 65(1), 31-71 (2002)
  4. Huang, W., Lehalle, C.-A., Rosenbaum, M. — Simulating and Analyzing Order Book Data: The Queue-Reactive Model, JASA 110(509), 107-122 (2015)
  5. Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
  6. Byrd, D., Hybinette, M., Balch, T. — ABIDES: Towards High-Fidelity Multi-Agent Market Simulation (2020)
  7. Binance API — Cancel-Replace order semantics
  8. CME Globex — Order modification and time priority rules

Citation

@article{soloviov2026fillsimulation,
  author = {Soloviov, Eugen},
  title = {Fill simulation: the ladder from close-price fantasy to queue-aware reality},
  year = {2026},
  url = {https://marketmaker.cc/blog/fill-simulation-partial-fills-backtest},
  description = {Five rungs of fill simulation fidelity — from close-price fills to probabilistic queue-position models. Partial fills as a state machine, limit-fill probability bounds as a PnL bracket, and a calibration loop against live fills.}
}
Disclaimer: The information provided in this article is for educational and informational purposes only and does not constitute financial, investment, or trading advice. Trading cryptocurrencies involves significant risk of loss.

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Stay Ahead of the Market

Subscribe to our newsletter for exclusive AI trading insights, market analysis, and platform updates.

We respect your privacy. Unsubscribe at any time.