Market Making·Advanced Techniques·Advanced

Adverse Selection Filter

Implement sophisticated adverse selection detection for market making operations by identifying statistical signature patterns of informed and toxic order flow, temporarily widening quotes or strategically pulling orders to avoid being adversely picked off by better-informed market participants.

advanced-techniquesmarket-making

Adverse Selection Filter — Market Making

Category: Market Making | Subcategory: Advanced


What This Notebook Does

Adverse selection is the #1 enemy of market makers. It occurs when an informed trader fills your quote — they know more than you do about where the price is going, so your fill immediately becomes a losing position. You bought at 50,000 and the informed trader knew it was going to 49,500.

This notebook builds a comprehensive adverse selection detection and filtering system:

  1. Defines adverse selection: post-fill price movement against the MM's position
  2. Measures the adverse selection ratio: loss from price moves vs. gain from spread
  3. Implements the Trade Imbalance Filter: detect when order flow heavily favors one side
  4. Implements the Price Impact Filter: detect when fills consistently precede adverse moves
  5. Implements the Time-of-Day Filter: restrict quoting during historically toxic periods
  6. Compares MM performance with and without the filter active
  7. Exports filter signals and performance comparison data

The Adverse Selection Math

For each buy fill at price P, we measure the mid-price τ seconds later (P_τ).

  • If P_τ < P (price fell after we bought) → adverse selection occurred
  • If P_τ > P (price rose after we bought) → favorable (price went our way)

Adverse selection cost = E[P_τ - P | side = buy, τ seconds later]

The filter triggers when the rolling adverse selection ratio exceeds a threshold, widening spreads or pausing quoting on the affected side.

[ ]
!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 collections import deque
from typing import List, Tuple, Optional
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 include normal and wide spread values, base and reduced quote sizes, lookback periods and thresholds for adverse selection detection, order imbalance window, and simulation steps.

[ ]
NORMAL_SPREAD_BPS  = 8.0    # bps when no adverse selection detected
WIDE_SPREAD_BPS    = 20.0   # bps when adverse selection detected
BASE_SIZE          = 0.10   # BTC base quote size
REDUCED_SIZE       = 0.03   # BTC quote size during adverse selection warning

ADVERSE_LOOKBACK   = 20     # number of recent fills to assess adverse selection
ADVERSE_THRESHOLD  = 0.65   # fraction of fills with adverse post-fill moves → trigger filter
MEASURE_HORIZON    = 5      # ticks after fill to measure price impact

IMBALANCE_WINDOW   = 30     # ticks for order flow imbalance measurement
IMBALANCE_THRESH   = 0.70   # buy_volume / total_volume > this → bearish signal for MM

SIMULATION_STEPS   = 5_000
START_PRICE        = 50_000.0

Section 3 — Adverse Selection Measurement

This section defines the core logic for detecting adverse selection. It includes functions to compute the adverse selection rate based on fill history and order flow imbalance. It also introduces the AdverseSelectionFilter class, which is a stateful filter that tracks fill history, manages pending measurements, and updates the filter's active status based on the calculated rates and imbalances. This class determines when to adjust quoting parameters (spread and size) due to detected adverse selection.

[ ]
def compute_adverse_selection_rate(
    fill_history: list,
    current_mid: float,
    lookback: int
) -> float:
    """
    Compute the fraction of recent fills where price moved against the MM.

    Parameters
    ----------
    fill_history : list of dict
        Each dict: {'tick', 'side', 'fill_price', 'mid_at_fill', 'post_mid'}
        post_mid is measured MEASURE_HORIZON ticks after the fill.
    current_mid : float
        Current mid-price (used to fill incomplete post_mid measurements).
    lookback : int
        Number of recent fills to consider.

    Returns
    -------
    float
        Adverse selection rate in [0, 1].
        0 = no adverse selection, 1 = all fills were adversely selected.

    Notes
    -----
    A buy fill is 'adverse' if the post-fill mid-price is LOWER than the fill price
    (meaning we bought and the price immediately went against us).
    A sell fill is 'adverse' if post-fill mid is HIGHER than fill price.
    Rates above 60% are concerning; above 70% suggests significant informed flow.
    """
    if not fill_history:
        return 0.0

    recent = [f for f in fill_history if f.get('post_mid') is not None][-lookback:]
    if not recent:
        return 0.0

    adverse_count = 0
    for f in recent:
        if f['side'] == 'buy'  and f['post_mid'] < f['fill_price']:
            adverse_count += 1
        elif f['side'] == 'sell' and f['post_mid'] > f['fill_price']:
            adverse_count += 1

    return adverse_count / len(recent)


def compute_order_flow_imbalance(
    trade_sides: deque,
    window: int
) -> float:
    """
    Compute the order flow imbalance in a rolling window.

    Parameters
    ----------
    trade_sides : deque
        Recent trade sides: +1 for buy, -1 for sell.
    window : int
        Number of recent trades to consider.

    Returns
    -------
    float
        Imbalance in [-1, 1].
        +1 = all buys (selling pressure against ask), -1 = all sells.

    Notes
    -----
    From a market maker's perspective:
    High positive imbalance → asks are being hit → price likely going up → reduce bid size
    High negative imbalance → bids are being hit → price likely going down → reduce ask size
    """
    if not trade_sides:
        return 0.0
    recent = list(trade_sides)[-window:]
    return sum(recent) / len(recent)


class AdverseSelectionFilter:
    """
    Stateful adverse selection filter that tracks fill history and
    adjusts quote parameters when adverse selection exceeds thresholds.

    Attributes
    ----------
    fill_history : list
        Record of all fills with post-fill price measurements.
    trade_sides : deque
        Rolling record of recent trade sides for imbalance computation.
    active : bool
        Whether the adverse selection filter is currently triggered.
    trigger_count : int
        Number of times the filter has been triggered.
    """

    def __init__(
        self,
        adverse_lookback: int,
        adverse_threshold: float,
        measure_horizon: int,
        imbalance_window: int,
        imbalance_thresh: float
    ):
        self.adverse_lookback   = adverse_lookback
        self.adverse_threshold  = adverse_threshold
        self.measure_horizon    = measure_horizon
        self.imbalance_window   = imbalance_window
        self.imbalance_thresh   = imbalance_thresh

        self.fill_history  = []
        self.pending_fills = []  # fills waiting for their post-fill measurement
        self.trade_sides   = deque(maxlen=100)
        self.active        = False
        self.trigger_count = 0

    def record_fill(self, tick: int, side: str, fill_price: float, mid: float) -> None:
        """
        Record a new fill and add it to the pending measurement queue.

        Parameters
        ----------
        tick : int
            Current simulation tick.
        side : str
            'buy' or 'sell'.
        fill_price : float
            Price at which the fill occurred.
        mid : float
            Mid-price at fill time.
        """
        record = {
            'tick':       tick,
            'side':       side,
            'fill_price': fill_price,
            'mid_at_fill': mid,
            'measure_at': tick + self.measure_horizon,
            'post_mid':   None,
        }
        self.pending_fills.append(record)
        side_num = 1 if side == 'buy' else -1
        self.trade_sides.append(side_num)

    def update(self, tick: int, mid: float) -> Tuple[float, float, bool]:
        """
        Update filter state at each tick and return adjusted quote parameters.

        Parameters
        ----------
        tick : int
            Current tick.
        mid : float
            Current mid-price.

        Returns
        -------
        Tuple[float, float, bool]
            (spread_bps, size_fraction, filter_active)
            spread_bps: adjusted spread to quote.
            size_fraction: multiplier on base size (1.0 = normal, 0.3 = reduced).
            filter_active: whether adverse selection filter is triggered.
        """
        # Complete pending measurements
        remaining = []
        for f in self.pending_fills:
            if tick >= f['measure_at']:
                f['post_mid'] = mid
                self.fill_history.append(f)
            else:
                remaining.append(f)
        self.pending_fills = remaining

        adv_rate  = compute_adverse_selection_rate(self.fill_history, mid, self.adverse_lookback)
        imbalance = compute_order_flow_imbalance(self.trade_sides, self.imbalance_window)

        filter_triggered = (adv_rate >= self.adverse_threshold) or (abs(imbalance) >= self.imbalance_thresh)

        if filter_triggered and not self.active:
            self.trigger_count += 1
        self.active = filter_triggered

        if filter_triggered:
            return WIDE_SPREAD_BPS, REDUCED_SIZE / BASE_SIZE, True
        return NORMAL_SPREAD_BPS, 1.0, False

Section 4 — Simulation with and without Filter

This section sets up and runs the market making simulation. The simulate_with_adverse_filter function models market maker behavior with two types of counterparties: informed and noise traders. Informed traders cause price movements against the market maker after a fill, while noise traders fill randomly. The function simulates the market over a specified number of steps, updating prices, inventory, cash, and PnL. It runs two simulations: one with the adverse selection filter active and one without, to compare their performance. Finally, it prints the final PnL and the percentage of time the filter was active.

[ ]
def simulate_with_adverse_filter(
    n_steps: int,
    start_price: float,
    use_filter: bool = True,
    informed_trader_prob: float = 0.20
) -> pd.DataFrame:
    """
    Simulate market maker with two types of counterparties: informed and noise traders.

    Informed traders fill quotes and then price moves against the MM.
    Noise traders fill quotes randomly with no directional bias.

    Parameters
    ----------
    n_steps : int
        Number of simulation ticks.
    start_price : float
        Initial mid-price.
    use_filter : bool
        Whether to activate the adverse selection filter.
    informed_trader_prob : float
        Fraction of fills coming from informed traders.

    Returns
    -------
    pd.DataFrame
        Tick-by-tick state including filter status, spread, inventory, PnL.
    """
    np.random.seed(42)
    tick_vol = 0.02 / np.sqrt(288)

    filt = AdverseSelectionFilter(
        ADVERSE_LOOKBACK, ADVERSE_THRESHOLD, MEASURE_HORIZON,
        IMBALANCE_WINDOW, IMBALANCE_THRESH
    )

    mid       = start_price
    inventory = 0.0
    cash      = 0.0
    records   = []

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

        # Get adjusted quote params from filter
        spread_bps, size_frac, filter_on = filt.update(t, mid) if use_filter else (NORMAL_SPREAD_BPS, 1.0, False)

        spread_dec = spread_bps / 10_000
        size       = BASE_SIZE * size_frac
        half_spread = mid * spread_dec / 2
        bid_price   = mid - half_spread
        ask_price   = mid + half_spread

        # Simulate fills
        for _ in range(2):  # potentially one bid fill and one ask fill
            if np.random.rand() < 0.12:
                is_informed = np.random.rand() < informed_trader_prob
                side = np.random.choice(['buy', 'sell'])

                if side == 'buy':
                    inventory += size
                    cash      -= size * bid_price
                    filt.record_fill(t, 'buy', bid_price, mid)
                    # Informed trader: push price down after buying
                    if is_informed:
                        mid *= (1 - 2 * tick_vol)
                else:
                    inventory -= size
                    cash      += size * ask_price
                    filt.record_fill(t, 'sell', ask_price, mid)
                    if is_informed:
                        mid *= (1 + 2 * tick_vol)

        mtm_pnl = cash + inventory * mid
        records.append({
            'tick':        t,
            'mid':         mid,
            'inventory':   inventory,
            'mtm_pnl':     mtm_pnl,
            'spread_bps':  spread_bps,
            'filter_on':   int(filter_on),
            'size_frac':   size_frac,
        })

    return pd.DataFrame(records)


sim_with    = simulate_with_adverse_filter(SIMULATION_STEPS, START_PRICE, use_filter=True)
sim_without = simulate_with_adverse_filter(SIMULATION_STEPS, START_PRICE, use_filter=False)

print(f'With filter:    Final MtM PnL = ${sim_with["mtm_pnl"].iloc[-1]:.2f}')
print(f'Without filter: Final MtM PnL = ${sim_without["mtm_pnl"].iloc[-1]:.2f}')
print(f'Filter active %: {sim_with["filter_on"].mean()*100:.1f}%')
With filter:    Final MtM PnL = $-6118.26
Without filter: Final MtM PnL = $-8403.43
Filter active %: 7.0%

Section 5 — Visualization

This section provides a visual comparison of the simulation results. The plot_adverse_selection_comparison function generates a three-panel plot: PnL comparison, quoted spread, and inventory position. This visualization helps in understanding how the adverse selection filter impacts the market maker's profitability, how frequently the spread is widened, and the overall inventory management, highlighting the differences between filtered and unfiltered scenarios.

[ ]
def plot_adverse_selection_comparison(
    sim_with: pd.DataFrame,
    sim_without: pd.DataFrame
) -> None:
    """
    Three-panel comparison of filtered vs unfiltered market making.

    Parameters
    ----------
    sim_with : pd.DataFrame
        Simulation results with adverse selection filter active.
    sim_without : pd.DataFrame
        Simulation results without filter.
    """
    fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=True)

    # Panel 1: PnL comparison
    axes[0].plot(sim_with['tick'],    sim_with['mtm_pnl'],    label='With Filter',    color='green', linewidth=1.5)
    axes[0].plot(sim_without['tick'], sim_without['mtm_pnl'], label='Without Filter', color='red',   linewidth=1.5, alpha=0.7)
    axes[0].axhline(0, color='black', linewidth=0.5, linestyle='--')
    axes[0].set_ylabel('MtM PnL (USD)')
    axes[0].set_title('Market Maker PnL: Adverse Selection Filter Comparison')
    axes[0].legend()

    # Panel 2: Spread width (shows when filter triggers)
    axes[1].fill_between(sim_with['tick'], sim_with['spread_bps'],
                          alpha=0.4, color=np.where(sim_with['filter_on'] == 1, 'orange', 'steelblue').tolist()[:1][0])
    axes[1].plot(sim_with['tick'], sim_with['spread_bps'], color='steelblue', linewidth=0.8)
    axes[1].axhline(NORMAL_SPREAD_BPS, color='green', linewidth=0.8, linestyle='--', label='Normal spread')
    axes[1].axhline(WIDE_SPREAD_BPS,   color='red',   linewidth=0.8, linestyle='--', label='Wide spread (filter active)')
    axes[1].set_ylabel('Spread (bps)')
    axes[1].set_title('Quoted Spread — Orange Spikes = Filter Triggered')
    axes[1].legend()

    # Panel 3: Inventory comparison
    axes[2].plot(sim_with['tick'],    sim_with['inventory'],    label='With Filter',    color='green', linewidth=1.0)
    axes[2].plot(sim_without['tick'], sim_without['inventory'], label='Without Filter', color='red',   linewidth=1.0, alpha=0.7)
    axes[2].axhline(0, color='black', linewidth=0.8, linestyle='--')
    axes[2].set_ylabel('Inventory (BTC)')
    axes[2].set_title('Inventory Position')
    axes[2].set_xlabel('Tick')
    axes[2].legend()

    plt.tight_layout()
    plt.show()


plot_adverse_selection_comparison(sim_with, sim_without)
cell output

Section 6 — Export

This section handles the export of the simulation results. The export_adverse_selection_data function saves the dataframes from both the filtered and unfiltered simulations to CSV files. This allows for further analysis or external review of the simulation outcomes.

[ ]
def export_adverse_selection_data(
    sim_with: pd.DataFrame,
    sim_without: pd.DataFrame
) -> None:
    """
    Export filtered and unfiltered simulation results.

    Parameters
    ----------
    sim_with : pd.DataFrame
        Filtered simulation.
    sim_without : pd.DataFrame
        Unfiltered simulation.
    """
    sim_with.to_csv('adverse_filter_on.csv', index=False)
    sim_without.to_csv('adverse_filter_off.csv', index=False)
    print('Exported: adverse_filter_on.csv')
    print('Exported: adverse_filter_off.csv')


export_adverse_selection_data(sim_with, sim_without)
Exported: adverse_filter_on.csv
Exported: adverse_filter_off.csv

Summary & Next Steps

Key Takeaways

  • Adverse selection is the primary profitability driver for market makers — managing it is more important than optimizing spread width
  • The order flow imbalance signal fires quickly (within seconds) and is highly effective
  • Widening the spread is safer than pausing completely — you still participate but extract more premium
  • The filter's trigger rate should be calibrated: too sensitive = miss good fills, too loose = absorb too much toxic flow
  • In crypto, adverse selection spikes around macro events, liquidation cascades, and large whale orders