Market Making·Advanced Techniques·Advanced

Multi Level Quote Engine

Build a multi-level quoting engine that simultaneously posts limit orders at several price levels away from the mid-price on both sides of the book with a configurable size distribution curve, capturing spreads at multiple depth layers of the limit order book simultaneously.

advanced-techniquesmarket-making

Multi-Level Quote Engine — Market Making

Category: Market Making | Subcategory: Advanced


What This Notebook Does

Rather than posting a single bid and ask, sophisticated market makers post quotes at multiple price levels simultaneously (a 'ladder'). This approach has several advantages:

  • Captures fills at different price points without manual intervention
  • Each deeper level provides worse pricing but larger size (compensates for taking further-away price risk)
  • Naturally provides liquidity across the full order book depth
  • Better handles large incoming orders that would otherwise sweep through a single level

This notebook:

  1. Defines a multi-level quote ladder structure (price and size per level)
  2. Implements inventory-skewed pricing across all levels simultaneously
  3. Calculates exponentially increasing spreads for deeper levels (compensates for price risk)
  4. Simulates the full ladder running over synthetic order flow with level-by-level fill tracking
  5. Measures fill distribution across levels and adjusts ladder parameters
  6. Exports quote ladder state and fill history

Quote Ladder Design Principles

LevelDistance from MidSizeSpread MultiplePurpose
Level 1 (best)Tight (2-5 bps)SmallCapture most fills, earn rebates
Level 2Moderate (5-10 bps)MediumBetter fills, slightly more exposure
Level 3Wide (10-20 bps)LargeLarge sweeps, high spread revenue
Level 4 (deepest)Very wide (20+ bps)LargestExtreme moves only, very high reward
[1]
!pip install numpy pandas matplotlib seaborn --quiet
[2]
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, Dict, Tuple
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('husl')
print('Imports ready.')
Imports ready.

Section 2 — Configuration

[3]
N_LEVELS         = 4       # number of bid levels and ask levels each
LEVEL_1_SPREAD   = 0.0004  # level 1 half-spread (4 bps from mid each side)
SPREAD_MULTIPLIER = 2.0    # each deeper level's spread = previous × this

LEVEL_SIZES      = [0.05, 0.10, 0.20, 0.40]  # BTC size per level (increasing depth = bigger size)
MAX_INVENTORY    = 2.00    # hard inventory limit
SKEW_FACTOR      = 0.5     # how much to shift quotes when inventory is non-zero
SIMULATION_STEPS = 4_000
START_PRICE      = 50_000.0
MAKER_REBATE_BPS = 2.0

Section 3 — Quote Ladder Engine

[4]
@dataclass
class QuoteLevel:
    """
    Represents a single resting quote on one side of the ladder.

    Attributes
    ----------
    level_num : int
        Level index (1 = best/tightest, N = deepest/widest).
    side : str
        'bid' or 'ask'.
    price : float
        Posted price for this level.
    size : float
        Posted size in base asset.
    spread_from_mid : float
        Distance from mid price as a decimal fraction.
    """
    level_num:       int
    side:            str
    price:           float
    size:            float
    spread_from_mid: float


def build_quote_ladder(
    mid: float,
    inventory: float,
    n_levels: int,
    level_1_spread: float,
    spread_multiplier: float,
    level_sizes: List[float],
    max_inventory: float,
    skew_factor: float
) -> List[QuoteLevel]:
    """
    Build a complete bid-ask ladder with inventory skew applied.

    Parameters
    ----------
    mid : float
        Current market mid-price.
    inventory : float
        Current net inventory in base asset.
    n_levels : int
        Number of levels on each side.
    level_1_spread : float
        Half-spread for level 1 (decimal fraction).
    spread_multiplier : float
        Factor by which each deeper level's spread increases.
    level_sizes : list of float
        Quote size in base asset per level.
    max_inventory : float
        Hard inventory limit for size capping.
    skew_factor : float
        Skew shift per unit of normalized inventory (shifts entire ladder).

    Returns
    -------
    list of QuoteLevel
        All bid and ask levels in the current ladder.

    Notes
    -----
    The reservation price (mid adjusted for inventory) shifts the entire ladder:
    positive inventory → shift both bid and ask lower (want to sell)
    negative inventory → shift both bid and ask higher (want to buy back)
    This is the key insight from Avellaneda-Stoikov: quote around the
    'reservation price', not the raw mid-price.
    """
    # Reservation price: adjust mid for inventory risk
    inv_norm = inventory / max_inventory  # normalize to [-1, 1]
    reservation_price = mid * (1 - skew_factor * inv_norm * level_1_spread)

    levels = []
    current_spread = level_1_spread

    for i in range(n_levels):
        raw_size = level_sizes[i] if i < len(level_sizes) else level_sizes[-1]

        # Cap bid side when long, cap ask side when short
        inv_fraction = abs(inventory) / max_inventory
        if inventory > 0:
            bid_size_cap = raw_size * max(0, 1 - inv_fraction * (i + 1) / n_levels)
            ask_size     = raw_size
        else:
            bid_size_cap = raw_size
            ask_size     = raw_size * max(0, 1 - inv_fraction * (i + 1) / n_levels)

        bid_price = reservation_price * (1 - current_spread)
        ask_price = reservation_price * (1 + current_spread)

        if bid_size_cap > 0.001:
            levels.append(QuoteLevel(i + 1, 'bid', bid_price, bid_size_cap, current_spread))
        if ask_size > 0.001:
            levels.append(QuoteLevel(i + 1, 'ask', ask_price, ask_size, current_spread))

        current_spread *= spread_multiplier

    return levels


# Quick test
test_ladder = build_quote_ladder(
    50000, 0.5, N_LEVELS, LEVEL_1_SPREAD, SPREAD_MULTIPLIER,
    LEVEL_SIZES, MAX_INVENTORY, SKEW_FACTOR
)
print('Sample ladder (inventory=0.5 BTC):')
for lvl in test_ladder:
    print(f'  Level {lvl.level_num} {lvl.side:4s}: price={lvl.price:.2f}  size={lvl.size:.3f}  spread={lvl.spread_from_mid*10000:.1f}bps')
Sample ladder (inventory=0.5 BTC):
  Level 1 bid : price=49977.50  size=0.047  spread=4.0bps
  Level 1 ask : price=50017.50  size=0.050  spread=4.0bps
  Level 2 bid : price=49957.50  size=0.088  spread=8.0bps
  Level 2 ask : price=50037.50  size=0.100  spread=8.0bps
  Level 3 bid : price=49917.50  size=0.163  spread=16.0bps
  Level 3 ask : price=50077.50  size=0.200  spread=16.0bps
  Level 4 bid : price=49837.51  size=0.300  spread=32.0bps
  Level 4 ask : price=50157.49  size=0.400  spread=32.0bps

Section 4 — Simulation

[5]
def simulate_multi_level_mm(
    n_steps: int,
    start_price: float,
    n_levels: int,
    level_1_spread: float,
    spread_multiplier: float,
    level_sizes: List[float],
    max_inventory: float,
    skew_factor: float,
    maker_rebate_bps: float
) -> pd.DataFrame:
    """
    Run a full multi-level market making simulation.

    At each tick: rebuild the ladder, simulate fills at each level based on
    incoming order flow, update inventory and PnL.

    Parameters
    ----------
    n_steps : int
        Number of simulation ticks.
    start_price : float
        Initial mid-price.
    n_levels : int
        Number of quote levels per side.
    level_1_spread : float
        Tightest half-spread (decimal fraction).
    spread_multiplier : float
        Spread growth factor per level.
    level_sizes : list of float
        Base size per level in BTC.
    max_inventory : float
        Inventory hard limit.
    skew_factor : float
        Inventory skew strength.
    maker_rebate_bps : float
        Rebate per maker fill in basis points.

    Returns
    -------
    pd.DataFrame
        Simulation history with per-level fill counts and PnL.
    """
    np.random.seed(42)
    tick_vol   = 0.02 / np.sqrt(288)
    rebate_dec = maker_rebate_bps / 10_000

    mid       = start_price
    inventory = 0.0
    cash      = 0.0
    level_fills = {f'lvl{i+1}_bid': 0 for i in range(n_levels)}
    level_fills.update({f'lvl{i+1}_ask': 0 for i in range(n_levels)})

    records = []

    for t in range(n_steps):
        mid += mid * np.random.normal(0, tick_vol)
        mid  = max(mid, 1.0)

        ladder = build_quote_ladder(
            mid, inventory, n_levels, level_1_spread,
            spread_multiplier, level_sizes, max_inventory, skew_factor
        )

        # Fill probability decreases with level distance from mid
        for quote in ladder:
            fill_prob = 0.15 / (quote.level_num ** 1.5)
            if np.random.rand() < fill_prob:
                if quote.side == 'bid':
                    inventory += quote.size
                    cash      -= quote.size * quote.price
                    cash      += quote.size * quote.price * rebate_dec
                    level_fills[f'lvl{quote.level_num}_bid'] += 1
                else:
                    inventory -= quote.size
                    cash      += quote.size * quote.price
                    cash      += quote.size * quote.price * rebate_dec
                    level_fills[f'lvl{quote.level_num}_ask'] += 1

        mtm_pnl = cash + inventory * mid

        record = {'tick': t, 'mid': mid, 'inventory': inventory,
                  'mtm_pnl': mtm_pnl, 'cash': cash}
        record.update(dict(level_fills))
        records.append(record)

    return pd.DataFrame(records)


sim = simulate_multi_level_mm(
    SIMULATION_STEPS, START_PRICE, N_LEVELS,
    LEVEL_1_SPREAD, SPREAD_MULTIPLIER, LEVEL_SIZES,
    MAX_INVENTORY, SKEW_FACTOR, MAKER_REBATE_BPS
)

print(f'Simulation complete. Final MtM PnL: ${sim["mtm_pnl"].iloc[-1]:.2f}')
fill_cols = [c for c in sim.columns if c.startswith('lvl')]
print('Total fills per level:')
print(sim[fill_cols].iloc[-1].to_string())
Simulation complete. Final MtM PnL: $13070.10
Total fills per level:
lvl1_bid    579
lvl2_bid    209
lvl3_bid    105
lvl4_bid     81
lvl1_ask    604
lvl2_ask    239
lvl3_ask     97
lvl4_ask     74

Section 5 — Visualization

[6]
def plot_ladder_analysis(sim: pd.DataFrame, n_levels: int) -> None:
    """
    Four-panel visualization of multi-level quote engine performance.

    Parameters
    ----------
    sim : pd.DataFrame
        Simulation output.
    n_levels : int
        Number of ladder levels.
    """
    fig, axes = plt.subplots(2, 2, figsize=(15, 10))

    # Panel 1: MtM PnL
    axes[0, 0].plot(sim['tick'], sim['mtm_pnl'], color='gold', linewidth=1.0)
    axes[0, 0].axhline(0, color='black', linewidth=0.8, linestyle='--')
    axes[0, 0].set_title('Mark-to-Market PnL')
    axes[0, 0].set_ylabel('PnL (USD)')

    # Panel 2: Inventory
    axes[0, 1].plot(sim['tick'], sim['inventory'], color='steelblue', linewidth=0.8)
    axes[0, 1].axhline(0, color='black', linewidth=0.8, linestyle='--')
    axes[0, 1].axhline( MAX_INVENTORY, color='red', linewidth=0.8, linestyle=':')
    axes[0, 1].axhline(-MAX_INVENTORY, color='red', linewidth=0.8, linestyle=':')
    axes[0, 1].set_title('Inventory')
    axes[0, 1].set_ylabel('BTC')

    # Panel 3: Fill distribution by level
    fill_cols = [c for c in sim.columns if c.startswith('lvl')]
    fill_totals = sim[fill_cols].iloc[-1]
    colors = ['green' if 'bid' in c else 'red' for c in fill_totals.index]
    axes[1, 0].bar(range(len(fill_totals)), fill_totals.values, color=colors, alpha=0.7, edgecolor='white')
    axes[1, 0].set_xticks(range(len(fill_totals)))
    axes[1, 0].set_xticklabels(fill_totals.index, rotation=45, ha='right', fontsize=8)
    axes[1, 0].set_title('Total Fills by Level and Side')
    axes[1, 0].set_ylabel('Fill Count')

    # Panel 4: Sample ladder visualization
    sample_mid = sim['mid'].iloc[-1]
    sample_inv = sim['inventory'].iloc[-1]
    sample_ladder = build_quote_ladder(
        sample_mid, sample_inv, n_levels, LEVEL_1_SPREAD,
        SPREAD_MULTIPLIER, LEVEL_SIZES, MAX_INVENTORY, SKEW_FACTOR
    )
    bid_levels = [(l.price, l.size) for l in sample_ladder if l.side == 'bid']
    ask_levels = [(l.price, l.size) for l in sample_ladder if l.side == 'ask']

    if bid_levels:
        prices, sizes = zip(*sorted(bid_levels, reverse=True))
        axes[1, 1].barh(range(len(prices)), sizes, color='green', alpha=0.7, label='Bids')
        axes[1, 1].set_yticks(range(len(prices)))
        axes[1, 1].set_yticklabels([f'${p:.0f}' for p in prices])
    if ask_levels:
        prices_a, sizes_a = zip(*sorted(ask_levels))
        offset = len(bid_levels) if bid_levels else 0
        axes[1, 1].barh(range(offset, offset + len(prices_a)), sizes_a, color='red', alpha=0.7, label='Asks')
        axes[1, 1].set_yticks(range(offset + len(prices_a)))
    axes[1, 1].axhline(len(bid_levels) - 0.5, color='black', linewidth=1.0, linestyle='--')
    axes[1, 1].set_title(f'Sample Ladder (inventory={sample_inv:.3f} BTC)')
    axes[1, 1].set_xlabel('Quote Size (BTC)')
    axes[1, 1].legend()

    plt.tight_layout()
    plt.show()


plot_ladder_analysis(sim, N_LEVELS)
cell output

Section 6 — Export

[7]
def export_ladder_results(sim: pd.DataFrame) -> None:
    """
    Export multi-level simulation results.

    Parameters
    ----------
    sim : pd.DataFrame
        Simulation history.
    """
    sim.to_csv('multi_level_quote_simulation.csv', index=False)
    print(f'Exported multi_level_quote_simulation.csv ({len(sim)} ticks)')


export_ladder_results(sim)
Exported multi_level_quote_simulation.csv (4000 ticks)

Summary & Next Steps

Key Takeaways

  • Multi-level quoting significantly increases fill rate compared to a single level
  • Level 1 (tightest) captures the most fills but at the smallest spread per fill
  • Deeper levels rarely fill but when they do, the spread captured is much larger
  • Inventory skew applied to the reservation price naturally rebalances position across all levels
  • Fill distribution analysis reveals whether the ladder is too tight (Level 1 dominates) or too wide (no fills at outer levels)