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.
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:
- Defines adverse selection: post-fill price movement against the MM's position
- Measures the adverse selection ratio: loss from price moves vs. gain from spread
- Implements the Trade Imbalance Filter: detect when order flow heavily favors one side
- Implements the Price Impact Filter: detect when fills consistently precede adverse moves
- Implements the Time-of-Day Filter: restrict quoting during historically toxic periods
- Compares MM performance with and without the filter active
- 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 --quietimport 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.0Section 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, FalseSection 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)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