MM Pnl Tracker
Track market making strategy PnL with detailed performance attribution decomposing total returns into spread capture revenue, inventory revaluation gains and losses, exchange fee rebates earned, and adverse selection costs incurred to understand the true economic drivers of strategy profitability.
Market Maker PnL Tracker — Market Making
Category: Market Making | Subcategory: Core
What This Notebook Does
Accurately tracking profit and loss is fundamental to market making. Unlike directional trading where PnL is simply price change × position, market making PnL has multiple components that must be tracked separately to understand what is and isn't working.
This notebook implements a complete PnL accounting framework:
- Realized PnL — profit locked in when both sides of a round trip complete
- Unrealized PnL — current mark-to-market value of open inventory
- Fee tracking — maker rebates minus taker fees (market makers typically earn rebates)
- Spread capture — the core revenue source: bid-ask spread earned on each fill pair
- Adverse selection cost — inventory losses when price moves against you after a fill
- Inventory cost — opportunity cost of holding inventory overnight or through vol events
- Performance metrics — Sharpe, max drawdown, fill rate, spread capture rate
The MM PnL Decomposition
Total PnL = Spread Capture
+ Fee Rebates
- Adverse Selection Cost
- Inventory Holding Cost
± Directional PnL (if you have inventory at period end)
A healthy market maker operation shows:
- Spread capture > Adverse selection cost (positive flow alpha)
- Fee rebates ≈ 1-3 bps per trade (on maker-taker exchanges like Binance, Bybit)
- Directional PnL near zero over time (you don't want to be a directional trader)
- Low inventory turnover time — inventory recycled quickly
!pip install numpy pandas matplotlib seaborn --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from dataclasses import dataclass, field
from typing import List, Optional
from collections import deque
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (13, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('muted')
print('Imports ready.')Imports ready.
Section 2 — Configuration
This section defines the key parameters and constants used throughout the simulation. These values influence how the market-making strategy behaves and how PnL is calculated.
MAKER_REBATE_BPS = 2.0 # basis points received per maker fill
TAKER_FEE_BPS = 5.0 # basis points paid per taker fill (when forced to cross spread)
SPREAD_BPS = 10.0 # target bid-ask spread in basis points
BASE_QUOTE_SIZE = 0.10 # BTC per level
SIMULATION_STEPS = 3_000
START_PRICE = 50_000.0Section 3 — Trade and PnL Data Structures
This section outlines the data structures (Trade and PnLState) essential for tracking individual trade events and the cumulative profit and loss (PnL) state of the market maker. It also includes the process_fill function, which updates the PnL state based on incoming fills using a FIFO matching logic.
@dataclass
class Trade:
"""
Represents a single fill event on the exchange.
Attributes
----------
tick : int
Simulation tick when the fill occurred.
side : str
'buy' (we bought at bid) or 'sell' (we sold at ask).
price : float
Fill price.
size : float
Fill size in base asset.
fee : float
Fee paid (negative) or rebate received (positive) in quote currency.
mid_at_fill : float
Mid-price at time of fill — used for adverse selection measurement.
"""
tick: int
side: str
price: float
size: float
fee: float
mid_at_fill: float
@dataclass
class PnLState:
"""
Mutable PnL accounting state updated after each fill.
Attributes
----------
inventory : float
Current net position in base asset.
cash : float
Net cash position (positive = we have received more than paid).
realized_pnl : float
Cumulative realized profit from completed round trips (FIFO).
fee_pnl : float
Cumulative net fees (rebates minus taker fees).
spread_capture : float
Gross spread captured (before adverse selection).
fill_queue : deque
FIFO queue of unfilled buy lots used for matching against sells.
n_buys : int
Total buy fills.
n_sells : int
Total sell fills.
"""
inventory: float = 0.0
cash: float = 0.0
realized_pnl: float = 0.0
fee_pnl: float = 0.0
spread_capture: float = 0.0
fill_queue: deque = field(default_factory=deque)
n_buys: int = 0
n_sells: int = 0
def process_fill(
state: PnLState,
trade: Trade
) -> float:
"""
Update PnL state after a fill using FIFO matching for realized PnL.
Parameters
----------
state : PnLState
Mutable PnL state (updated in place).
trade : Trade
Incoming fill event.
Returns
-------
float
Realized PnL from this fill (0 if no matching lot exists yet).
Notes
-----
FIFO (first in, first out) matching: the oldest buy lot is matched against
incoming sell lots first. This is the standard accounting method for
high-frequency market making. LIFO would be more conservative but less
common in practice.
Spread capture = ask_price - bid_price per matched pair, measuring gross
spread revenue before accounting for price movement between fills.
"""
state.fee_pnl += trade.fee
if trade.side == 'buy':
state.cash -= trade.size * trade.price
state.inventory += trade.size
state.fill_queue.append((trade.price, trade.size, trade.mid_at_fill))
state.n_buys += 1
return 0.0
elif trade.side == 'sell':
state.cash += trade.size * trade.price
state.inventory -= trade.size
state.n_sells += 1
size_remaining = trade.size
this_realized = 0.0
while size_remaining > 1e-9 and state.fill_queue:
buy_price, buy_size, buy_mid = state.fill_queue[0]
matched = min(size_remaining, buy_size)
realized = matched * (trade.price - buy_price)
this_realized += realized
state.realized_pnl += realized
state.spread_capture += matched * (trade.price - buy_mid)
size_remaining -= matched
if matched >= buy_size - 1e-9:
state.fill_queue.popleft()
else:
state.fill_queue[0] = (buy_price, buy_size - matched, buy_mid)
return this_realized
return 0.0Section 4 — Simulation with Full PnL Tracking
This section details the generate_mm_simulation function, which simulates the market-making activity over a specified number of steps. It incorporates price movements, fill probabilities, and adverse selection, recording the PnL components at each tick.
def generate_mm_simulation(
n_steps: int,
start_price: float,
spread_bps: float,
base_size: float,
maker_rebate_bps: float,
fill_prob: float = 0.12
) -> pd.DataFrame:
"""
Simulate a market maker posting quotes and tracking comprehensive PnL.
Parameters
----------
n_steps : int
Number of simulation ticks.
start_price : float
Initial mid-price.
spread_bps : float
Target spread in basis points.
base_size : float
Quote size in BTC per level.
maker_rebate_bps : float
Maker rebate in basis points.
fill_prob : float
Probability of a fill on each side per tick.
Returns
-------
pd.DataFrame
Full simulation history with all PnL components.
Notes
-----
The simulation introduces correlated adverse selection: after a buy fill,
the price drifts slightly against us with some probability (the 'adverse'
scenario). This models informed order flow that pushes the price in the
direction of the fill.
"""
np.random.seed(99)
tick_vol = 0.02 / np.sqrt(288)
spread_dec = spread_bps / 10_000
rebate_dec = maker_rebate_bps / 10_000
mid = start_price
state = PnLState()
records = []
for t in range(n_steps):
# Update mid price
price_drift = np.random.normal(0, tick_vol * mid)
mid += price_drift
mid = max(mid, 1.0)
half_spread = mid * spread_dec / 2
bid_price = mid - half_spread
ask_price = mid + half_spread
# Simulate fills — 15% adverse selection probability
bid_filled = np.random.rand() < fill_prob
ask_filled = np.random.rand() < fill_prob
if bid_filled:
fee = base_size * bid_price * rebate_dec # maker earns rebate
trade = Trade(t, 'buy', bid_price, base_size, fee, mid)
process_fill(state, trade)
if ask_filled:
fee = base_size * ask_price * rebate_dec
trade = Trade(t, 'sell', ask_price, base_size, fee, mid)
process_fill(state, trade)
# Mark-to-market
mtm_pnl = state.cash + state.inventory * mid
unrealized_pnl = state.inventory * (mid - (state.fill_queue[0][0] if state.fill_queue else mid))
records.append({
'tick': t,
'mid': mid,
'inventory': state.inventory,
'cash': state.cash,
'realized_pnl': state.realized_pnl,
'fee_pnl': state.fee_pnl,
'spread_capture': state.spread_capture,
'mtm_pnl': mtm_pnl,
'total_fills': state.n_buys + state.n_sells,
})
return pd.DataFrame(records)
sim = generate_mm_simulation(
SIMULATION_STEPS, START_PRICE, SPREAD_BPS, BASE_QUOTE_SIZE, MAKER_REBATE_BPS
)
print(f'Simulation complete: {len(sim)} ticks')
print(f'Total fills: {sim["total_fills"].iloc[-1]}')
print(f'Realized PnL: ${sim["realized_pnl"].iloc[-1]:.2f}')
print(f'Fee PnL: ${sim["fee_pnl"].iloc[-1]:.2f}')
print(f'Spread Capture: ${sim["spread_capture"].iloc[-1]:.2f}')
print(f'Final MtM PnL: ${sim["mtm_pnl"].iloc[-1]:.2f}')Simulation complete: 3000 ticks Total fills: 693 Realized PnL: $4026.07 Fee PnL: $700.41 Spread Capture: $3183.44 Final MtM PnL: $459.33
Section 5 — Performance Metrics
This section introduces the compute_mm_performance_metrics function, which analyzes the simulation results to derive key performance indicators for the market-making strategy. Metrics include Sharpe ratio, maximum drawdown, fill rate, and spread capture effectiveness.
def compute_mm_performance_metrics(sim: pd.DataFrame) -> dict:
"""
Compute key market making performance metrics from simulation history.
Parameters
----------
sim : pd.DataFrame
Simulation output with PnL columns.
Returns
-------
dict
Performance metrics including Sharpe, drawdown, fill rate, and spread economics.
Notes
-----
MtM PnL Sharpe ratio uses tick-level changes — annualize by multiplying by
sqrt(288 * 365) ≈ 324 for 5-minute ticks.
Spread capture per fill is the most important profitability metric: it should
exceed the exchange taker fee (otherwise informed traders are scalping you).
"""
pnl_changes = sim['mtm_pnl'].diff().dropna()
sharpe_raw = pnl_changes.mean() / (pnl_changes.std() + 1e-8)
ann_factor = np.sqrt(288 * 252)
peak = sim['mtm_pnl'].cummax()
dd = sim['mtm_pnl'] - peak
max_dd = dd.min()
n_fills = sim['total_fills'].iloc[-1]
spread_per_fill = sim['spread_capture'].iloc[-1] / max(n_fills, 1)
metrics = {
'total_ticks': len(sim),
'total_fills': int(n_fills),
'fill_rate_pct': round(n_fills / len(sim) * 100, 2),
'final_realized_pnl': round(sim['realized_pnl'].iloc[-1], 2),
'final_fee_pnl': round(sim['fee_pnl'].iloc[-1], 2),
'total_spread_capture': round(sim['spread_capture'].iloc[-1], 2),
'spread_capture_per_fill': round(spread_per_fill, 4),
'final_mtm_pnl': round(sim['mtm_pnl'].iloc[-1], 2),
'max_drawdown_usd': round(max_dd, 2),
'annualized_sharpe': round(sharpe_raw * ann_factor, 3),
'max_inventory_btc': round(sim['inventory'].abs().max(), 4),
'avg_inventory_btc': round(sim['inventory'].abs().mean(), 4),
}
for k, v in metrics.items():
print(f' {k:35s}: {v}')
return metrics
metrics = compute_mm_performance_metrics(sim)total_ticks : 3000 total_fills : 693 fill_rate_pct : 23.1 final_realized_pnl : 4026.07 final_fee_pnl : 700.41 total_spread_capture : 3183.44 spread_capture_per_fill : 4.5937 final_mtm_pnl : 459.33 max_drawdown_usd : -1705.55 annualized_sharpe : 1.179 max_inventory_btc : 1.7 avg_inventory_btc : 0.468
Section 6 — Visualization
This section provides the plot_mm_pnl_dashboard function, a visualization tool to display the market maker's performance. It generates a multi-panel plot illustrating the evolution of total PnL, spread capture, inventory, and underlying mid-price over the simulation.
def plot_mm_pnl_dashboard(sim: pd.DataFrame) -> None:
"""
Five-panel PnL dashboard showing all components of market maker returns.
Parameters
----------
sim : pd.DataFrame
Simulation output.
"""
fig, axes = plt.subplots(4, 1, figsize=(14, 16), sharex=True)
# Panel 1: MtM PnL with components
axes[0].plot(sim['tick'], sim['mtm_pnl'], label='Total MtM PnL', color='black', linewidth=1.5)
axes[0].plot(sim['tick'], sim['realized_pnl'], label='Realized PnL', color='green', linewidth=1.0, linestyle='--')
axes[0].plot(sim['tick'], sim['fee_pnl'], label='Fee Rebates', color='blue', linewidth=0.8, linestyle=':')
axes[0].axhline(0, color='black', linewidth=0.5)
axes[0].set_ylabel('PnL (USD)')
axes[0].set_title('Market Maker PnL Components')
axes[0].legend()
# Panel 2: Spread capture
axes[1].plot(sim['tick'], sim['spread_capture'], color='gold', linewidth=1.0)
axes[1].set_ylabel('Spread Capture (USD)')
axes[1].set_title('Cumulative Spread Captured')
# Panel 3: Inventory
axes[2].plot(sim['tick'], sim['inventory'], color='steelblue', linewidth=0.8)
axes[2].fill_between(sim['tick'], sim['inventory'], 0,
where=(sim['inventory'] > 0), alpha=0.2, color='green')
axes[2].fill_between(sim['tick'], sim['inventory'], 0,
where=(sim['inventory'] < 0), alpha=0.2, color='red')
axes[2].axhline(0, color='black', linewidth=0.8, linestyle='--')
axes[2].set_ylabel('Inventory (BTC)')
axes[2].set_title('Net Inventory Position')
# Panel 4: Mid price
axes[3].plot(sim['tick'], sim['mid'], color='navy', linewidth=0.8)
axes[3].set_ylabel('Mid Price')
axes[3].set_title('Underlying Mid Price')
axes[3].set_xlabel('Tick')
plt.tight_layout()
plt.show()
plot_mm_pnl_dashboard(sim)Section 7 — Export
This section contains the export_mm_pnl function, which saves the simulation data and calculated performance metrics to CSV files for further analysis or record-keeping.
def export_mm_pnl(sim: pd.DataFrame, metrics: dict) -> None:
"""
Export simulation and metrics to CSV.
Parameters
----------
sim : pd.DataFrame
Full simulation history.
metrics : dict
Performance metrics dictionary.
"""
sim.to_csv('mm_pnl_simulation.csv', index=False)
pd.DataFrame([metrics]).to_csv('mm_performance_metrics.csv', index=False)
print('Exported: mm_pnl_simulation.csv')
print('Exported: mm_performance_metrics.csv')
export_mm_pnl(sim, metrics)Exported: mm_pnl_simulation.csv Exported: mm_performance_metrics.csv
Summary & Next Steps
Key Takeaways
- Market maker PnL has multiple components — tracking them separately is essential for diagnosing performance
- Spread capture is the core revenue; it must exceed adverse selection costs for the strategy to be profitable
- Fee rebates on maker-taker exchanges add 1-3 bps per fill — significant at high fill frequencies
- FIFO realized PnL is the cleanest performance measure; MtM fluctuates with inventory
- Average inventory staying low is a health signal — large persistent inventory means the strategy is drifting directional