Earning the spread, carrying the risk
A market maker posts a bid and an ask and waits. When a seller hits the bid and a buyer lifts the ask, the maker buys low and sells high and keeps the spread. That is the income. The cost is inventory risk: between the two trades the maker holds a position whose value moves with the price, and the maker is more often filled by someone who knows more (adverse selection) than by someone who does not. Good market making is a balance between quoting tight enough to be hit often and wide enough to be paid for the risk, while keeping the inventory near zero.
# The income: buy at the bid, sell at the ask, keep the spread
bid, ask, size = 99.95, 100.05, 100
cash = -bid * size + ask * size
print(round(cash, 2)) # 10.0 a round trip of 100 shares earned the 0.10 spread
# The risk: between the two trades the price moves against the inventory
inventory, mid_after = 100, 99.80
print(round(inventory * (mid_after - bid), 2)) # -15.0 the position lost more than the spread would have earnedAvellaneda-Stoikov: where to quote
The Avellaneda-Stoikov model gives a closed-form answer. First, the reservation price r = mid - q * gamma * sigma^2 * tau is where the maker would be happy to trade given the inventory q: a long maker (q > 0) should value the asset below the mid and quote lower to attract buyers, a short maker should quote higher. Risk aversion gamma, volatility sigma and time left tau set how hard to lean. Second, the optimal spread gamma * sigma^2 * tau + (2 / gamma) * ln(1 + gamma / k) has two parts: a charge for the risk of holding the position until the horizon, and a term for the order flow, where k says how quickly fills dry up as you quote farther from the mid. The quotes are r - spread/2 and r + spread/2.
# The reservation price leans against inventory: long -> quote lower, short -> quote higher
def reservation_price(mid, inventory, gamma, sigma, tau):
return mid - inventory * gamma * sigma ** 2 * tau
for inventory in (-50, 0, 50):
print(inventory, round(reservation_price(100.0, inventory, 0.1, 0.2, 1.0), 4))
# -50 100.2 short: quote higher to buy back
# 0 100.0 flat: centred on the mid
# 50 99.8 long: quote lower to sell# The optimal spread and the two quotes around the reservation price
import math
def optimal_spread(gamma, sigma, tau, k):
return gamma * sigma ** 2 * tau + (2 / gamma) * math.log(1 + gamma / k)
def make_quotes(mid, inventory, gamma, sigma, tau, k):
centre = mid - inventory * gamma * sigma ** 2 * tau
half = optimal_spread(gamma, sigma, tau, k) / 2
return centre - half, centre + half
print(round(optimal_spread(0.1, 0.2, 1.0, 1.5), 4)) # 1.2948
bid, ask = make_quotes(100.0, 0, 0.1, 0.2, 1.0, 1.5)
print(round(bid, 4), round(ask, 4)) # 99.3526 100.6474 symmetric around the mid when flat
bid, ask = make_quotes(100.0, 50, 0.1, 0.2, 1.0, 1.5)
print(round(bid, 4), round(ask, 4)) # 99.1526 100.4474 long 50: both quotes move down by 0.2Inventory, fills and the P&L
Track the state as two numbers, inventory and cash. When the bid is hit you bought: inventory rises and cash falls by price * quantity; when the ask is hit you sold: the reverse. Your wealth at any moment is cash plus inventory marked at the mid, cash + inventory * mid, and the change in it is your P&L. That P&L comes from the spread captured and from the price moves while holding inventory; quoting around the reservation price pulls inventory back to zero because the side that reduces it becomes more attractive. Limits matter in practice: a hard cap on inventory, a pause in fast markets, and a check on every quote that the bid is below the ask.
# Booking fills and marking the book to market
def apply_fill(state, side, price, quantity):
inventory, cash = state
if side == "bid":
return inventory + quantity, cash - price * quantity
return inventory - quantity, cash + price * quantity
state = (0, 0.0)
state = apply_fill(state, "bid", 99.95, 100) # our bid is hit: we buy 100
print(state) # (100, -9995.0)
state = apply_fill(state, "ask", 100.05, 100) # our ask is hit: we sell 100
print(state) # (0, 10.0) flat again, and the spread is the profit
inventory, cash = apply_fill(state, "bid", 99.9, 50)
print(round(cash + inventory * 99.9, 2)) # 10.0 marked at the price we just paid, the equity is unchanged- A market maker earns the spread but carries inventory risk and adverse selection; the aim is tight enough to trade, wide enough to be paid, and inventory near zero.
- Reservation price
mid - q * gamma * sigma^2 * tauleans against inventory; the spreadgamma * sigma^2 * tau + (2/gamma) ln(1 + gamma/k)grows with risk aversion-weighted volatility and time. - Track inventory and cash, mark to the mid, enforce hard inventory limits, and test the strategy in a simulator on recorded data before using it live.
Next up: the Heston model, volatility that is itself random.
Lab · what's graded
- Lean against inventory with
reservation_price25 pts - Set the width with
optimal_spread25 pts - Quote both sides with
make_quotes25 pts - Book a fill with
apply_fill25 pts