---
title: "2.4.8 Fill modeling: limit orders, queue, gaps and stops"
description: "When an order really fills in a backtest: limit orders by touch, by cross and by queue, adverse selection, partial fills and stops through gaps."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/backtest-order-fill-modeling/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/backtest-order-fill-modeling.png"
---

# 2.4.8 · Fill modeling: limit orders, queue, partial fills, gaps and stops

> A backtest decides on each bar whether an order has been filled and at what price. With market orders the error is small; with limit orders and stops, the fill rule can turn a losing strategy into a winning one. This lesson compares fill models with NOVA's order-by-order book and with the 5-minute bars of `KAPA`, and measures how much each assumption inflates the result.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.4 · Backtesting: engines and paradigms
- Duration: 65 min
- Prerequisites: [1.1.3 · Order book: bid, ask, spread and depth](https://pythonparatrading.com/en/python-trading-course/level-1/order-book-bid-ask-spread-depth/), [1.1.4 · Order types](https://pythonparatrading.com/en/python-trading-course/level-1/order-types-explained/), [1.1.5 · Costs, settlement, leverage and margin](https://pythonparatrading.com/en/python-trading-course/level-1/trading-costs-settlement-leverage-and-margin/)

## By the end of this lesson

- Tell apart the fill models of a limit order (by touch, by cross, by queue position, probabilistic) and know what data each one requires.
- Simulate the queue position of a limit order with L3 messages and, approximately, with L2 snapshots and trades.
- Measure adverse selection with the markout and quantify how much the touch model inflates the result of a strategy with limit orders.
- Model partial fills with a participation limit on volume and explain why they turn a cost into a risk.
- Fill stops honestly through opening gaps and measure the slippage that the optimistic model hides.

## The fill is also an assumption

A backtest does not execute anything: it decides, with the data it has, whether an order would have been filled and at
what price. That decision is the **fill model**. With small market orders the model matters little: they are filled
for sure, and the price is the bar's plus the slippage and costs from lesson 2.4.7. With limit orders and stops, the
model decides the strategy: a limit order treated as filled when it would not have been, or filled at the price of a
stop that the market jumped over, improves the result on every trade, always in the same direction.

| Model | Rule for a limit buy at $L$ | Data it needs | Bias |
|---|---|---|---|
| By touch | Filled if the price trades at $L$ or lower | Bars or trades | Optimistic: assumes being first in the queue |
| By cross | Only if the price trades below $L$ | Bars or trades | Pessimistic in quantity; captures adverse selection |
| By queue | When the quantity that was ahead at $L$ runs out | L3, or L2 plus trades | The most realistic; requires book data |
| Probabilistic | By touch, with probability $p$ | Bars | Adjusts how many, not which ones |
| Participation | At most a fraction of each bar's volume | Volume | Partial fills; does not measure impact |

The data in this lesson is synthetic: NOVA's June 2, 2025 session (level 1), simulated order by order, with its L2
book, its L3 messages and its trades, and the 5-minute bars of the fictitious ETF `KAPA` from 2022 to 2025 (level 2).
None of it comes from a real market.

## An order in the queue: NOVA at 10:00

NOVA's L3 messages cover 10:00 to 10:05 New York time (14:00 to 14:05 UTC) and start with a snapshot of all the live
orders in priority order (lesson 1.1.3). With them you can follow exactly what would have happened to your own order
arriving at 10:00 at the **end of the queue** at the best price:

- ahead of it are the orders that were already at that price;
- the queue moves forward when one of them is filled, cancelled or reduces its size;
- when nobody is left ahead, the next fill at that price would have been against your own order.

```python
import numpy as np
import pandas as pd

l3 = pd.read_parquet("nova_libro_l3.parquet")
foto, mensajes = l3.iloc[:358], l3.iloc[358:]


def simular_cola(lado, precio, tamano=500):
    delante = foto[(foto["lado"] == lado) & (foto["precio"] == precio)]
    vivas = dict(zip(delante["id_orden"], delante["tamano"]))
    inicio = sum(vivas.values())
    toque = fill = None
    lleno = 0
    m = mensajes[(mensajes["lado"] == lado)
                 & (mensajes["precio"] == precio)]
    for msg in m.itertuples():
        if msg.evento == "ejecucion" and toque is None:
            toque = msg.ts                  # touch model
        if msg.id_orden in vivas:           # the queue moves forward
            if msg.evento == "cancelacion":
                vivas.pop(msg.id_orden)
            elif msg.evento == "modificacion":
                vivas[msg.id_orden] = msg.tamano
            elif msg.evento == "ejecucion":
                vivas[msg.id_orden] -= msg.tamano
                if vivas[msg.id_orden] <= 0:
                    vivas.pop(msg.id_orden)
        elif msg.evento == "ejecucion" and not vivas:
            lleno += msg.tamano             # against our own order
            if lleno >= tamano:
                fill = msg.ts
                break
    return inicio, toque, fill


for lado, precio in [("bid", 53.08), ("ask", 53.09)]:
    inicio, toque, fill = simular_cola(lado, precio)
    seg = lambda t: None if t is None else round(
        (t - pd.Timestamp("2025-06-02 14:00", tz="UTC"))
        .total_seconds(), 1)
    print(f"{lado} {precio}: {inicio} shares ahead · touch at",
          f"{seg(toque)} s · queue fill at {seg(fill)} s")
```
```output
bid 53.08: 5966 shares ahead · touch at 0.5 s · queue fill at None s
ask 53.09: 2488 shares ahead · touch at 2.4 s · queue fill at 27.0 s
```

The two orders tell the whole story of limit orders.

- **The buy at 53.08** would have been treated as filled in the touch model after 0.5 seconds: an aggressive sell
  traded at 53.08 almost immediately. But there were 5,966 shares ahead in eight orders. The queue did get emptied (by
  fills and cancellations) and, right then, the price went up and nobody else sold at 53.08: our own order is **never
  filled** in the window. The price moved up, which is exactly when the buyer wanted to be in.
- **The sell at 53.09** is touched after 2.4 seconds and really filled after 27 seconds, when the 2,488 shares ahead
  run out. By then, the price was already rising.

```python
cot = pd.read_parquet("nova_cotizaciones.parquet")
cot["medio"] = (cot["bid"] + cot["ask"]) / 2
ventana = cot[(cot["ts"] >= "2025-06-02 14:00")
              & (cot["ts"] <= "2025-06-02 14:05")]
ventana.set_index("ts")["medio"].resample("1min").last()
```
```output
ts
2025-06-02 14:00:00+00:00    53.085
2025-06-02 14:01:00+00:00    53.140
2025-06-02 14:02:00+00:00    53.155
2025-06-02 14:03:00+00:00    53.155
2025-06-02 14:04:00+00:00    53.175
Freq: min, Name: medio, dtype: float64
```

The midpoint goes from 53.085 to 53.175 in four minutes. The touch model buys at 53.08 in the first half second and
profits from the rise; the queue model does not buy (and misses the rise) and sells at 53.09 just before the price
rises almost nine cents. It is the **adverse selection** of the market makers from lesson 2.3.6, seen from a single
order: a limit order gets filled mostly when it is not convenient.

```mermaid
stateDiagram-v2
  [*] --> IN_QUEUE
  IN_QUEUE --> IN_QUEUE: queue moves forward
  IN_QUEUE --> FIRST: queue empty
  FIRST --> FILLED: an aggressor arrives
  FIRST --> NO_FILL: the price moves away
  IN_QUEUE --> NO_FILL: the price moves away
  FILLED --> [*]
  NO_FILL --> [*]
```

## Touch, cross and queue over the whole session

One order is not a sample. The L3 messages only cover five minutes, but the L2 snapshots every 10 seconds and the
trades of the whole session make it possible to approximate the queue at any moment: our own order arrives at the end
of the best bid in the snapshot, with the level's quantity ahead, and the queue moves forward with the volume traded at
that price on the venues with a book. It is a conservative approximation (it does not see the cancellations ahead,
which would move the order forward) and it is the one that can be done with L2 and trade data; NautilusTrader does
something similar with `queue_position=True`.

For each of the 2,251 snapshots between 9:35 and 15:50, a limit buy of 200 shares at the best bid, valid for 60
seconds, is simulated with three models: **touch** (any trade on the consolidated tape at that price or lower),
**cross** (a trade below it) and **queue** (the volume traded at that price on the visible books exceeds the quantity
ahead plus our own, or the price crosses).

```python
l2 = pd.read_parquet("nova_libro_l2.parquet")
op = pd.read_parquet("nova_operaciones.parquet")
con_libro = op[op["centro"].isin(["PRINCIPAL", "MTF_A", "MTF_B"])
               & (op["condicion"] == "regular")].reset_index(drop=True)
mejor_bid = l2[(l2["nivel"] == 1) & (l2["lado"] == "bid")]
mejor_bid = mejor_bid[mejor_bid["ts"].between(
    "2025-06-02 13:35", "2025-06-02 19:50")]
H, TAM = pd.Timedelta(seconds=60), 200


def tramo(df, desde, hasta):
    i = df["ts"].searchsorted(desde, side="right")
    j = df["ts"].searchsorted(hasta, side="right")
    return df.iloc[i:j]


filas = []
for foto_l2 in mejor_bid.itertuples():
    p, delante = foto_l2.precio, foto_l2.tamano
    cinta = tramo(op, foto_l2.ts, foto_l2.ts + H)
    libro = tramo(con_libro, foto_l2.ts, foto_l2.ts + H)
    toque = cinta.loc[cinta["precio"] <= p, "ts"].min()
    cruce = libro.loc[libro["precio"] < p, "ts"].min()
    en_p = libro["tamano"].where(libro["precio"] == p, 0).cumsum()
    cola = libro.loc[en_p >= delante + TAM, "ts"].min()
    cola = min(t for t in [cola, cruce] if pd.notna(t)) if (
        pd.notna(cola) or pd.notna(cruce)) else pd.NaT
    filas.append({"ts": foto_l2.ts, "precio": p, "delante": delante,
                  "toque": toque, "cruce": cruce, "cola": cola})
fills = pd.DataFrame(filas)
print(len(fills), "orders · quantity ahead (median):",
      int(fills["delante"].median()), "shares")
fills[["toque", "cruce", "cola"]].notna().mean().round(3)
```
```output
2251 orders · quantity ahead (median): 6900 shares
toque    0.868
cruce    0.413
cola     0.444
dtype: float64
```

With the touch model, 87% of the orders are filled within a minute; with the queue model, 44%, and with the cross
model, 41%. The difference is almost entirely the queue: the median quantity ahead at the best bid is 6,900 shares,
thirty-odd times our own order, and that is not always traded within a minute.

## Adverse selection: who gets filled and when

The fill percentage is half the problem. The other half is **which** orders get filled. The **markout** measures the
quality of a fill: where the midpoint is some time later, compared with the price paid. For a buy, a negative markout
means that the price kept falling after buying.

```python
medio = cot.set_index("ts")["medio"]
ask = cot.set_index("ts")["ask"]


def a_los(serie, ts, segundos):
    i = serie.index.searchsorted(
        ts + pd.Timedelta(seconds=segundos)) - 1
    return serie.iloc[i].to_numpy()


markout = {}
for modelo in ["toque", "cruce", "cola"]:
    f = fills[fills[modelo].notna()]
    fila = {}
    for s in [5, 30, 60, 300]:
        m = a_los(medio, f[modelo], s)
        fila[f"{s}s"] = (1e4 * (m - f["precio"]) / f["precio"]).mean()
    a = a_los(ask, f[modelo], 60)
    fila["ida_vuelta_60s"] = (1e4 * (a - f["precio"])
                              / f["precio"]).mean()
    fila["p_fill"] = len(f) / len(fills)
    markout[modelo] = fila
pd.DataFrame(markout).T.round(2)
```
```output
         5s   30s   60s   300s  ida_vuelta_60s  p_fill
toque  0.80  0.11 -0.57  -2.74            0.39    0.87
cruce -1.14 -3.32 -5.95 -13.20           -4.98    0.41
cola  -0.94 -2.98 -5.38 -12.27           -4.40    0.44
```

*Fig. — Markout of NOVA limit buys by fill model: distance from the mid price to the purchase price 5, 30, 60 and 300 seconds later.* Three lines on a log time scale. All three fall with the horizon: passive buys fill when the price is falling. Touch (highlighted) goes from +0.80 bp at 5 s to −2.74 at 300 s; queue from −0.94 to −12.27; trade-through from −1.14 to −13.20.

In all three models the markout worsens over time: passive buys get filled when the flow is selling, and the selling
flow continues. But the touch model is much higher at every horizon: it includes the “free” fills from touches in which
the price rebounds, which in the real queue never get filled.

The `ida_vuelta_60s` column is a minimal market-making strategy: buy at the bid and sell at the ask 60 seconds later,
assuming (generously, equally in all three cases) that the exit gets filled. With the touch model it makes 0.39 bp per
trade; with the queue model, it loses 4.40. The same code, the same data and the same exit: only the entry rule
changes, and the strategy goes from winning to losing. The header figure sums it up on two axes, the fill probability
and the result of each fill.

Does a fill probability, like NautilusTrader's `prob_fill_on_limit` (lesson 2.4.5), fix the bias? It can be checked:
randomly fill 44% of the touches, the same proportion the queue model gives (over all orders, 38.9%).

```python
rng = np.random.default_rng(2028)
p_cola = fills["cola"].notna().mean()
azar = fills[fills["toque"].notna()
             & (rng.random(len(fills)) < p_cola)]
a = a_los(ask, azar["toque"], 60)
m = a_los(medio, azar["toque"], 60)
print(f"fills: {len(azar) / len(fills):.1%}")
print(f"markout 60 s: {(1e4 * (m / azar['precio'] - 1)).mean():.2f} bp")
print(f"round trip: {(1e4 * (a / azar['precio'] - 1)).mean():.2f} bp")
```
```output
fills: 38.9%
markout 60 s: -0.50 bp
round trip: 0.47 bp
```

It does not fix it: the round trip stays at +0.47 bp, versus −4.40 in the queue model. The probability adjusts **how
many** orders get filled, but picks them at random among the touches, which are the good and the bad ones mixed
together. The real ones are the bad ones. For a strategy that lives on limit orders, a random fill probability is better
than nothing for the number of trades and almost nothing for the result.

## Partial fills: participating in volume

A large order is not filled in one bar. The usual way to model this is a **participation limit**: on each bar, at most
a fraction of the traded volume is filled (here, 10%) and the rest waits for the next one. With the `KAPA` bars, for
each of the 1,003 sessions, a buy that starts at 10:00 and is filled at the close of each bar:

```python
barras = pd.read_parquet("indice2_5min.parquet")
ny = barras["ts"].dt.tz_convert("America/New_York")
barras["dia"], barras["hora"] = ny.dt.date, ny.dt.strftime("%H:%M")


def participar(sesion, total, part=0.10, inicio="10:00"):
    s = sesion[sesion["hora"] >= inicio]
    llegada = s["apertura"].iloc[0]          # decision price
    resto, gasto, n = total, 0.0, 0
    for b in s.itertuples():
        q = min(resto, int(part * b.volumen))
        gasto, resto, n = gasto + q * b.cierre, resto - q, n + 1
        if resto == 0:
            break
    medio = gasto / (total - resto)
    return n, resto > 0, 1e4 * (medio / llegada - 1)


tabla = {}
for total in [50_000, 500_000, 2_000_000, 10_000_000]:
    r = pd.DataFrame([participar(g, total)
                      for _, g in barras.groupby("dia")],
                     columns=["barras", "incompleta", "desl_pb"])
    tabla[f"{total:,}"] = {
        "barras_mediana": r["barras"].median(),
        "incompleta_pct": 100 * r["incompleta"].mean(),
        "desl_medio_pb": r["desl_pb"].mean(),
        "desl_desv_pb": r["desl_pb"].std()}
pd.DataFrame(tabla).T.round(2)
```
```output
            barras_mediana  incompleta_pct  desl_medio_pb  desl_desv_pb
50,000                 1.0             0.0           0.59         12.62
500,000                6.0             0.0           0.75         18.21
2,000,000             31.0             0.0           1.42         30.36
10,000,000            72.0            98.4           3.23         48.43
```

With 50,000 shares the order fits in one bar; with two million it takes a median of 31 bars, two and a half hours, and
with ten million it is not completed within the day in 98% of the sessions. The average slippage versus the arrival
price is small and positive (`KAPA` rises over the sample: buying later costs a bit more), but its **standard
deviation** grows with execution time, from 13 to 48 bp. Splitting an order reduces impact, but exposes it to the
market while it is being executed: the cost turns into risk.

A warning about this data: `KAPA` prices do not react to the simulated order, as no historical series does. The
participation model decides how much and when is filled; the impact those buys would have caused has to be added
separately, with the square-root law from lesson 2.4.7. And if a strategy only works with orders that need days to be
executed, the problem is not the fill model: it is its capacity (lesson 2.2.3).

## Stops and gaps

A stop is a dormant market order (lesson 1.1.4): when the price reaches the trigger, it is filled at the first available
price. Within a continuous session that price is close to the trigger. Between sessions it is not: if the price opens
below a sell stop, the first trade is the open, and the order is filled there. The optimistic model, which always fills
at the stop price, gives away the difference.

With `KAPA`: each day the position is bought at the close and protected with a sell stop at a percentage of the close.
The honest fill is the open if the session opens below the stop, and the stop otherwise (in these bars the open of each
bar is the close of the previous one, so there are no gaps within the session):

```python
diario = barras.groupby("dia").agg(
    apertura=("apertura", "first"), minimo=("minimo", "min"),
    cierre=("cierre", "last"))
diario["cierre_previo"] = diario["cierre"].shift()
diario = diario.dropna()

filas, huecos_1pct = [], None
for pct in [0.005, 0.01, 0.015, 0.02]:
    stop = (diario["cierre_previo"] * (1 - pct)).round(2)
    dispara = diario["minimo"] <= stop
    hueco = diario["apertura"] <= stop
    ejecucion = diario["apertura"].where(hueco, stop)
    desl = 1e4 * (stop - ejecucion) / stop
    filas.append({"stop_pct": 100 * pct, "disparos": dispara.sum(),
                  "en_hueco": hueco.sum(),
                  "desl_medio_hueco_pb": desl[hueco].mean(),
                  "desl_max_pb": desl.max(),
                  "coste_total_pb": desl.sum()})
    if pct == 0.01:
        huecos_1pct = diario[hueco].assign(stop=stop, desl_pb=desl)
pd.DataFrame(filas).set_index("stop_pct").round(1)
```
```output
          disparos  en_hueco  desl_medio_hueco_pb  desl_max_pb  coste_total_pb
stop_pct
0.5            565       122                 27.9        138.1          3405.5
1.0            277        18                 27.9         88.5           502.2
1.5            127         4                 19.1         38.0            76.6
2.0             52         0                  NaN          0.0             0.0
```

*Fig. — The 18 KAPA opening gaps, 2022-2025, that jump a sell stop 1% below the previous close: difference between the stop price and the open at which it really fills.* Vertical bars by date, from January 2022 to May 2025. Most are between 0.5 and 30 bp; five exceed 40: 41.6 (January 2022), 88.5 (18 May 2022, highlighted), 67.3 (December 2023), 64.0 (February 2024) and 58.3 (March 2025). Mean: 27.9 bp.

With a 1% stop, 18 of the 277 triggers come through a gap, and on those the optimistic model saves an average of 28 bp
per trade; in the worst one (May 18, 2022) it is 88 bp. The closer the stop, the more triggers come through a gap. A 2%
stop is never jumped in this data, but the tail of the gaps of an ETF on an index is short; in an individual stock, the
day after an earnings release, the gap can be several times the stop (lesson 1.1.4, NOVA's 5.1% gap).

```python
huecos_1pct.sort_values("desl_pb", ascending=False)[
    ["cierre_previo", "stop", "apertura", "desl_pb"]].head(4).round(2)
```
```output
            cierre_previo    stop  apertura  desl_pb
dia
2022-05-18         243.12  240.69    238.56    88.50
2023-12-22         366.15  362.49    360.05    67.31
2024-02-29         372.71  368.98    366.62    63.96
2025-03-27         365.66  362.00    359.89    58.29
```

There is a second trap with stops on bars: if the stop and the profit target both fall within the same bar, the bar does
not say which was touched first. The prudent rule is to assume that the stop (the loss) came first. NautilusTrader walks
each bar in the order open, high, low, close, or with `bar_adaptive_high_low_ordering=True`, first the extreme closest
to the open (lesson 2.4.5): both are conventions, not information.

## Which model to use

```mermaid
flowchart TB
  O{Order type} --> M[Market]
  O --> L[Limit]
  O --> S[Stop]
  M --> MP[Price and costs]
  L --> D{Book data}
  D -- L3 --> CE[Exact queue]
  D -- L2 and tape --> CA[Approximate queue]
  D -- bars --> CR[By cross]
  S --> HA[Open on gap]
```

| Situation | Reasonable model | In NautilusTrader 1.231 |
|---|---|---|
| Small market orders | Bar price (or next open) plus slippage and costs | `FillModel(prob_slippage=…)`, `FeeModel` |
| Large market orders | Volume participation plus impact | Synthetic books (`ThreeTierFillModel`…) or a custom model |
| Limit orders with L3 data | Exact queue | `book_type=BookType.L3_MBO` and book data |
| Limit orders with L2 and trades | Approximate queue | `trade_execution=True`, `queue_position=True` |
| Limit orders with bars only | By cross (and accept that it is pessimistic in quantity) | No direct equivalent: `prob_fill_on_limit` adjusts the quantity, not the selection |
| Stops | Open if there is a gap; the stop first when in doubt | `order_factory.stop_market` with bars, `bar_adaptive_high_low_ordering` |

The general rule is to choose the **most pessimistic model the data allows** and check how much the result changes with
the optimistic one. If the strategy only works with the optimistic one, it does not work. In the experiment log
(lesson 2.2.4), the fill model goes next to the costs: two backtests with different models are two different
experiments.

## Exercises

1. Repeat the study of the three models for limit **sells** at the best ask (`lado == "ask"`, filled by trades at that
   price or higher). Does the conclusion hold?

**Solution.**

```python
mejor_ask = l2[(l2["nivel"] == 1) & (l2["lado"] == "ask")]
mejor_ask = mejor_ask[mejor_ask["ts"].between(
    "2025-06-02 13:35", "2025-06-02 19:50")]
bid = cot.set_index("ts")["bid"]
res = {"toque": [], "cola": []}
for foto_l2 in mejor_ask.itertuples():
    p, delante = foto_l2.precio, foto_l2.tamano
    cinta = tramo(op, foto_l2.ts, foto_l2.ts + H)
    libro = tramo(con_libro, foto_l2.ts, foto_l2.ts + H)
    toque = cinta.loc[cinta["precio"] >= p, "ts"].min()
    cruce = libro.loc[libro["precio"] > p, "ts"].min()
    en_p = libro["tamano"].where(libro["precio"] == p, 0).cumsum()
    cola = libro.loc[en_p >= delante + TAM, "ts"].min()
    cola = min([t for t in [cola, cruce] if pd.notna(t)],
               default=pd.NaT)
    for modelo, t in [("toque", toque), ("cola", cola)]:
        if pd.notna(t):
            b = a_los(bid, pd.Series([t]), 60)[0]
            # sells at the ask, buys back at the bid
            res[modelo].append(1e4 * (p - b) / p)
for modelo, v in res.items():
    print(f"{modelo:6} p_fill {len(v) / len(mejor_ask):.1%}",
          f"· round trip 60 s {np.mean(v):.2f} bp")
```
```output
toque  p_fill 84.9% · round trip 60 s 0.88 bp
cola   p_fill 43.1% · round trip 60 s -4.20 bp
```

The conclusion holds on the other side of the book: the touch model fills almost all the sells and gives them a much
better result than the queue model, which only fills the ones the price trades through or that exhaust the queue, at
moments of buying flow. Adverse selection is symmetric.

2. In the stop study, compute the total cost of the optimistic model in USD for a position of 1,000 shares with the
   stop at 1%, and compare it with what a commission of 1 USD per order would cost over the 277 triggers.

**Solution.**

```python
desl_usd = 1000 * (huecos_1pct["stop"] - huecos_1pct["apertura"])
print(f"hidden slippage: {desl_usd.sum():,.0f} USD",
      f"in {len(huecos_1pct)} gaps")
print(f"commissions on 277 sells at 1 USD: {277 * 1:,} USD")
```
```output
hidden slippage: 15,940 USD in 18 gaps
commissions on 277 sells at 1 USD: 277 USD
```

The slippage that the optimistic model hides in only 18 gaps, 15,940 USD, is more than fifty times what the commissions
on all the sells would cost (277 USD). It is the difference between modeling a cost that shows up in the fee schedule
and one that only appears when the market jumps.

3. A colleague presents a mean reversion strategy on daily bars that buys with limit orders 1% below the close and
   sells at the next close, with a fill “if the day's low touches the price”. Which three changes would you ask for in
   the backtest before discussing the result?

**Solution.**

First, the fill model: fill only if the low ends up at least one tick below the limit (cross), and compare the result
with the touch model; if the difference is large, the strategy lives on touches that the real queue would not have
filled. Second, the gaps: if the session opens below the limit, the buy is filled at the open (a better price than the
limit, but at the worst moment), and that has to be treated just like a stop. Third, the size: what fraction of volume
it trades and what impact it would have, because a large limit buy does not get filled in full when the price is
touched. And, if possible, check it against L2 or trade data on a sample to measure adverse selection, as in NOVA's
session.

## Self-assessment

1. A bar backtest treats a limit buy at 53.08 as filled as soon as the bar's low touches 53.08. What does it assume without saying so?

   - A. That the price trades through the order
   - B. That there is market impact
   - C. That the order was first in the queue at that price
   - D. Nothing: it is the exact model

   *Answer C: That the order was first in the queue at that price.* The price reaching your level is not enough: the orders ahead in the queue are filled first. In NOVA's session, the 10:00 buy order at 53.08 would have been “filled” by touch in half a second; with the real queue, there were 5,966 shares ahead and the price moved away without ever filling it.

2. In NOVA's session, a limit buy at the bid is filled within 60 seconds 87% of the time by touch and 44% by queue. What happens to the average result of each fill?

   - A. It is worse in the queue model: the fills that only happen when the queue runs out are those of the moments when the price keeps falling
   - B. It is the same in both models
   - C. It is better in the queue model
   - D. It depends on the commission

   *Answer A: It is worse in the queue model: the fills that only happen when the queue runs out are those of the moments when the price keeps falling.* That is adverse selection: a passive buy gets filled when many are selling. At 60 seconds, the midpoint is 0.57 bp below the purchase price in the touch model and 5.38 bp in the queue model.

3. Why is it not enough to randomly fill 44% of the touches (as `prob_fill_on_limit=0.44` does in NautilusTrader with bars) to correct the bias of the touch model?

   - A. Because 44% is too little
   - B. Because NautilusTrader does not support that parameter
   - C. It is enough
   - D. Because it reduces the number of fills, but picks at random which ones: it keeps the optimistic quality of the touch fills, which lack the adverse selection of the real ones

   *Answer D: Because it reduces the number of fills, but picks at random which ones: it keeps the optimistic quality of the touch fills, which lack the adverse selection of the real ones.* The problem with the touch model is not only how many orders get filled, but which ones. The ones that really get filled are the worst. In NOVA, the random 44% of touches keeps a positive round-trip result, while the queue model makes it clearly negative.

4. A sell stop at 368.98 on `KAPA` is triggered on February 29, 2024 because the session opens at 366.62. At what price is it honest to fill it in the backtest?

   - A. At 368.98, the stop price
   - B. At the open, 366.62, because the price jumped past the stop without trading at it
   - C. At the day's low
   - D. At the day's close

   *Answer B: At the open, 366.62, because the price jumped past the stop without trading at it.* A stop is a dormant market order: when it is triggered it is filled at the first available price. If the session opens below the stop, that price is the open. Assuming 368.98 saves 64 bp that do not exist.

5. A buy of 2 million `KAPA` shares with a participation limit of 10% of volume takes a median of 31 bars. What changes compared with executing it all at once?

   - A. Nothing
   - B. The arrival price is guaranteed
   - C. The commission increases
   - D. Impact is reduced, but the average price is exposed to whatever the market does for two and a half hours: the standard deviation of slippage versus the arrival price rises to 30 bp

   *Answer D: Impact is reduced, but the average price is exposed to whatever the market does for two and a half hours: the standard deviation of slippage versus the arrival price rises to 30 bp.* Splitting the order turns part of the cost (impact) into execution risk. In this synthetic data prices do not react to the order, so the simulation only measures that risk; impact is added with the model from lesson 2.4.7.

## Key points

- The fill model is a backtest assumption, like the cost model, and it has to be declared: market at the bar price, limit by touch, by cross, by queue or probabilistic.
- A limit order is filled when the queue ahead of it runs out; without L3 data it is approximated with the size of the level and the volume traded at it.
- Limit orders suffer adverse selection: they get filled when the price moves against them. In NOVA, the touch model turns a passive round trip of −4.4 bp into one of +0.4 bp.
- A random fill probability reduces how many orders get filled, but not which ones: it does not correct adverse selection.
- Partial fills with a participation limit reduce impact and add execution risk; stops are filled at the open when the price jumps past them.

## Resources

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min

### Documentation

- [NautilusTrader: fill models (prob_fill_on_limit, prob_slippage, synthetic books)](https://nautilustrader.io/docs/latest/concepts/backtesting/fill-models/) · nautilustrader.io
- [NautilusTrader: trade-based execution and queue position tracking](https://nautilustrader.io/docs/latest/concepts/backtesting/trade-execution/) · nautilustrader.io

### Books

- [Bouchaud, Bonart, Donier and Gould (2018): Trades, Quotes and Prices. Cambridge University Press](https://doi.org/10.1017/9781316659335) · doi.org

### Data

- [Level 1 data: L3 messages of NOVA's book (Parquet)](https://pythonparatrading.com/curso/datos/nivel-1/nova_libro_l3.parquet) · Download · nova_libro_l3.parquet
- [Level 2 data: 5-minute bars of the KAPA ETF (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/indice2_5min.parquet) · Download · indice2_5min.parquet

Previous lesson: [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/)

Next lesson: [2.5.1 · Backtest biases: look-ahead, survivorship, selection and narrative](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-biases/)
