Lesson 2.4.8 · 65 min

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.

Fig. — Four fill models for 2,251 limit buys at NOVA's best bid (2 June 2025): share filled within 60 s and result of buying at the bid and selling at the ask 60 s later
08/08
Module
2.4 · Backtesting: engines and paradigms
Duration
65 min
Updated
Level 2 lessons
  1. 2.1 · Statistics and econometrics for trading

    1. 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
    2. 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
    3. 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
    4. 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
    5. 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
    6. 2.1.6 Kalman filter for dynamic hedge ratios
    7. 2.1.7 Regime detection: hidden Markov models and structural breaks
    8. 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
  2. 2.2 · Anatomy of a trading system

    1. 2.2.1 Edge hypotheses: risk, behavior, structure and information
    2. 2.2.2 Universe, signal, filter, entry, exit, sizing and risk management
    3. 2.2.3 Frequencies: intraday, swing and position trading, and the capacity of a strategy
    4. 2.2.4 Research journal and experiment tracking against data snooping
  3. 2.3 · Strategy families

    1. 2.3.1 Trend following and momentum
    2. 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
    3. 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
    4. 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
    5. 2.3.5 Factors (value, quality, low volatility, size) and event strategies
    6. 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
    7. 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
  4. 2.4 · Backtesting: engines and paradigms

    1. 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
    2. 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
    3. 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
    4. 2.4.4 backtesting.py and backtrader: two teaching engines and their status
    5. 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
    6. 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
    7. 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
    8. 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
  5. 2.5 · Biases and validation

    1. 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
    2. 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
    3. 2.5.3 Combinatorial purged cross-validation for strategies
    4. 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
    5. 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
    6. 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
    7. 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
    8. 2.5.8 “Honest backtest” checklist before moving to paper trading
  6. 2.6 · Performance metrics

    1. 2.6.1 CAGR, Sharpe, Sortino, Calmar, Omega, drawdown and Ulcer Index
    2. 2.6.2 Win rate, payoff, expectancy, alpha, beta, information ratio, turnover and capacity
    3. 2.6.3 Reports with QuantStats or pyfolio-reloaded and custom tearsheets

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.

ModelRule for a limit buy at LLData it needsBias
By touchFilled if the price trades at LL or lowerBars or tradesOptimistic: assumes being first in the queue
By crossOnly if the price trades below LLBars or tradesPessimistic in quantity; captures adverse selection
By queueWhen the quantity that was ahead at LL runs outL3, or L2 plus tradesThe most realistic; requires book data
ProbabilisticBy touch, with probability ppBarsAdjusts how many, not which ones
ParticipationAt most a fraction of each bar's volumeVolumePartial 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.
IN [01]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")
OUT [01]Exact queue of a limit order with L3 messages
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.
IN [02]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()
OUT [02]What the price did next
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.

queue moves forwardqueue emptyan aggressor arrivesthe price moves awaythe price moves awayIN_QUEUEFIRSTFILLEDNO_FILL
Mermaid source
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).

IN [03]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)
OUT [03]Three fill models on 2,251 orders
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.

IN [04]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)
OUT [04]Markout of the fills by model
         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

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%).

IN [05]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")
OUT [05]Random touches with the queue model probability
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:

IN [06]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)
OUT [06]Buys of four sizes with 10% participation
            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):

IN [07]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)
OUT [07]Sell stops at different distances
          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

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).

IN [08]python
huecos_1pct.sort_values("desl_pb", ascending=False)[
    ["cierre_previo", "stop", "apertura", "desl_pb"]].head(4).round(2)
OUT [08]The largest gaps
            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

L3L2 and tapebarsOrder typeMarketLimitStopPrice and costsBook dataExact queueApproximate queueBy crossOpen on gap
Mermaid source
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]
SituationReasonable modelIn NautilusTrader 1.231
Small market ordersBar price (or next open) plus slippage and costsFillModel(prob_slippage=…), FeeModel
Large market ordersVolume participation plus impactSynthetic books (ThreeTierFillModel…) or a custom model
Limit orders with L3 dataExact queuebook_type=BookType.L3_MBO and book data
Limit orders with L2 and tradesApproximate queuetrade_execution=True, queue_position=True
Limit orders with bars onlyBy cross (and accept that it is pessimistic in quantity)No direct equivalent: prob_fill_on_limit adjusts the quantity, not the selection
StopsOpen if there is a gap; the stop first when in doubtorder_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
IN [09]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")
OUT [09]Limit sells at the ask
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.

  1. 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
IN [10]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")
OUT [10]What the optimistic model hides
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.

  1. 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.

Each answer is folded under its question

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?

    • AThat the price trades through the order
    • BThat there is market impact
    • CThat the order was first in the queue at that price
    • DNothing: it is the exact model
    Show answer

    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?

    • AIt 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
    • BIt is the same in both models
    • CIt is better in the queue model
    • DIt depends on the commission
    Show answer

    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?

    • ABecause 44% is too little
    • BBecause NautilusTrader does not support that parameter
    • CIt is enough
    • DBecause 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
    Show answer

    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?

    • AAt 368.98, the stop price
    • BAt the open, 366.62, because the price jumped past the stop without trading at it
    • CAt the day's low
    • DAt the day's close
    Show answer

    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?

    • ANothing
    • BThe arrival price is guaranteed
    • CThe commission increases
    • DImpact 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
    Show answer

    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

Data