---
title: "2.3.6 Market making: inventory, spread, Avellaneda-Stoikov"
description: "Arrival intensity, symmetric quotes versus Avellaneda-Stoikov, inventory risk and adverse selection with a synthetic trading session."
url: "https://pythonparatrading.com/en/python-trading-course/level-2/market-making-avellaneda-stoikov/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-2/market-making-avellaneda-stoikov.png"
---

# 2.3.6 · 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.

- Course: [Level 2: Systematic strategies and rigorous backtesting](https://pythonparatrading.com/en/python-trading-course/level-2/)
- Module: 2.3 · Strategy families
- Duration: 70 min
- Prerequisites: [1.1.1 · Market participants and trading venues](https://pythonparatrading.com/en/python-trading-course/level-1/market-participants-and-trading-venues/), [1.1.3 · Order book: bid, ask, spread and depth](https://pythonparatrading.com/en/python-trading-course/level-1/order-book-bid-ask-spread-depth/), [1.1.4 · Order types](https://pythonparatrading.com/en/python-trading-course/level-1/order-types-explained/)

## By the end of this lesson

- Estimate the fill intensity of a quote at distance δ from the mid, λ(δ) = A·exp(−kδ), from the order flow.
- Measure adverse selection with the markout at several horizons of informed and uninformed orders.
- Simulate a market maker with symmetric quotes and another with the Avellaneda-Stoikov reservation price and optimal spread.
- Break the result down into captured spread, inventory and adverse selection, and measure inventory risk with simulated sessions.

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

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

<aside class="callout" data-kind="aviso">

The data is **synthetic**: one session of a fictitious stock, `MMQ`, on March 12, 2025. The generator follows the
Avellaneda and Stoikov model with known parameters: market orders as a Poisson process of 0.9 per second and side;
uninformed orders with an exponential reach of mean 1/k (k = 40 per USD); and 10% of informed orders that move the mid
2 cents over the next 30 seconds. It serves to check whether the methods recover those parameters. The market maker
simulations are deliberately optimistic: there is no competition in the queue, no commissions and no latency.

</aside>

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

```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)
```
```output
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

$$
\lambda(\delta) = A\, e^{-k\delta}.
$$

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

```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")
```
```output
                   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: $A$ between 0.82 and 0.84 per
second (truth 0.81) and $k$ between 40.0 and 40.2 per USD (truth 40). With all the flow, which is all a real market
maker sees, $A$ rises to 0.87 and $k$ 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/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.* Points on a log scale: fills per second and side that a quote would get at 0, 0.5, 1… up to 10 cents from the mid, from about 0.90 at the mid to 0.027 at 10 cents. They fall almost on a highlighted straight line, the fit 0.87 · exp(−35.4 · δ). A label recalls the planted intensity of uninformed orders, 0.81 · exp(−40 · δ).

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 $h$ seconds:
$\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.

```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)
```
```output
    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.* Two curves. After informed orders, highlighted, the mid moves in their direction almost linearly up to 2.0 cents at 30 seconds, marked by a vertical line, and then stays flat. After uninformed orders, the curve stays at zero.

## 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,
$\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.

```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/k$ that maximizes each side's
revenue, without looking at inventory:

```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)
```
```output
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 $q$ and a horizon $T$
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:

$$
r_t = s_t - q_t\,\gamma\,\sigma^2\,(T - t).
$$

With positive inventory, $r$ sits below the mid $s$; with negative inventory, above it. The second is the **optimal
spread** around $r$:

$$
\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 ($k$) and, with small $\gamma$, tends to $2/k$, the symmetric spread from before. The difference
between the two strategies lies mainly in the inventory shift.

```mermaid
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 $q$ in shares, $\gamma$ has units of 1/USD
and small values: with $\gamma = 10^{-4}$ and 100 shares, the shift at the start of the session is one cent.

```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)
```
```output
                 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.* Horizontal bars; AS = Avellaneda-Stoikov. Symmetric: spread +41.4, inventory −15.2, result +26.2, 30-second adverse selection −4.7. Avellaneda-Stoikov: spread +39.6, inventory −1.6, result +38.1 (highlighted), adverse selection −4.5. Adverse selection is part of inventory: the result is spread plus inventory.

```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)
```
```output
         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 $\gamma = 2 \times 10^{-5}$ control is looser (a deviation of 563 shares) and the result similar; with
$5 \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 \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^{-\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.

```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)
```
```output
             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 $\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 $\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
$\gamma = 10^{-4}$). The formula multiplies inventory by $T - 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.

<aside class="callout" data-kind="riesgo">

Market making is an activity with very thin margins on many trades, with the risk of large losses within a few
minutes. It requires low-latency infrastructure, direct market access and automated risk control. The results in this
lesson come from an optimistic simulation on synthetic data and do not estimate what anyone would earn.

</aside>

## 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.**

```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)
```
```output
        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/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.

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

```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}")
```
```output
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.

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

## 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?

   - A. One tick (1 cent)
   - B. δ = 1/k ≈ 2.8 cents
   - C. The wider, the better
   - D. Half of the market's bid-ask spread

   *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?

   - A. It rises, to buy more
   - B. It does not change: only the spread changes
   - 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
   - D. It equals the last traded price

   *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?

   - 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
   - B. Nothing: it is noise
   - C. It should trade only with informed orders
   - D. The mid is trending

   *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?

   - A. Because the formula is wrong
   - B. Because it gets filled less
   - C. Because costs are higher
   - 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

   *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?

   - A. Because the data covers a single day
   - 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
   - C. Because it uses the mid price
   - D. Because there are no informed orders

   *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

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min
- [Quantitative Models and Algorithmic Execution](https://pythonparatrading.com/en/quantitative-models-algorithmic-execution/) · Research-0009 · 11 min

### Documentation

- [Avellaneda and Stoikov (2008): High-frequency trading in a limit order book, Quantitative Finance 8(3)](https://doi.org/10.1080/14697680701381228) · doi.org
- [Glosten and Milgrom (1985): Bid, ask and transaction prices in a specialist market with heterogeneously informed traders, Journal of Financial Economics 14(1)](https://doi.org/10.1016/0304-405X(85)90044-3) · doi.org
- [Guéant, Lehalle and Fernandez-Tapia (2013): Dealing with the inventory risk, Mathematics and Financial Economics 7(4)](https://doi.org/10.1007/s11579-012-0087-0) · doi.org

### Data

- [Level 2 data: market orders in the MMQ session (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/mm2_flujo.parquet) · Download · mm2_flujo.parquet
- [Level 2 data: MMQ mid price per second (Parquet)](https://pythonparatrading.com/curso/datos/nivel-2/mm2_medio.parquet) · Download · mm2_medio.parquet

Previous lesson: [2.3.5 · Factors (value, quality, low volatility, size) and event strategies](https://pythonparatrading.com/en/python-trading-course/level-2/factor-investing-and-event-strategies/)

Next lesson: [2.3.7 · Crypto-specific strategies: momentum, funding arbitrage and basis](https://pythonparatrading.com/en/python-trading-course/level-2/crypto-momentum-funding-basis-strategies/)
