Market Making·Market Making Fundamentals·Intermediate

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-makingmarket-making-fundamentals

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:

  1. Realized PnL — profit locked in when both sides of a round trip complete
  2. Unrealized PnL — current mark-to-market value of open inventory
  3. Fee tracking — maker rebates minus taker fees (market makers typically earn rebates)
  4. Spread capture — the core revenue source: bid-ask spread earned on each fill pair
  5. Adverse selection cost — inventory losses when price moves against you after a fill
  6. Inventory cost — opportunity cost of holding inventory overnight or through vol events
  7. 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 --quiet
[ ]
import 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.0

Section 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.0

Section 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)
cell output

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