The Math of Market Making: Avellaneda-Stoikov
Overview: Solving for Inventory Risk
This project implements the foundational market-making model proposed by Avellaneda & Stoikov (2006)
While most retail algorithms quote symmetrically around the mid-price, professional market makers must account for Inventory Risk— the danger of holding too much stock while the price moves against them.
Key Implementations:
- The Indifference Price: Derived from a CARA utility framework to determine the subjective valuation of an asset given current inventory levels.
- Optimal Calibration: Using the Hamilton-Jacobi-Bellman (HJB) equation to solve for the optimal distance of bid and ask quotes.
- Stress Testing: Expanding the model to handle Toxic Order Flow and Fat-Tail Jump Diffusion to reflect modern market microstructure.
Research & Simulation Notebook
The following notebook contains the full derivation of the simulation engine, the Monte Carlo logic, and the comparative performance metrics.
Show Data Processing Code
import numpy as np
from plotly.subplots import make_subplots
import plotly.graph_objects as go
import pandas as pd
from IPython.display import display, HTML
# 1. Base Parameters from Avellaneda-Stoikov (2006)
s_0 = 100.0
T = 1.0
sigma = 2.0
dt = 0.005
k = 1.5
A = 140.0
simulations = 1000
N = int(T / dt)
time_grid = np.linspace(0, T, N)
def simulate_market_maker(strategy="inventory", ask_multiplier=1.0, bid_multiplier=1.0, jump_diffusion=False, gamma=0.1):
S = np.zeros(N)
S[0] = s_0
q = np.zeros(N)
X = np.zeros(N)
# Tracking arrays for Graph 1
r_track = np.zeros(N)
p_a_track = np.zeros(N)
p_b_track = np.zeros(N)
for i in range(N - 1):
t = time_grid[i]
r = S[i] - q[i] * gamma * (sigma ** 2) * (T - t)
optimal_spread = gamma * (sigma ** 2) * (T - t) + (2 / gamma) * np.log(1 + gamma / k)
if strategy == "inventory":
p_a = r + optimal_spread / 2
p_b = r - optimal_spread / 2
else:
p_a = S[i] + optimal_spread / 2
p_b = S[i] - optimal_spread / 2
r_track[i] = r
p_a_track[i] = p_a
p_b_track[i] = p_b
delta_a = p_a - S[i]
delta_b = S[i] - p_b
lambda_a = A * np.exp(-k * delta_a) * ask_multiplier
lambda_b = A * np.exp(-k * delta_b) * bid_multiplier
if np.random.rand() < (lambda_a * dt):
q[i+1] = q[i] - 1
X[i+1] = X[i] + p_a
else:
q[i+1] = q[i]
X[i+1] = X[i]
if np.random.rand() < (lambda_b * dt):
q[i+1] = q[i+1] + 1
X[i+1] = X[i+1] - p_b
price_shock = np.random.choice([1, -1]) * sigma * np.sqrt(dt)
if jump_diffusion and np.random.rand() < (5 * dt):
price_shock += np.random.choice([1, -1]) * np.random.exponential(1.5)
S[i+1] = S[i] + price_shock
# Carry forward final step for trackers
r_track[-1], p_a_track[-1], p_b_track[-1] = r_track[-2], p_a_track[-2], p_b_track[-2]
PnL = X + q * S
return time_grid, S, q, X, PnL, r_track, p_a_track, p_b_trackPart 2: Running the Monte Carlo & Generating Visualizations
This block runs both strategies 1,000 times, computes the performance metrics required (Profit, Std Dev, Final \(q\)), and generates the visualizations.
Show Data Processing Code
def run_monte_carlo(ask_mult=1.0, bid_mult=1.0, jumps=False, gamma_val=0.1):
results = {"inventory": {"pnl": [], "final_q": []},
"symmetric": {"pnl": [], "final_q": []}}
sample_paths = {}
for strat in ["inventory", "symmetric"]:
for i in range(simulations):
t, S, q, X, PnL, r_t, p_a_t, p_b_t = simulate_market_maker(
strategy=strat, ask_multiplier=ask_mult, bid_multiplier=bid_mult, jump_diffusion=jumps, gamma=gamma_val
)
results[strat]["pnl"].append(PnL[-1])
results[strat]["final_q"].append(q[-1])
if i == 0:
sample_paths[strat] = (t, S, q, PnL, r_t, p_a_t, p_b_t)
# Calculate Risk Metrics
metrics = []
for strat in ["inventory", "symmetric"]:
pnl_array = np.array(results[strat]["pnl"])
q_array = np.array(results[strat]["final_q"])
metrics.append({
"Strategy": strat.capitalize(),
"Mean Profit": np.mean(pnl_array),
"Std Dev (Profit)": np.std(pnl_array),
"Mean Final q": np.mean(q_array),
"Std Dev (Final q)": np.std(q_array),
"Sharpe Ratio": np.mean(pnl_array) / np.std(pnl_array) if np.std(pnl_array) > 0 else 0
})
df_metrics = pd.DataFrame(metrics)
# Style, format to 3 decimals, and center the HTML table
styled_df = df_metrics.style.format(precision=3) \
.set_table_attributes('style="margin-left: auto; margin-right: auto; width: 80%;"') \
.set_properties(**{'text-align': 'center'}) \
.hide(axis='index')
display(HTML("<h3 style='text-align: center;'>Performance Metrics</h3>"))
display(HTML(styled_df.to_html()))
# --- Visualizations (Plotly 6-Panel Layout) ---
t, S, q, PnL, r_t, p_a_t, p_b_t = sample_paths["inventory"]
fig = make_subplots(
rows=3, cols=2,
subplot_titles=(
"Price Tracking & Optimal Quotes",
"Inventory Trajectory",
"P&L Trajectory",
"Trade Execution Map",
f"P&L Distribution (\u03B3 = {gamma_val})",
"Final Inventory Distribution"
)
)
# Clean Hover Templates (Name in Bold, 3 decimal places, no side-box)
hover_price = '<b>%{data.name}</b><br>Time: %{x:.3f}<br>Price: %{y:.3f}<extra></extra>'
hover_inv = '<b>%{data.name}</b><br>Time: %{x:.3f}<br>Inventory: %{y:.3f}<extra></extra>'
hover_pnl = '<b>%{data.name}</b><br>Time: %{x:.3f}<br>P&L: $%{y:.3f}<extra></extra>'
hover_dist = '<b>%{data.name}</b><br>P&L: %{x:.3f}<br>Count: %{y}<extra></extra>'
hover_distq = '<b>%{data.name}</b><br>Final Q: %{x:.3f}<br>Count: %{y}<extra></extra>'
# 1. Price Tracking (Row 1, Col 1)
fig.add_trace(go.Scatter(x=t, y=S, mode='lines', name='Mid-market price', line=dict(color='black', width=1.5), hovertemplate=hover_price, legendgroup="price"), row=1, col=1)
fig.add_trace(go.Scatter(x=t, y=r_t, mode='lines', name='Indifference Price', line=dict(color='lime', width=1.5), hovertemplate=hover_price, legendgroup="price"), row=1, col=1)
fig.add_trace(go.Scatter(x=t, y=p_a_t, mode='markers', name='Price asked', marker=dict(color='red', size=4), hovertemplate=hover_price, legendgroup="price"), row=1, col=1)
fig.add_trace(go.Scatter(x=t, y=p_b_t, mode='markers', name='Price bid', marker=dict(color='white', line=dict(color='red', width=1.5), size=6), hovertemplate=hover_price, legendgroup="price"), row=1, col=1)
# 2. Inventory Trajectory (Row 1, Col 2)
fig.add_trace(go.Scatter(x=t, y=q, mode='lines', name='Inventory Strategy', line=dict(color='red', width=1.5), hovertemplate=hover_inv, legendgroup="inv"), row=1, col=2)
fig.add_trace(go.Scatter(x=t, y=sample_paths["symmetric"][2], mode='lines', name='Symmetric Strategy', line=dict(color='navy', width=1.5), opacity=0.6, hovertemplate=hover_inv, legendgroup="inv"), row=1, col=2)
fig.add_hline(y=0, line_dash="dash", line_color="black", opacity=0.3, row=1, col=2)
# 3. P&L Trajectory (Row 2, Col 1)
fig.add_trace(go.Scatter(x=t, y=PnL, mode='lines', name='Inventory Strategy P&L', line=dict(color='red', width=1.5), hovertemplate=hover_pnl, legendgroup="pnl", showlegend=False), row=2, col=1)
fig.add_trace(go.Scatter(x=t, y=sample_paths["symmetric"][3], mode='lines', name='Symmetric Strategy P&L', line=dict(color='navy', width=1.5), opacity=0.6, hovertemplate=hover_pnl, legendgroup="pnl", showlegend=False), row=2, col=1)
# 4. Trade Execution Map (Row 2, Col 2)
buy_idx = np.where(np.diff(q) > 0)[0] + 1
sell_idx = np.where(np.diff(q) < 0)[0] + 1
fig.add_trace(go.Scatter(x=t, y=S, mode='lines', name='Mid-Price', line=dict(color='black', width=1.0), opacity=0.5, hovertemplate=hover_price, legendgroup="exec", showlegend=False), row=2, col=2)
fig.add_trace(go.Scatter(x=t[buy_idx], y=S[buy_idx], mode='markers', name='Buy Execution', marker=dict(symbol='triangle-up', color='green', size=8), hovertemplate=hover_price, legendgroup="exec"), row=2, col=2)
fig.add_trace(go.Scatter(x=t[sell_idx], y=S[sell_idx], mode='markers', name='Sell Execution', marker=dict(symbol='triangle-down', color='red', size=8), hovertemplate=hover_price, legendgroup="exec"), row=2, col=2)
# 5. P&L Histogram (Row 3, Col 1)
fig.add_trace(go.Histogram(x=results["inventory"]["pnl"], nbinsx=30, name='Inventory Strategy', marker_color='red', opacity=0.8, hovertemplate=hover_dist, legendgroup="dist", showlegend=False), row=3, col=1)
fig.add_trace(go.Histogram(x=results["symmetric"]["pnl"], nbinsx=30, name='Symmetric Strategy', marker=dict(color='rgba(0,0,0,0)', line=dict(color='navy', width=2)), hovertemplate=hover_dist, legendgroup="dist", showlegend=False), row=3, col=1)
# 6. Final Inventory Histogram (Row 3, Col 2)
fig.add_trace(go.Histogram(x=results["inventory"]["final_q"], nbinsx=30, name='Inventory Strategy', marker_color='red', opacity=0.8, hovertemplate=hover_distq, legendgroup="dist_q", showlegend=False), row=3, col=2)
fig.add_trace(go.Histogram(x=results["symmetric"]["final_q"], nbinsx=30, name='Symmetric Strategy', marker=dict(color='rgba(0,0,0,0)', line=dict(color='navy', width=2)), hovertemplate=hover_distq, legendgroup="dist_q", showlegend=False), row=3, col=2)
# Update global layout
fig.update_layout(
height=1200,
autosize=True,
template="plotly_white",
barmode='overlay',
hovermode="closest",
legend=dict(
orientation="h",
yanchor="bottom",
y=1.05,
xanchor="center",
x=0.5
)
)
# Add axis titles
fig.update_yaxes(title_text="Price", row=1, col=1)
fig.update_yaxes(title_text="Inventory Held", row=1, col=2)
fig.update_yaxes(title_text="Profit & Loss ($)", row=2, col=1)
fig.update_yaxes(title_text="Execution Price", row=2, col=2)
fig.update_xaxes(title_text="Final Profit", row=3, col=1)
fig.update_yaxes(title_text="Frequency", row=3, col=1)
fig.update_xaxes(title_text="Final q", row=3, col=2)
fig.update_yaxes(title_text="Frequency", row=3, col=2)
fig.show()
# Execute the plot
run_monte_carlo(gamma_val=0.1)Performance Metrics
| Strategy | Mean Profit | Std Dev (Profit) | Mean Final q | Std Dev (Final q) | Sharpe Ratio |
|---|---|---|---|---|---|
| Inventory | 64.503 | 6.637 | 0.065 | 2.917 | 9.719 |
| Symmetric | 68.063 | 12.845 | -0.093 | 8.155 | 5.299 |
Show Data Processing Code
def evaluate_gamma_impact():
gamma_values = [0.01, 0.1, 0.5]
metrics = []
for g in gamma_values:
for strat in ["inventory", "symmetric"]:
pnl_list = []
for _ in range(simulations):
_, _, _, _, PnL, _, _, _ = simulate_market_maker(strategy=strat, gamma=g)
pnl_list.append(PnL[-1])
pnl_array = np.array(pnl_list)
metrics.append({
"Gamma (γ)": g,
"Strategy": strat.capitalize(),
"Mean Profit": np.mean(pnl_array),
"Std Dev (Profit)": np.std(pnl_array),
"Sharpe Ratio": np.mean(pnl_array) / np.std(pnl_array) if np.std(pnl_array) > 0 else 0
})
df_gamma = pd.DataFrame(metrics)
# Style and center the table
styled_df = df_gamma.style.format({
"Gamma (γ)": "{:.2f}",
"Mean Profit": "{:.3f}",
"Std Dev (Profit)": "{:.3f}",
"Sharpe Ratio": "{:.3f}"
}).set_table_attributes('style="margin-left: auto; margin-right: auto; width: 60%;"') \
.set_properties(**{'text-align': 'center'}) \
.hide(axis='index')
display(HTML("<h3 style='text-align: center;'>Impact of Risk Aversion (γ)</h3>"))
display(HTML(styled_df.to_html()))
evaluate_gamma_impact()Impact of Risk Aversion (γ)
| Gamma (γ) | Strategy | Mean Profit | Std Dev (Profit) | Sharpe Ratio |
|---|---|---|---|---|
| 0.01 | Inventory | 68.454 | 8.906 | 7.686 |
| 0.01 | Symmetric | 68.173 | 14.696 | 4.639 |
| 0.10 | Inventory | 64.786 | 6.373 | 10.166 |
| 0.10 | Symmetric | 67.276 | 13.368 | 5.033 |
| 0.50 | Inventory | 48.266 | 5.845 | 8.258 |
| 0.50 | Symmetric | 57.801 | 11.829 | 4.886 |
Part 3: Adding Asymmetric “Toxic” Order Flow
The base paper assumes buyers and sellers arrive identically. In reality, order flow becomes “toxic” when the market develops a sudden trend (e.g., news breaks, and aggressive buyers swarm the market). A naive symmetric strategy will blindly sell to all these buyers, accumulating a massive, dangerous short position.
Show Data Processing Code
# ==========================================
# SCENARIO: TOXIC ORDER FLOW
# ==========================================
display(HTML("""
<div style='text-align: center; margin-bottom: 20px;'>
<h2>Scenario: Toxic Order Flow</h2>
<p><b>Condition:</b> Aggressive buyers arrive 20% faster than sellers (Market Trend).<br>
Notice how the Symmetric strategy accumulates a massive negative inventory, while the Inventory strategy defends its book.</p>
</div>
"""))
# ask_mult=1.2 simulates heavy buying pressure against the market maker
run_monte_carlo(ask_mult=1.2, bid_mult=1.0, jumps=False, gamma_val=0.1)Scenario: Toxic Order Flow
Condition: Aggressive buyers arrive 20% faster than sellers (Market Trend).
Notice how the Symmetric strategy accumulates a massive negative inventory, while the Inventory strategy defends its book.
Performance Metrics
| Strategy | Mean Profit | Std Dev (Profit) | Mean Final q | Std Dev (Final q) | Sharpe Ratio |
|---|---|---|---|---|---|
| Inventory | 70.782 | 6.878 | -1.487 | 3.024 | 10.291 |
| Symmetric | 74.612 | 17.426 | -9.534 | 8.686 | 4.282 |
Part 4: Adding Jump-Diffusion (Fat Tails)
Standard Brownian motion (\(dW_u\)) creates continuous, smooth prices. Real financial markets experience “jumps” (fat tails)—sudden $2.00 price crashes or spikes between ticks.
Show Data Processing Code
# ==========================================
# SCENARIO: FAT-TAIL JUMP DIFFUSION
# ==========================================
display(HTML("""
<div style='text-align: center; margin-bottom: 20px;'>
<h2>Scenario: Fat-Tail Jump Diffusion</h2>
<p><b>Condition:</b> Mid-price experiences sudden, random Poisson jumps.<br>
Notice how the Symmetric strategy's P&L variance explodes because it is often caught holding large inventory right when a jump occurs.</p>
</div>
"""))
# jumps=True activates the exponential shock generator in the core engine
run_monte_carlo(ask_mult=1.0, bid_mult=1.0, jumps=True, gamma_val=0.1)Scenario: Fat-Tail Jump Diffusion
Condition: Mid-price experiences sudden, random Poisson jumps.
Notice how the Symmetric strategy's P&L variance explodes because it is often caught holding large inventory right when a jump occurs.
Performance Metrics
| Strategy | Mean Profit | Std Dev (Profit) | Mean Final q | Std Dev (Final q) | Sharpe Ratio |
|---|---|---|---|---|---|
| Inventory | 64.820 | 8.925 | 0.187 | 2.886 | 7.263 |
| Symmetric | 67.488 | 31.389 | -0.462 | 8.746 | 2.150 |
