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.
- Module
- 2.4 · Backtesting: engines and paradigms
- Duration
- 65 min
- Prerequisites
- 1.1.3 · Order book: bid, ask, spread and depth, 1.1.4 · Order types, 1.1.5 · Costs, settlement, leverage and margin
- Author
- Jesús Cuesta
- Updated
Level 2 lessons
2.1 · Statistics and econometrics for trading
- 2.1.1 Hypothesis tests, p-values, bootstrap and regression with robust standard errors
- 2.1.2 Stationarity: ADF, KPSS, differencing and fractional differencing
- 2.1.3 ARMA and ARIMA models: autocorrelation (ACF) and partial autocorrelation (PACF)
- 2.1.4 Volatility models: ARCH, GARCH, EGARCH and GJR with the arch library
- 2.1.5 Cointegration: Engle-Granger, Johansen and the half-life of mean reversion
- 2.1.6 Kalman filter for dynamic hedge ratios
- 2.1.7 Regime detection: hidden Markov models and structural breaks
- 2.1.8 The multiple testing problem: Bonferroni, FDR and the “factor zoo”
2.2 · Anatomy of a trading system
2.3 · Strategy families
- 2.3.1 Trend following and momentum
- 2.3.2 Mean reversion: RSI and Bollinger, intraday reversal and gaps
- 2.3.3 Pairs trading and statistical arbitrage: distance, cointegration and Kalman
- 2.3.4 Carry in currencies, futures and bonds; seasonality and calendar effects
- 2.3.5 Factors (value, quality, low volatility, size) and event strategies
- 2.3.6 Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
- 2.3.7 Crypto-specific strategies: momentum, funding arbitrage and basis
2.4 · Backtesting: engines and paradigms
- 2.4.1 Vectorized backtest from scratch: matrices, lag, costs and limits
- 2.4.2 Event-driven backtest from scratch: loop, portfolio, orders and fills
- 2.4.3 vectorbt: massive parameter sweeps and portfolio simulation
- 2.4.4 backtesting.py and backtrader: two teaching engines and their status
- 2.4.5 NautilusTrader: architecture, data, strategies and the same code in backtest and live
- 2.4.6 LEAN/QuantConnect and Zipline Reloaded; bt for asset allocation
- 2.4.7 Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing
- 2.4.8 Fill modeling: limit orders, queue, partial fills, gaps and stops
2.5 · Biases and validation
- 2.5.1 Backtest biases: look-ahead, survivorship, selection and narrative
- 2.5.2 In-sample and out-of-sample: anchored and rolling walk-forward
- 2.5.3 Combinatorial purged cross-validation for strategies
- 2.5.4 Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- 2.5.5 Monte Carlo: trade resampling, block bootstrap and signal permutations
- 2.5.6 Parameter sensitivity: plateaus versus peaks, robustness across assets and periods
- 2.5.7 Synthetic data for stress testing: GBM, GARCH and bootstrapping
- 2.5.8 “Honest backtest” checklist before moving to paper trading
2.6 · Performance metrics
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 | Data it needs | Bias |
|---|---|---|---|
| By touch | Filled if the price trades at or lower | Bars or trades | Optimistic: assumes being first in the queue |
| By cross | Only if the price trades below | Bars or trades | Pessimistic in quantity; captures adverse selection |
| By queue | When the quantity that was ahead at runs out | L3, or L2 plus trades | The most realistic; requires book data |
| Probabilistic | By touch, with probability | 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.
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")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 sThe 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.
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()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: float64The 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 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).
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)2251 orders · quantity ahead (median): 6900 shares
toque 0.868
cruce 0.413
cola 0.444
dtype: float64With 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.
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) 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.44In 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%).
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")fills: 38.9%
markout 60 s: -0.50 bp
round trip: 0.47 bpIt 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:
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) 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.43With 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):
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) 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.0With 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).
huecos_1pct.sort_values("desl_pb", ascending=False)[
["cierre_previo", "stop", "apertura", "desl_pb"]].head(4).round(2) 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.29There 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 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]| 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
- 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
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")toque p_fill 84.9% · round trip 60 s 0.88 bp
cola p_fill 43.1% · round trip 60 s -4.20 bpThe 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.
- 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
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")hidden slippage: 15,940 USD in 18 gaps
commissions on 277 sells at 1 USD: 277 USDThe 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.
- 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
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.
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.
Why is it not enough to randomly fill 44% of the touches (as
prob_fill_on_limit=0.44does 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.
A sell stop at 368.98 on
KAPAis 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.
A buy of 2 million
KAPAshares 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.