Lesson 2.3.6 · 70 min
Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov)
A market maker quotes a bid and an ask at the same time and earns the bid-ask spread if both get filled. It loses in two ways: the inventory it accumulates when only one side is filled, and the orders of those who know more than it does. This lesson estimates the order intensity of a synthetic session, simulates one market maker with symmetric quotes and another with the Avellaneda and Stoikov model, and breaks the result down into spread, inventory and adverse selection.
- Module
- 2.3 · Strategy families
- Duration
- 70 min
- Prerequisites
- 1.1.1 · Market participants and trading venues, 1.1.3 · Order book: bid, ask, spread and depth, 1.1.4 · Order types
- 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 business of quoting both sides
A market maker keeps, at the same time, a buy limit order below the mid price and a sell limit order above it. If a market order arrives on each side, it buys at its bid, sells at its ask and keeps the difference: the bid-ask spread. It does not bet on the direction of the price; it sells immediacy to those who need it (lesson 1.1.1).
Mermaid source
flowchart TB
M[Mid price] --> C[Quote bid and ask]
C --> F{Fill?}
F -- one side --> I[Inventory]
F -- both --> H[Spread earned]
I --> R[Price risk]
R --> C
H --> CThe problem is in the left branch. Orders do not arrive in pairs: if five sells arrive in a row, the market maker buys five times and ends up long, exposed to a falling price. And not all orders are equal: some come from those who know the price is about to move, and those fill the market maker just before the move makes it lose. These are the two sources of loss this lesson measures:
| Component | Sign | What it depends on |
|---|---|---|
| Captured spread | + | Distance of the quotes from the mid and number of fills |
| Inventory | ± | Size of the position and price volatility while it is held |
| Adverse selection | − | Share of informed orders and how much they move the price afterwards |
The order flow of a session
mm2_flujo.parquet has every market order of the session: when it arrives, on which side (compra takes the ask,
venta the bid), its size and its reach, the distance from the mid down to which it sweeps the book. A sell quote
at δ from the mid is filled by a buy if that buy's reach is at least δ. mm2_medio.parquet is the mid price at the
end of each second.
import numpy as np
import pandas as pd
flujo = pd.read_parquet("mm2_flujo.parquet")
medio = pd.read_parquet("mm2_medio.parquet")
inicio = pd.Timestamp("2025-03-12 13:30:00", tz="UTC")
T = len(medio) # seconds in the session
print(len(flujo), "orders,", T, "seconds")
print(flujo.groupby("lado")["tamano"].agg(["size", "mean"]).round(0))
print(f"informed: {flujo['informada'].mean():.1%}")
flujo.groupby("informada")["alcance"].describe()[
["count", "mean", "50%", "max"]].round(4)42092 orders, 23400 seconds
size mean
lado
compra 20889 300.0
venta 21203 302.0
informed: 10.0%
count mean 50% max
informada
False 37903.0 0.0250 0.017 0.281
True 4189.0 0.0492 0.034 0.691In 6.5 hours, 42,092 market orders arrive, almost the same on each side (0.89 and 0.91 per second), of about 300 shares on average. 10.0% are informed, and their reach is double: an average of 4.9 cents versus 2.5 for uninformed orders. Those who know the price is about to move are willing to sweep more levels of the book to get filled now, and that has a direct consequence for the market maker: the farther from the mid it quotes, the larger the share of informed traders among the orders that reach it.
Fill intensity as a function of distance from the mid
The Avellaneda and Stoikov (2008) model assumes that a quote at distance δ from the mid is filled as a Poisson process with intensity
is the intensity of a quote at the mid itself and says how quickly it falls as the quote moves away. It is estimated by counting, for each δ, how many orders on one side have a reach of at least δ and dividing by the length of the session; on a log scale the relationship is a straight line with slope .
deltas = np.arange(0, 0.1001, 0.005)
def intensidad(ordenes):
lam = np.array([(ordenes["alcance"] >= d).sum() / T
for d in deltas])
pend, ordenada = np.polyfit(deltas, np.log(lam), 1)
return np.exp(ordenada), -pend, lam
filas = {}
for lado in ["compra", "venta"]:
g = flujo[flujo["lado"] == lado]
for nombre, sub in [("all", g), ("uninformed",
g[~g["informada"]])]:
A, k, _ = intensidad(sub)
filas[(lado, nombre)] = {"A_por_s": A, "k_por_usd": k}
est = pd.DataFrame(filas).T
print(est.round(3))
A = est.xs("all", level=1)["A_por_s"].mean()
k = est.xs("all", level=1)["k_por_usd"].mean()
print(f"\nmarket maker parameters: A = {A:.2f} per s, "
f"k = {k:.1f} per USD")
print(f"half spread that maximizes δ·λ(δ): "
f"1/k = {100 / k:.2f} cents") A_por_s k_por_usd
compra all 0.866 35.327
uninformed 0.824 39.969
venta all 0.883 35.433
uninformed 0.836 40.230
market maker parameters: A = 0.87 per s, k = 35.4 per USD
half spread that maximizes δ·λ(δ): 1/k = 2.83 centsWith uninformed orders, the estimate recovers the planted parameters almost exactly: between 0.82 and 0.84 per second (truth 0.81) and between 40.0 and 40.2 per USD (truth 40). With all the flow, which is all a real market maker sees, rises to 0.87 and falls to 35.4: informed orders, with more reach, fatten the tail. The market maker will use these parameters, the observable ones. The half spread that maximizes one side's revenue with no risk, , is 2.83 cents.
The figure shows the measured intensity at each distance, on a log scale, with the fitted line: the points fall almost on it. One cent from the mid, a sell quote is filled about 0.6 times per second; at 5 cents, 0.15; at 10, less than 0.03. Each extra cent of spread costs 30% of the fills.
Adverse selection: what happens after each order
The markout of an order is how much the mid moves in its direction over the next seconds: . For the market maker that fills it, it is what it loses because of the information the order carried. Glosten and Milgrom (1985) explain the spread with this mechanism: the market maker cannot tell the informed apart and charges everyone enough to make up for what it loses to them.
m = np.r_[medio["medio"].iloc[0], medio["medio"].to_numpy()]
# m[s] = mid at the start of second s (the last one known)
seg = np.floor((flujo["ts"] - inicio).dt.total_seconds()).astype(int)
seg = seg.clip(0, T - 1).to_numpy()
signo = np.where(flujo["lado"] == "compra", 1, -1)
informada = flujo["informada"].to_numpy()
markout = {}
for h in [1, 5, 10, 20, 30, 45, 60]:
mo = 100 * signo * (m[np.minimum(seg + h, T)] - m[seg])
markout[h] = {"informadas_cent": mo[informada].mean(),
"no_informadas_cent": mo[~informada].mean()}
pd.DataFrame(markout).T.round(3) informadas_cent no_informadas_cent
1 0.066 0.002
5 0.311 0.004
10 0.616 0.007
20 1.323 -0.010
30 1.998 -0.008
45 1.959 -0.001
60 1.945 0.009After an informed order, the mid moves in its direction almost linearly for 30 seconds, up to 2.0 cents, and then stays there: it is the planted impact. After an uninformed one it does not move at all (from −0.010 to +0.009 cents). A fill against an informed trader costs the market maker, on average, 2 cents per share of adverse move, almost the same as it earns in half spread. The figure plots the two curves.
A market maker simulator
The simulator walks through the session second by second. At the start of each second, the market maker sets its bid and ask from the last known mid and its inventory, rounded to the tick (the bid down and the ask up). Each order that arrives in that second fills 100 shares if its reach gets to the quote. The simplifications matter and are worth stating: its quote is the only one in the book at that price, it is always filled in full, it pays no commissions and it updates the quote with no latency.
The accounting separates the three parts of the result. The spread is what it earns on each fill relative to the mid at that moment; the inventory, what the position gains or loses with changes in the mid, ; the sum of the two is exactly the final result marked at the mid. Adverse selection is measured separately, with the 30-second markout of fills against informed orders: it is the part of inventory lost because of them.
TICK, LOTE = 0.01, 100
por_segundo = [[] for _ in range(T)]
for i, s in enumerate(seg):
por_segundo[s].append(i)
alcance = flujo["alcance"].to_numpy()
def simular(cotizar):
q, caja, horquilla, inventario = 0, 0.0, 0.0, 0.0
ejec, q_seg = [], np.zeros(T)
for s in range(T):
bid, ask = cotizar(m[s], q, s)
bid = np.floor(bid / TICK + 1e-9) * TICK
ask = np.ceil(ask / TICK - 1e-9) * TICK
for i in por_segundo[s]:
if signo[i] == 1 and alcance[i] >= ask - m[s] - 1e-9:
q -= LOTE; caja += ask * LOTE
horquilla += (ask - m[s]) * LOTE
ejec.append((s, -1, informada[i]))
elif signo[i] == -1 and alcance[i] >= m[s] - bid - 1e-9:
q += LOTE; caja -= bid * LOTE
horquilla += (m[s] - bid) * LOTE
ejec.append((s, 1, informada[i]))
inventario += q * (m[s + 1] - m[s])
q_seg[s] = q
e = np.array(ejec)
s_e, lado_e, inf_e = e[:, 0].astype(int), e[:, 1], e[:, 2] == 1
mo = lado_e * (m[np.minimum(s_e + 30, T)] - m[s_e]) * LOTE
res = {"resultado": caja + q * m[T], "horquilla": horquilla,
"inventario": inventario,
"sel_adversa_30s": mo[inf_e].sum(),
"ejecuciones": len(e), "pct_informadas": 100 * inf_e.mean(),
"q_desv": q_seg.std(), "q_max_abs": np.abs(q_seg).max()}
return res, q_segThe first strategy quotes symmetrically around the mid, at the half spread that maximizes each side's revenue, without looking at inventory:
def simetrica(medio_s, q, s):
return medio_s - 1 / k, medio_s + 1 / k
res_sim, q_sim = simular(simetrica)
pd.Series(res_sim).round(1)resultado 26169.0
horquilla 41382.5
inventario -15213.5
sel_adversa_30s -4709.5
ejecuciones 12820.0
pct_informadas 17.1
q_desv 3553.9
q_max_abs 15500.0
dtype: float64The captured spread is 41,383 USD over 12,820 fills of 100 shares: about 3.2 cents per share, because the 2.83-cent quote is rounded outward to the tick and the mid is a multiple of half a cent. Inventory takes 15,214 USD: without control, the position drifts like a random walk and reaches 15,500 shares in absolute value, with a standard deviation of 3,554. The final result, 26,169 USD, depends mainly on which way the price moved while inventory was large. Adverse selection, measured separately, is −4,710 USD: 17.1% of fills are against informed traders, more than their 10% of the flow, because they reach farther.
Avellaneda-Stoikov: reservation price and optimal spread
Avellaneda and Stoikov solve the problem of a market maker with risk aversion , inventory and a horizon at which it closes the position. Their approximate solution has two pieces. The first is the reservation price, the price at which the market maker would be indifferent between buying or selling one more unit:
With positive inventory, sits below the mid ; with negative inventory, above it. The second is the optimal spread around :
The first term is a premium for inventory risk, larger the more of the session remains; the second depends on the shape of demand () and, with small , tends to , the symmetric spread from before. The difference between the two strategies lies mainly in the inventory shift.
Mermaid source
flowchart TB Q[Inventory q] --> R[Reservation price] S[Mid s] --> R V[σ, γ, T - t] --> R R --> B[Bid = r - δ] R --> A[Ask = r + δ] K[k, γ] --> D[Half spread δ] D --> B D --> A
The volatility of the mid is 0.0065 USD per √s (the generator's). With in shares, has units of 1/USD and small values: with and 100 shares, the shift at the start of the session is one cent.
SIGMA = 0.0065
def avellaneda_stoikov(gamma):
def cotizar(medio_s, q, s):
resto = T - s
r = medio_s - q * gamma * SIGMA**2 * resto
semi = 0.5 * (gamma * SIGMA**2 * resto
+ 2 / gamma * np.log(1 + gamma / k))
return r - semi, r + semi
return cotizar
res_as, q_as = simular(avellaneda_stoikov(1e-4))
pd.DataFrame({"symmetric": res_sim,
"AS γ=1e-4": res_as}).round(1) symmetric AS γ=1e-4
resultado 26169.0 38055.0
horquilla 41382.5 39647.5
inventario -15213.5 -1592.5
sel_adversa_30s -4709.5 -4453.0
ejecuciones 12820.0 13165.0
pct_informadas 17.1 16.6
q_desv 3553.9 225.7
q_max_abs 15500.0 1700.0With the same session and almost the same captured spread (39,648 versus 41,383 USD), the Avellaneda-Stoikov market maker cuts the inventory loss from 15,214 to 1,593 USD and its result rises to 38,055. The standard deviation of inventory drops from 3,554 to 226 shares and the maximum, from 15,500 to 1,700. Adverse selection barely changes (−4,453 versus −4,710): inventory control does not protect against the informed, who keep arriving and keep moving the price. It is a loss that can only be reduced by charging more spread or by detecting toxic flow.
The header figure shows the difference: the symmetric strategy's inventory wanders across thousands of shares for the whole session; Avellaneda-Stoikov's, highlighted, oscillates near zero, because each buy lowers the reservation price and makes the next sell more likely. The figure below breaks down the result of both.
barrido = {g: simular(avellaneda_stoikov(g))[0]
for g in [2e-5, 1e-4, 5e-4, 2e-3]}
pd.DataFrame(barrido).T[["resultado", "horquilla", "inventario",
"sel_adversa_30s", "q_desv"]].round(0) resultado horquilla inventario sel_adversa_30s q_desv
0.00002 37652.0 40854.0 -3202.0 -4615.0 563.0
0.00010 38055.0 39647.0 -1592.0 -4453.0 226.0
0.00050 22447.0 22923.0 -475.0 -4457.0 136.0
0.00200 -76819.0 -76500.0 -319.0 -4224.0 105.0With control is looser (a deviation of 563 shares) and the result similar; with , inventory barely moves (136), but the captured spread halves: the reservation price moves so far from the mid, with little inventory, that the market maker gives away part of the spread to get rid of it. With the spread is negative (−76,500 USD): with 100 shares of inventory, the shift exceeds the half spread and one of the quotes crosses the mid. The market maker buys above the mid or sells below it on every fill. is not a parameter that can be chosen by looking at risk alone.
Inventory risk over many sessions
A single session does not say how much the result varies. With the estimated parameters many can be simulated: the mid as a random walk with volatility σ per second and, in each second, a fill on each side with probability (at least one order reaching the quote). These sessions have no informed orders, so they isolate inventory risk. 200 sessions are simulated at once, vectorized with NumPy, with a fixed seed.
def sesiones(gamma, n=200, semilla=7):
rng = np.random.default_rng(semilla)
s_m = np.full(n, 48.0)
q, caja = np.zeros(n), np.zeros(n)
for s in range(T):
resto = T - s
if gamma == 0:
r, semi = s_m, 1 / k
else:
r = s_m - q * gamma * SIGMA**2 * resto
semi = 0.5 * (gamma * SIGMA**2 * resto
+ 2 / gamma * np.log(1 + gamma / k))
bid = np.floor((r - semi) / TICK + 1e-9) * TICK
ask = np.ceil((r + semi) / TICK - 1e-9) * TICK
p_ask = 1 - np.exp(-A * np.exp(-k * np.maximum(ask - s_m, 0)))
p_bid = 1 - np.exp(-A * np.exp(-k * np.maximum(s_m - bid, 0)))
vende = rng.random(n) < p_ask
compra = rng.random(n) < p_bid
q += LOTE * (compra.astype(float) - vende)
caja += LOTE * (ask * vende - bid * compra)
s_m = s_m + SIGMA * rng.standard_normal(n)
return caja + q * s_m, q
mc = {}
for nombre, g in [("symmetric", 0), ("AS γ=2e-5", 2e-5),
("AS γ=1e-4", 1e-4), ("AS γ=5e-4", 5e-4)]:
pyg, q_fin = sesiones(g)
mc[nombre] = {"media": pyg.mean(), "desv": pyg.std(),
"peor": pyg.min(),
"q_final_abs": np.abs(q_fin).mean()}
pd.DataFrame(mc).T.round(0) media desv peor q_final_abs
symmetric 36890.0 6385.0 6774.0 6732.0
AS γ=2e-5 36362.0 564.0 35028.0 1248.0
AS γ=1e-4 35344.0 398.0 34230.0 777.0
AS γ=5e-4 27902.0 309.0 27095.0 538.0Over 200 sessions without informed traders, the symmetric strategy earns on average 36,890 USD with a standard deviation of 6,385, and its worst session leaves 6,774; the average inventory at the close is 6,732 shares. Avellaneda-Stoikov with earns somewhat less on average (35,344), but with a standard deviation 16 times smaller (398) and a worst session of 34,230. With dispersion keeps falling and the mean drops to 27,902. It is the trade-off the model formalizes: less inventory risk in exchange for less spread.
It is striking that inventory at the close is not zero with Avellaneda-Stoikov (777 shares on average with ). The formula multiplies inventory by , which tends to zero at the end of the session: in the last minutes the market maker stops caring about the position, just when it has to close it. It is a known flaw of the finite-horizon version.
From the model to the market
The Avellaneda and Stoikov model is the starting point of the market making literature, not a ready-made strategy. Guéant, Lehalle and Fernandez-Tapia (2013) solve the same problem with an inventory limit and obtain approximations of the optimal quotes that, far from the end of the horizon, do not depend on time, which avoids the formula's flaw near the close. A real market also has pieces that the simulation ignores:
- Competition and queue. Other market makers quote at the same level and price-time priority decides who gets filled (lessons 1.1.3 and 2.4.8). Competition narrows the spread until the expected profit is small.
- Latency. A quote that is not updated in time is the one the informed pick off: real adverse selection depends heavily on speed.
- Fees and rebates. Exchanges charge for taking liquidity and often pay a rebate for providing it; for a market maker, that rebate can be a large part of the margin.
- Overnight inventory and jumps. The position left at the close is exposed to the opening gap (lesson 2.3.2), and a news item can move the price many spreads at once.
Exercises
- Repeat the symmetric strategy with half spreads of 1, 2, 3 and 5 cents in the MMQ session. Which half spread gives the most captured spread and which the best result?
Solution
def fija(d):
return lambda medio_s, q, s: (medio_s - d, medio_s + d)
filas = {f"{100 * d:.0f} cent": simular(fija(d))[0]
for d in [0.01, 0.02, 0.03, 0.05]}
pd.DataFrame(filas).T[["resultado", "horquilla", "inventario",
"ejecuciones", "pct_informadas"]].round(0) resultado horquilla inventario ejecuciones pct_informadas
1 cent 14146.0 32910.0 -18764.0 26842.0 12.0
2 cent 20662.0 40980.0 -20317.0 18403.0 14.0
3 cent 26169.0 41382.0 -15213.0 12820.0 17.0
5 cent 22838.0 32584.0 -9745.0 6227.0 23.0The captured spread peaks between 2 and 3 cents (40,980 and 41,382 USD), close to : with one cent there are twice as many fills but very little is earned on each; with 5, a lot is earned but the quote is filled half as often as with 3. The best result is at 3 cents, although in a single session the inventory term (from −9,745 to −20,317 USD) moves it so much that the order could change on another day. The share of informed orders rises from 12% to 23% as the quote moves away: the wider the spread, the more toxic the flow that reaches it.
- Check the decomposition: for the Avellaneda-Stoikov market maker, spread plus inventory must add up to the final result. What part of inventory does adverse selection explain?
Solution
print(f"spread + inventory = "
f"{res_as['horquilla'] + res_as['inventario']:.1f}")
print(f"result = {res_as['resultado']:.1f}")
print(f"adverse selection (30 s, informed) = "
f"{res_as['sel_adversa_30s']:.1f}")spread + inventory = 38055.0
result = 38055.0
adverse selection (30 s, informed) = -4453.0The sum gives exactly the result, 38,055 USD: spread and inventory are a complete accounting decomposition. Adverse
selection, −4,453 USD, measures how much the positions opened against informed traders lose in 30 seconds.
Avellaneda-Stoikov's total inventory is −1,593 USD, less negative than that figure: the rest of the inventory moves
gained something. With real data there is no informada column, so adverse selection is estimated with the markout of
all fills, which mixes the informed with the noise.
- A colleague proposes “removing” adverse selection from the simulator by never quoting in the 30 seconds after an informed order. What is wrong with the proposal?
Solution
It uses information the market maker does not have: the informada column exists because the data is synthetic, but
in a real market nobody knows which order is informed when it arrives. It is look-ahead in its purest form. What can
be done is to estimate toxicity from what is observable (order size, reach, recent flow imbalance, mid move after
previous fills) and widen the spread or step back when it rises; and that estimate has to be tested without looking at
the informada column, which only serves to check afterwards whether the method gets it right.
Each answer is folded under its question
Self-assessment
With λ(δ) = 0.87·exp(−35.4·δ), which half spread maximizes the expected revenue per second of one side, δ·λ(δ), with no inventory risk?
- AOne tick (1 cent)
- Bδ = 1/k ≈ 2.8 cents
- CThe wider, the better
- DHalf of the market's bid-ask spread
Show answer
Answer B. δ = 1/k ≈ 2.8 cents
The derivative of δ·A·exp(−kδ) is zero at δ = 1/k. Closer to the mid, the quote is filled more often but earns less per fill; farther away, the opposite. It is also the limit of the Avellaneda-Stoikov spread when risk aversion tends to zero.
What does the Avellaneda-Stoikov reservation price do when the market maker is long 500 shares?
- AIt rises, to buy more
- BIt does not change: only the spread changes
- CIt drops below the mid: the bid and the ask move down, so it is easier to sell and harder to buy, and inventory tends to return to zero
- DIt equals the last traded price
Show answer
Answer C. It drops below the mid: the bid and the ask move down, so it is easier to sell and harder to buy, and inventory tends to return to zero
r = s − q·γ·σ²·(T − t). With q > 0, r < s. Both quotes shift down with r: the ask ends up closer to the mid (it is filled more) and the bid farther away (it is filled less).
In the simulated session, the 30-second markout of informed orders is +2.0 cents and that of uninformed orders, 0.0. What does it mean for the market maker?
- AEvery time an informed order fills it, the mid moves 2 cents against it over the next 30 seconds: it loses what it earned from the spread and then some
- BNothing: it is noise
- CIt should trade only with informed orders
- DThe mid is trending
Show answer
Answer A. Every time an informed order fills it, the mid moves 2 cents against it over the next 30 seconds: it loses what it earned from the spread and then some
It is the adverse selection of Glosten and Milgrom (1985): those who know more trade against the market maker just when the price is about to move. Since it cannot tell who is who, the market maker has to charge everyone a spread that makes up for what it loses to the informed.
With a γ that is too high, the lesson's Avellaneda-Stoikov market maker loses money on the spread. Why?
- ABecause the formula is wrong
- BBecause it gets filled less
- CBecause costs are higher
- DBecause with inventory the reservation price moves so far from the mid that one of the quotes ends up on the other side: it buys above the mid or sells below it
Show answer
Answer D. Because with inventory the reservation price moves so far from the mid that one of the quotes ends up on the other side: it buys above the mid or sells below it
The shift q·γ·σ²·(T − t) grows with γ and with inventory. When it exceeds the half spread, the market maker pays to get rid of inventory instead of earning. Real implementations cap that shift and the maximum inventory.
Why is the result of the lesson's simulation much better than what a real market maker would get?
- ABecause the data covers a single day
- BBecause it assumes its quote is the only one in the book and is always filled in full whenever the order reaches its price: no queue, no competition for priority, no commissions or latency
- CBecause it uses the mid price
- DBecause there are no informed orders
Show answer
Answer B. Because it assumes its quote is the only one in the book and is always filled in full whenever the order reaches its price: no queue, no competition for priority, no commissions or latency
In a real book there are other market makers at the same level and price-time priority decides who gets filled (lesson 2.4.8). Competition narrows the spread until the expected profit approaches zero, and latency means stale quotes are the ones the informed pick off.
Key points
- Fill intensity falls exponentially with distance from the mid: in the session, λ(δ) = 0.87·exp(−35.4·δ) with all the flow, versus 0.81·exp(−40·δ) for uninformed orders.
- A market maker earns the spread and loses through inventory and adverse selection; the result breaks down into those three parts.
- Informed orders move the mid 2 cents in 30 seconds; uninformed ones, nothing: the markout measures adverse selection.
- Avellaneda-Stoikov shifts both quotes with inventory (reservation price) and sets a spread that depends on γ, σ and k; it cuts the standard deviation of inventory from thousands of shares to hundreds.
- With γ too high the reservation price crosses the mid and the market maker pays to get rid of inventory.
- The simulation assumes a single quote, with no queue, commissions or latency: it is a tool for understanding, not a profit estimate.
Resources
Documentation
- Avellaneda and Stoikov (2008): High-frequency trading in a limit order book, Quantitative Finance 8(3)
- Glosten and Milgrom (1985): Bid, ask and transaction prices in a specialist market with heterogeneously informed traders, Journal of Financial Economics 14(1)
- Guéant, Lehalle and Fernandez-Tapia (2013): Dealing with the inventory risk, Mathematics and Financial Economics 7(4)