Sameer Siddiqui
Back to Research

High-frequency Trading in a Limit Order Book

Marco Avellaneda, Sasha Stoikov

Quantitative Finance, 2008 10.1080/14697680701381228

Market Microstructure

High-frequency Trading in a Limit Order Book

The landmark model for optimal market making under inventory constraints — deriving closed-form bid and ask quote schedules via stochastic control theory.

7 min read
Market MakingLimit Order BookHFTStochastic ControlInventory Risk

Overview

Avellaneda & Stoikov (2008) is the foundational paper for rigorous market making theory. It formulates the market maker's problem as a stochastic optimal control problem: the dealer continuously posts bid and ask quotes to maximise terminal wealth while managing the risk of holding an undesirable inventory position. The result is an elegant closed-form solution that every serious quant in market microstructure needs to understand.


The Setup

State Variables

  • StS_t — mid-price of the asset, modelled as arithmetic Brownian motion: dSt=σdWtdS_t = \sigma \, dW_t
  • qtZq_t \in \mathbb{Z} — the market maker's inventory (shares held, positive = long, negative = short)
  • xtx_tcash (wealth excluding the position value)

Total wealth: Wt=xt+qtStW_t = x_t + q_t S_t

Control Variables

The dealer controls the spreads around the mid-price:

δta=StaSt(ask spread)δtb=StStb(bid spread)\begin{aligned} \delta_t^a &= S_t^a - S_t \quad \text{(ask spread)} \\ \delta_t^b &= S_t - S_t^b \quad \text{(bid spread)} \end{aligned}

Order Arrival Model

Limit orders are filled by incoming market orders modelled as Poisson processes. The arrival intensity of buy (hitting the ask) and sell (hitting the bid) market orders is:

λa(δa)=Aekδaλb(δb)=Aekδb\begin{aligned} \lambda^a(\delta^a) = A e^{-k\delta^a} \\ \lambda^b(\delta^b) = A e^{-k\delta^b} \end{aligned}

where:

  • A>0A > 0 — baseline arrival rate
  • k>0k > 0 — sensitivity of arrival intensity to the spread (empirically estimated from order book data)

This captures the trade-off: tighter spreads attract more order flow but reduce per-trade profit.


The Optimisation Problem

The dealer solves a finite-horizon utility maximisation:

maxδa,δbE ⁣[eγWT]\begin{aligned} \max_{\delta^a, \delta^b} \mathbb{E}\!\left[-e^{-\gamma W_T}\right] \end{aligned}

subject to the inventory and wealth dynamics, where γ>0\gamma > 0 is the absolute risk aversion parameter.

The HJB Equation

Define the value function u(t,x,q,S)u(t, x, q, S). By the dynamic programming principle, it satisfies the Hamilton-Jacobi-Bellman (HJB) PDE:

ut+σ222uS2+maxδa ⁣[λa(δa)(u(t,x+S+δa,q1,S)u)]+maxδb ⁣[λb(δb)(u(t,xS+δb,q+1,S)u)]=0\frac{\partial u}{\partial t} + \frac{\sigma^2}{2} \frac{\partial^2 u}{\partial S^2} + \max_{\delta^a}\!\left[\lambda^a(\delta^a)\left(u(t, x+S+\delta^a, q-1, S) - u\right)\right] + \max_{\delta^b}\!\left[\lambda^b(\delta^b)\left(u(t, x-S+\delta^b, q+1, S) - u\right)\right] = 0

Terminal condition: u(T,x,q,S)=eγ(x+qS)u(T, x, q, S) = -e^{-\gamma(x + qS)}


The Solution: Closed-Form Quote Schedules

Ansatz

Conjecture the form:

u(t,x,q,S)=eγ(x+qS+θ(t,q))\begin{aligned} u(t, x, q, S) = -e^{-\gamma(x + q S + \theta(t, q))} \end{aligned}

Substituting into the HJB and solving the resulting ODE system yields the reservation price (the market maker's subjective mid-price adjusted for inventory risk):

r(t,q)=Sqγσ2(Tt)\begin{aligned} r(t, q) = S - q \gamma \sigma^2 (T - t) \end{aligned}

The term qγσ2(Tt)-q \gamma \sigma^2 (T-t) is the inventory penalty: a long position (q>0q > 0) causes the dealer to shade their quotes downward to offload inventory.

Optimal Spreads

The optimal bid-ask half-spread is:

δ=12(γσ2(Tt)1+γk)+1γln ⁣(1+γk)\begin{aligned} \delta^* = \frac{1}{2}\left(\frac{\gamma \sigma^2(T-t)}{1 + \frac{\gamma}{k}}\right) + \frac{1}{\gamma} \ln\!\left(1 + \frac{\gamma}{k}\right) \end{aligned}

The total optimal spread posted around the reservation price is:

δ=12(γσ2(Tt)1+γk)+1γln ⁣(1+γk)\begin{aligned} \delta^* = \frac{1}{2}\left(\frac{\gamma \sigma^2(T-t)}{1 + \frac{\gamma}{k}}\right) + \frac{1}{\gamma} \ln\!\left(1 + \frac{\gamma}{k}\right) \end{aligned}
Spread=δa+δb=γσ2(Tt)+2γln ⁣(1+γk)\begin{aligned} \text{Spread} = \delta^a + \delta^b = \gamma \sigma^2 (T-t) + \frac{2}{\gamma}\ln\!\left(1 + \frac{\gamma}{k}\right) \end{aligned}

Notably, the spread has two components:

  1. γσ2(Tt)\gamma \sigma^2 (T-t)adverse selection / inventory risk component (shrinks as TT approaches)
  2. 2γln ⁣(1+γk)\frac{2}{\gamma}\ln\!\left(1 + \frac{\gamma}{k}\right)intrinsic spread (independent of time, driven by market depth kk)

Python Implementation

import numpy as np
from dataclasses import dataclass
 
@dataclass
class AvellanedaStoikov:
    """
    Optimal market making quotes under the Avellaneda-Stoikov (2008) model.
    """
    sigma: float   # Mid-price volatility (per unit time)
    gamma: float   # Market maker's absolute risk aversion
    k: float       # Order arrival decay (sensitivity to spread)
    T: float       # Total horizon (seconds, minutes, or days)
 
    def reservation_price(self, S: float, q: int, t: float) -> float:
        """Inventory-adjusted mid price (dealer's fair value)."""
        time_remaining = self.T - t
        return S - q * self.gamma * self.sigma**2 * time_remaining
 
    def optimal_spread(self, t: float) -> float:
        """Total optimal bid-ask spread."""
        time_remaining = self.T - t
        inventory_component = self.gamma * self.sigma**2 * time_remaining
        intrinsic_component = (2 / self.gamma) * np.log(1 + self.gamma / self.k)
        return inventory_component + intrinsic_component
 
    def quotes(self, S: float, q: int, t: float) -> tuple[float, float]:
        """Return (bid_price, ask_price) optimal quotes."""
        r = self.reservation_price(S, q, t)
        half_spread = self.optimal_spread(t) / 2
        return r - half_spread, r + half_spread

Let's simulate a market making session:

import matplotlib.pyplot as plt
 
mm = AvellanedaStoikov(sigma=2.0, gamma=0.1, k=1.5, T=1.0)
 
inventories = [-5, -2, 0, 2, 5]
times = np.linspace(0, 0.9, 100)
S = 100.0
 
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
 
# Reservation price shift vs inventory
for q in inventories:
    r_prices = [mm.reservation_price(S, q, t) for t in times]
    ax1.plot(times, [r - S for r in r_prices], label=f"q={q}")
ax1.set_xlabel("Time (fraction of T)")
ax1.set_ylabel("Reservation price shift (r - S)")
ax1.set_title("Inventory bias in reservation price")
ax1.legend(fontsize=8)
ax1.axhline(0, color="gray", lw=0.5, ls="--")
 
# Optimal spread over time
spreads = [mm.optimal_spread(t) for t in times]
ax2.plot(times, spreads)
ax2.set_xlabel("Time (fraction of T)")
ax2.set_ylabel("Total bid-ask spread")
ax2.set_title("Optimal spread (decreases near expiry)")
plt.tight_layout()

At t=0t = 0, q=0q = 0 (neutral inventory):

#S=100, q=0, t=0, T=1
bid, ask = mm.quotes(S=100.0, q=0, t=0.0)
print(f"Reservation price: {mm.reservation_price(S=100.0, q=0, t=0.0):.4f}")
print(f"Optimal spread:    {mm.optimal_spread(t=0.0):.4f}")
print(f"Bid: {bid:.4f}  |  Ask: {ask:.4f}")
Reservation price: 100.0000
Optimal spread:    1.6908
Bid: 99.1546  |  Ask: 100.8454

Key Insights

1. Inventory skews the fair value

When the dealer is long (q>0q > 0), their reservation price is below mid. They want to sell — so they shade their ask downward, and their bid downward. This creates asymmetric quotes that correct the inventory imbalance without market impact.

2. Spread widens with volatility

Higher σ\sigma → wider spread. This is consistent with empirical observation: dealers widen quotes in volatile markets to compensate for the increased adverse-selection risk.

3. Risk aversion controls spread scaling

γ0\gamma \to 0: the dealer is risk-neutral and only posts the intrinsic spread 2k\frac{2}{k}.
γ\gamma \to \infty: the dealer is extremely risk-averse, quotes extremely wide to avoid any inventory accumulation.

4. Spread narrows near horizon

As tTt \to T, the γσ2(Tt)\gamma\sigma^2(T-t) component vanishes. Near end-of-day, the inventory risk matters less (there's no time left to get hurt), so the dealer tightens quotes.


Limitations and Extensions

LimitationExtension
Symmetric Poisson arrival (same λ\lambda for buy/sell)Guéant, Lehalle & Fernandez-Tapia (2013) — general intensity functions
No adverse selection (informed traders)Cartea, Jaimungal & Penalva (2015) — incorporating toxic flow
Continuous quoting (no discrete tick sizes)Fodra & Pham (2015) — discrete LOB models
Single assetGuéant (2017) — multi-asset market making
No queue dynamicsLaruelle, Lehalle & Pagès (2011) — queue-reactive models

Avellaneda-Stoikov remains the canonical starting point. Every serious HFT market making system either uses this model directly or can trace its intellectual lineage back to it.


Implementation Notes

In practice, the parameters require careful estimation:

  • σ\sigma: estimate from realized variance over a short rolling window (e.g., 5-minute bars)
  • kk: estimate from LOB data by regressing log(fill rate)\log(\text{fill rate}) on spread-to-mid distance
  • γ\gamma: often calibrated via Sharpe ratio targeting or set by risk limits on intra-day inventory exposure
  • TT: usually the trading session length (6.5 hours for US equities) or a rolling window
A&S Model Implementation.ipynb
import numpy as np
from dataclasses import dataclass
 
@dataclass
class AvellanedaStoikov:
    """
    Optimal market making quotes under the Avellaneda-Stoikov (2008) model.
    """
    sigma: float # Mid-price volatility (per unit time)
    gamma: float # Market maker's absolute risk aversion
    k: float # Order arrival decay (sensitivity to spread)
    T: float # Total horizon (seconds, minutes, or days)
 
    def reservation_price(self, S: float, q: int, t: float) -> float:
        """Inventory-adjusted mid price (dealer's fair value)."""
        time_remaining = self.T - t
        return S - q * self.gamma * self.sigma**2 * time_remaining
 
    def optimal_spread(self, t: float) -> float:
        """Total optimal bid-ask spread."""
        time_remaining = self.T - t
        inventory_component = self.gamma * self.sigma**2 * time_remaining
        intrinsic_component = (2 / self.gamma) * np.log(1 + self.gamma / self.k)
        return inventory_component + intrinsic_component
 
    def quotes(self, S: float, q: int, t: float) -> tuple[float, float]:
        """Return (bid_price, ask_price) optimal quotes."""
        r = self.reservation_price(S, q, t)
        half_spread = self.optimal_spread(t) / 2
        return r - half_spread, r + half_spread
import matplotlib.pyplot as plt
 
mm = AvellanedaStoikov(sigma=2.0, gamma=0.1, k=1.5, T=1.0)
 
inventories = [-5, -2, 0, 2, 5]
times = np.linspace(0, 0.9, 100)
S = 100.0
 
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 4))
 
# Reservation price shift vs inventory
for q in inventories:
    r_prices = [mm.reservation_price(S, q, t) for t in times]
    ax1.plot(times, [r - S for r in r_prices], label=f"q={q}")
ax1.set_xlabel("Time (fraction of T)")
ax1.set_ylabel("Reservation price shift (r - S)")
ax1.set_title("Inventory bias in reservation price")
ax1.legend(fontsize=8)
ax1.axhline(0, color="gray", lw=0.5, ls="--")
 
# Optimal spread over time
spreads = [mm.optimal_spread(t) for t in times]
ax2.plot(times, spreads)
ax2.set_xlabel("Time (fraction of T)")
ax2.set_ylabel("Total bid-ask spread")
ax2.set_title("Optimal spread (decreases near expiry)")
plt.tight_layout()
Out [2]notebook output
At t=0, q=0 (neutral inventory):
#S=100, q=0, t=0, T=1
bid, ask = mm.quotes(S=100.0, q=0, t=0.0)
print(f"Reservation price: {mm.reservation_price(S=100.0, q=0, t=0.0):.4f}")
print(f"Optimal spread:    {mm.optimal_spread(t=0.0):.4f}")
print(f"Bid: {bid:.4f}  |  Ask: {ask:.4f}")
Out [3]
Reservation price: 100.0000
Optimal spread:    1.6908
Bid: 99.1546  |  Ask: 100.8454
# S=100, q=0, t=0, T=10
mm = AvellanedaStoikov(sigma=2.0, gamma=0.1, k=1.5, T=10.0)
bid, ask = mm.quotes(S=100.0, q=0, t=0.0)
print(f"Reservation price: {mm.reservation_price(S=100.0, q=0, t=0.0):.4f}")
print(f"Optimal spread:    {mm.optimal_spread(t=0.0):.4f}")
print(f"Bid: {bid:.4f}  |  Ask: {ask:.4f}")
Out [4]
Reservation price: 100.0000
Optimal spread:    5.2908
Bid: 97.3546  |  Ask: 102.6454
# S=100, q=0, t=5, T=10
mm = AvellanedaStoikov(sigma=2.0, gamma=0.1, k=1.5, T=10.0)
bid, ask = mm.quotes(S=100.0, q=0, t=5.0)
print(f"Reservation price: {mm.reservation_price(S=100.0, q=0, t=5.0):.4f}")
print(f"Optimal spread:    {mm.optimal_spread(t=5.0):.4f}")
print(f"Bid: {bid:.4f}  |  Ask: {ask:.4f}")
Out [5]
Reservation price: 100.0000
Optimal spread:    3.2908
Bid: 98.3546  |  Ask: 101.6454
# S=100, q=0, t=10, T=10
mm = AvellanedaStoikov(sigma=2.0, gamma=0.1, k=1.5, T=10.0)
bid, ask = mm.quotes(S=100.0, q=0, t=10.0)
print(f"Reservation price: {mm.reservation_price(S=100.0, q=0, t=10.0):.4f}")
print(f"Optimal spread:    {mm.optimal_spread(t=10.0):.4f}")
print(f"Bid: {bid:.4f}  |  Ask: {ask:.4f}")
Out [6]
Reservation price: 100.0000
Optimal spread:    1.2908
Bid: 99.3546  |  Ask: 100.6454

Live Simulator

The model described above is fully implemented below as an interactive browser simulation. The price process follows the exact discrete GBM solution with Itô correction. Order fills are modelled as two independent Poisson processes — one per side — with intensity λ(δ)=Aeκδ\lambda(\delta) = A e^{-\kappa\delta} and fill probability P(fill)=1eλdtP(\text{fill}) = 1 - e^{-\lambda \, dt}, exactly as in the A&S (2008) paper. All ten parameters are adjustable in real time.

▶ Interactive A&S Market Making SimulatorNew tab

Best viewed on a desktop browser. Simulation runs entirely in your browser — no server compute.

Mathematical Derivations

Open in new tab
A&S Model - Mathematical Derivation Proof (Hand Written)