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.

Fig. — Market maker inventory in the MMQ session: symmetric quotes against Avellaneda-Stoikov (γ = 1e-4)
06/07
Module
2.3 · Strategy families
Duration
70 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 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).

one sidebothMid priceQuote bid and askFill?InventorySpread earnedPrice risk
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 --> C

The 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:

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

IN [01]python
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)
OUT [01]MMQ market orders and mid price
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.691

In 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

λ(δ)=A e−kδ.\lambda(\delta) = A\, e^{-k\delta}.

AA is the intensity of a quote at the mid itself and kk 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 −k-k.

IN [02]python
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")
OUT [02]Estimating A and k by side
                   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 cents

With uninformed orders, the estimate recovers the planted parameters almost exactly: AA between 0.82 and 0.84 per second (truth 0.81) and kk between 40.0 and 40.2 per USD (truth 40). With all the flow, which is all a real market maker sees, AA rises to 0.87 and kk 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, 1/k1/k, is 2.83 cents.

Fig. — Fill intensity of a quote by its distance from the mid in the MMQ session, with the exponential fit

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 hh seconds: sign×(mt+h−mt)\text{sign} \times (m_{t+h} - m_t). 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.

IN [03]python
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)
OUT [03]Mid markout after informed and uninformed orders
    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.009

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

Fig. — Adverse selection: average mid move in the order's direction over the following 60 seconds

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, ∑tqt (mt+1−mt)\sum_t q_t\,(m_{t+1} - m_t); 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.

IN [04]python
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_seg

The first strategy quotes symmetrically around the mid, at the half spread 1/k1/k that maximizes each side's revenue, without looking at inventory:

IN [05]python
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)
OUT [05]Symmetric quotes at 1/k from the mid
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: float64

The 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 γ\gamma, inventory qq and a horizon TT 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:

rt=st−qt γ σ2 (T−t).r_t = s_t - q_t\,\gamma\,\sigma^2\,(T - t).

With positive inventory, rr sits below the mid ss; with negative inventory, above it. The second is the optimal spread around rr:

δa+δb=γ σ2 (T−t)+2γln⁡(1+γk).\delta^a + \delta^b = \gamma\,\sigma^2\,(T - t) + \frac{2}{\gamma}\ln\left(1 + \frac{\gamma}{k}\right).

The first term is a premium for inventory risk, larger the more of the session remains; the second depends on the shape of demand (kk) and, with small γ\gamma, tends to 2/k2/k, the symmetric spread from before. The difference between the two strategies lies mainly in the inventory shift.

Inventory qReservation priceMid sσ, γ, T - tBid = r - δAsk = r + δk, γHalf spread δ
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 qq in shares, γ\gamma has units of 1/USD and small values: with γ=10−4\gamma = 10^{-4} and 100 shares, the shift at the start of the session is one cent.

IN [06]python
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)
OUT [06]Avellaneda-Stoikov with γ = 1e-4
                 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.0

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

Fig. — Result of the two market makers in the MMQ session and its components, in thousand USD
IN [07]python
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)
OUT [07]Sensitivity to γ
         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.0

With γ=2×10−5\gamma = 2 \times 10^{-5} control is looser (a deviation of 563 shares) and the result similar; with 5×10−45 \times 10^{-4}, 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 2×10−32 \times 10^{-3} 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. γ\gamma 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 1−e−λ(δ)1 - e^{-\lambda(\delta)} (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.

IN [08]python
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)
OUT [08]200 simulated sessions: result and dispersion
             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.0

Over 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 γ=10−4\gamma = 10^{-4} earns somewhat less on average (35,344), but with a standard deviation 16 times smaller (398) and a worst session of 34,230. With γ=5×10−4\gamma = 5 \times 10^{-4} 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 γ=10−4\gamma = 10^{-4}). The formula multiplies inventory by T−tT - t, 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

  1. 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
IN [09]python
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)
OUT [09]Sweep of the symmetric half spread
        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.0

The captured spread peaks between 2 and 3 cents (40,980 and 41,382 USD), close to 1/k1/k: 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.

  1. 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
IN [10]python
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}")
OUT [10]Spread + inventory = result
spread + inventory = 38055.0
result             = 38055.0
adverse selection (30 s, informed) = -4453.0

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

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

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

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

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

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

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