High-frequency Trading in a Limit Order Book
Marco Avellaneda, Sasha Stoikov
Quantitative Finance, 2008 10.1080/14697680701381228
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.
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
- — mid-price of the asset, modelled as arithmetic Brownian motion:
- — the market maker's inventory (shares held, positive = long, negative = short)
- — cash (wealth excluding the position value)
Total wealth:
Control Variables
The dealer controls the spreads around the mid-price:
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:
where:
- — baseline arrival rate
- — 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:
subject to the inventory and wealth dynamics, where is the absolute risk aversion parameter.
The HJB Equation
Define the value function . By the dynamic programming principle, it satisfies the Hamilton-Jacobi-Bellman (HJB) PDE:
Terminal condition:
The Solution: Closed-Form Quote Schedules
Ansatz
Conjecture the form:
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):
The term is the inventory penalty: a long position () causes the dealer to shade their quotes downward to offload inventory.
Optimal Spreads
The optimal bid-ask half-spread is:
The total optimal spread posted around the reservation price is:
Notably, the spread has two components:
- — adverse selection / inventory risk component (shrinks as approaches)
- — intrinsic spread (independent of time, driven by market depth )
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_spreadLet'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 , (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 (), 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 → 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
: the dealer is risk-neutral and only posts the intrinsic spread .
: the dealer is extremely risk-averse, quotes extremely wide to avoid any inventory accumulation.
4. Spread narrows near horizon
As , the 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
| Limitation | Extension |
|---|---|
| Symmetric Poisson arrival (same 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 asset | Guéant (2017) — multi-asset market making |
| No queue dynamics | Laruelle, 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:
- : estimate from realized variance over a short rolling window (e.g., 5-minute bars)
- : estimate from LOB data by regressing on spread-to-mid distance
- : often calibrated via Sharpe ratio targeting or set by risk limits on intra-day inventory exposure
- : usually the trading session length (6.5 hours for US equities) or a rolling window
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_spreadimport 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=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}")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}")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}")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}")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 and fill probability , exactly as in the A&S (2008) paper. All ten parameters are adjustable in real time.
Best viewed on a desktop browser. Simulation runs entirely in your browser — no server compute.