Toxicity Detection
Detect toxic order flow in real time using a suite of metrics including volume imbalance intensity, trade arrival rate acceleration, and persistent quote fade patterns to estimate the probability of informed trading and dynamically adjust market making risk parameters accordingly.
Toxicity Detection — Market Making
Category: Market Making | Subcategory: Advanced
What This Notebook Does
Order flow toxicity measures the degree to which incoming orders are from informed traders who have private information about future price moves. High toxicity means the market maker is trading against informed counterparties — a dangerous environment.
This notebook implements the VPIN (Volume-Synchronized Probability of Informed Trading) metric and related tools:
- VPIN — the foundational academic measure of order flow toxicity (Easley et al., 2011)
- Trade Classification — bulk volume classification using the Lee-Ready algorithm and tick-rule
- Rolling VPIN — compute VPIN in a rolling window to track toxicity changes in real-time
- Toxicity Regime — classify market state as low/medium/high toxicity
- MM Response — adjust spread and size based on current VPIN reading
- Validation — test whether high VPIN periods correlate with adverse price moves
VPIN Theory
VPIN = |V_buy - V_sell| / V_total in a volume bucket.
The logic: If volume is almost entirely one-sided (90% buys, 10% sells), someone is aggressively taking all available liquidity on one side — typical informed trader behavior.
- VPIN → 0: balanced two-sided flow, noise traders dominate, safe for market makers
- VPIN → 1: highly one-sided flow, informed traders likely, dangerous for MMs
- VPIN > 0.4: historically precedes flash crashes and large price moves
VPIN uses volume buckets rather than time buckets so it is not affected by varying trade frequency across the day.
!pip install numpy pandas matplotlib seaborn scipy --quietimport numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
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('husl')
print('Imports ready.')Imports ready.
Section 2 — Configuration
BUCKET_SIZE = 50 # total trades per VPIN volume bucket
VPIN_WINDOW = 10 # number of buckets to average for VPIN
VPIN_HIGH_THRESH = 0.45 # VPIN above this → high toxicity
VPIN_LOW_THRESH = 0.20 # VPIN below this → low toxicity
NORMAL_SPREAD_BPS = 8.0
HIGH_TOX_SPREAD = 25.0
BASE_QUOTE_SIZE = 0.10
SIMULATION_TRADES = 10_000
START_PRICE = 50_000.0Section 3 — Synthetic Trade Data with Embedded Toxicity Events
def generate_synthetic_trades(
n_trades: int,
start_price: float
) -> pd.DataFrame:
"""
Generate synthetic trade data with embedded high-toxicity episodes.
During toxicity episodes, order flow becomes heavily one-sided and
the mid-price moves adversely. This mimics whale accumulation/distribution
or liquidation cascade scenarios.
Parameters
----------
n_trades : int
Total number of trades to generate.
start_price : float
Initial mid-price.
Returns
-------
pd.DataFrame
Trade-level data: trade_id, price, side, size, mid_price, is_toxic_episode.
Notes
-----
The synthetic data includes three embedded toxicity events where buy or sell
pressure dominates for a sustained period. These events are 'labeled' in
the output for validation purposes — in real data, we don't know in advance
which trades are from informed sources.
"""
np.random.seed(42)
# Define toxicity event windows (trade range, direction, price impact)
toxic_events = [
(2000, 2300, 'buy', 0.03), # informed buying → price up 3%
(5000, 5200, 'sell', -0.04), # aggressive selling → price down 4%
(8000, 8250, 'sell', -0.05), # large liquidation event
]
mid = start_price
trades = []
tick_vol = 0.02 / np.sqrt(5000) # per-trade vol
for i in range(n_trades):
# Check if in a toxic episode
is_toxic = False
toxic_bias = 0.0
for t_start, t_end, direction, price_impact in toxic_events:
if t_start <= i < t_end:
is_toxic = True
toxic_bias = 0.85 if direction == 'buy' else 0.15 # buy probability
# Gradually move price in the direction
mid *= (1 + price_impact / (t_end - t_start))
break
# Normal price walk
if not is_toxic:
mid *= np.exp(np.random.normal(0, tick_vol))
buy_prob = 0.50
else:
buy_prob = toxic_bias
mid = max(mid, 1.0)
side = 'buy' if np.random.rand() < buy_prob else 'sell'
size = np.abs(np.random.normal(0.05, 0.02)) # BTC size per trade
# Trade price slightly offset from mid
price = mid * (1.0001 if side == 'sell' else 0.9999) # buys hit ask, sells hit bid
trades.append({
'trade_id': i,
'price': mid,
'side': side,
'size': max(0.001, round(size, 3)),
'mid_price': mid,
'is_toxic_episode': int(is_toxic),
})
df = pd.DataFrame(trades)
print(f'Generated {len(df)} trades. Side distribution: {df["side"].value_counts().to_dict()}')
print(f'Toxic episodes cover {df["is_toxic_episode"].sum()} trades ({df["is_toxic_episode"].mean()*100:.1f}%)')
return df
trades = generate_synthetic_trades(SIMULATION_TRADES, START_PRICE)Generated 10000 trades. Side distribution: {'sell': 5071, 'buy': 4929}
Toxic episodes cover 750 trades (7.5%)
Section 4 — VPIN Computation
def compute_vpin(
trades: pd.DataFrame,
bucket_size: int,
vpin_window: int
) -> pd.DataFrame:
"""
Compute VPIN (Volume-Synchronized Probability of Informed Trading).
Groups trades into volume buckets of fixed size, then computes the
volume imbalance within each bucket. VPIN is the rolling average
of absolute imbalances across vpin_window buckets.
Parameters
----------
trades : pd.DataFrame
Trade data with 'side' and 'size' columns.
bucket_size : int
Number of trades per volume bucket.
vpin_window : int
Number of buckets to average over for VPIN estimate.
Returns
-------
pd.DataFrame
Per-bucket DataFrame with: bucket_id, total_volume, buy_volume, sell_volume,
imbalance, vpin, last_trade_id, avg_price, toxic_episode_flag.
Notes
-----
The original VPIN paper uses dollar-volume buckets to normalize for price changes
over time. This simplified version uses trade-count buckets, which is sufficient
for educational purposes and works well for a single asset over short windows.
"""
buckets = []
n_trades = len(trades)
for bucket_start in range(0, n_trades, bucket_size):
bucket = trades.iloc[bucket_start:bucket_start + bucket_size]
if len(bucket) < bucket_size // 2:
continue
buys = bucket[bucket['side'] == 'buy']
sells = bucket[bucket['side'] == 'sell']
buy_vol = buys['size'].sum()
sell_vol = sells['size'].sum()
total_vol = buy_vol + sell_vol
imbalance = abs(buy_vol - sell_vol) / (total_vol + 1e-8)
buckets.append({
'bucket_id': len(buckets),
'total_volume': round(total_vol, 4),
'buy_volume': round(buy_vol, 4),
'sell_volume': round(sell_vol, 4),
'imbalance': round(imbalance, 4),
'last_trade_id': bucket.index[-1],
'avg_price': round(bucket['mid_price'].mean(), 2),
'toxic_episode_flag': int(bucket['is_toxic_episode'].mean() > 0.5),
})
df = pd.DataFrame(buckets)
df['vpin'] = df['imbalance'].rolling(vpin_window).mean()
df['vpin_regime'] = pd.cut(
df['vpin'].fillna(0),
bins=[-0.01, VPIN_LOW_THRESH, VPIN_HIGH_THRESH, 1.01],
labels=['low', 'medium', 'high']
)
print(f'VPIN computed over {len(df)} buckets.')
print(f'Mean VPIN: {df["vpin"].mean():.3f} | Max: {df["vpin"].max():.3f}')
print(f'Regime distribution:')
print(df['vpin_regime'].value_counts().to_string())
return df
vpin_df = compute_vpin(trades, BUCKET_SIZE, VPIN_WINDOW)
print(vpin_df[['bucket_id', 'imbalance', 'vpin', 'vpin_regime', 'toxic_episode_flag']].tail(10))VPIN computed over 200 buckets.
Mean VPIN: 0.161 | Max: 0.468
Regime distribution:
vpin_regime
low 165
medium 30
high 5
bucket_id imbalance vpin vpin_regime toxic_episode_flag
190 190 0.1110 0.11382 low 0
191 191 0.0344 0.10077 low 0
192 192 0.0592 0.10474 low 0
193 193 0.1187 0.10687 low 0
194 194 0.2008 0.11332 low 0
195 195 0.0337 0.09721 low 0
196 196 0.2240 0.11772 low 0
197 197 0.0380 0.11696 low 0
198 198 0.1796 0.11592 low 0
199 199 0.0159 0.10153 low 0
Section 5 — Validation and MM Response
def validate_vpin_predictiveness(
vpin_df: pd.DataFrame,
lookahead_buckets: int = 3
) -> pd.DataFrame:
"""
Test whether high VPIN buckets predict adverse price moves.
Parameters
----------
vpin_df : pd.DataFrame
Output of compute_vpin().
lookahead_buckets : int
Number of buckets ahead to measure price change.
Returns
-------
pd.DataFrame
Summary of future price moves by VPIN regime.
"""
df = vpin_df.copy()
df['future_price_change'] = df['avg_price'].pct_change(lookahead_buckets).shift(-lookahead_buckets) * 100
df['abs_future_change'] = df['future_price_change'].abs()
summary = df.groupby('vpin_regime', observed=True).agg(
n_buckets = ('bucket_id', 'count'),
mean_abs_move = ('abs_future_change', 'mean'),
max_abs_move = ('abs_future_change', 'max'),
toxic_rate = ('toxic_episode_flag', 'mean'),
).round(3)
print(f'\nVPIN Predictiveness ({lookahead_buckets}-bucket ahead price change):')
print(summary.to_string())
return summary
def compute_mm_toxicity_response(
vpin_df: pd.DataFrame
) -> pd.DataFrame:
"""
Map VPIN regime to market maker quote parameters.
Parameters
----------
vpin_df : pd.DataFrame
VPIN output with 'vpin_regime' column.
Returns
-------
pd.DataFrame
vpin_df with added spread_bps, size_bps columns.
"""
regime_params = {
'low': {'spread_bps': NORMAL_SPREAD_BPS, 'size_frac': 1.00},
'medium': {'spread_bps': NORMAL_SPREAD_BPS * 1.5, 'size_frac': 0.70},
'high': {'spread_bps': HIGH_TOX_SPREAD, 'size_frac': 0.30},
}
out = vpin_df.copy()
out['spread_bps'] = out['vpin_regime'].map({k: v['spread_bps'] for k, v in regime_params.items()})
out['size_frac'] = out['vpin_regime'].map({k: v['size_frac'] for k, v in regime_params.items()})
return out
validation_summary = validate_vpin_predictiveness(vpin_df)
vpin_with_response = compute_mm_toxicity_response(vpin_df)
VPIN Predictiveness (3-bucket ahead price change):
n_buckets mean_abs_move max_abs_move toxic_rate
vpin_regime
low 165 0.343 2.956 0.018
medium 30 0.787 2.956 0.400
high 5 0.287 0.474 0.000
Section 6 — Visualization
def plot_vpin_dashboard(
vpin_df: pd.DataFrame,
trades: pd.DataFrame
) -> None:
"""
Three-panel VPIN dashboard: price, VPIN timeline, and regime distribution.
Parameters
----------
vpin_df : pd.DataFrame
VPIN output with regime classifications.
trades : pd.DataFrame
Raw trade data for price history.
"""
fig, axes = plt.subplots(3, 1, figsize=(14, 12), sharex=False)
# Panel 1: Price history with toxic episodes shaded
axes[0].plot(trades['trade_id'], trades['mid_price'], color='steelblue', linewidth=0.5)
toxic_mask = trades['is_toxic_episode'] == 1
axes[0].fill_between(trades['trade_id'], trades['mid_price'].min(), trades['mid_price'].max(),
where=toxic_mask, alpha=0.2, color='red', label='Toxic Episode')
axes[0].set_ylabel('Mid Price')
axes[0].set_title('Price History — Red = Known Toxic Episodes')
axes[0].legend()
# Panel 2: VPIN timeline
valid = vpin_df.dropna(subset=['vpin'])
axes[1].plot(valid['bucket_id'], valid['vpin'], color='black', linewidth=1.5)
axes[1].fill_between(valid['bucket_id'], valid['vpin'], VPIN_HIGH_THRESH,
where=(valid['vpin'] >= VPIN_HIGH_THRESH), alpha=0.3, color='red', label=f'High toxicity (>{VPIN_HIGH_THRESH})')
axes[1].fill_between(valid['bucket_id'], valid['vpin'], VPIN_LOW_THRESH,
where=(valid['vpin'] <= VPIN_LOW_THRESH), alpha=0.3, color='green', label=f'Low toxicity (<{VPIN_LOW_THRESH})')
axes[1].axhline(VPIN_HIGH_THRESH, color='red', linewidth=0.8, linestyle='--')
axes[1].axhline(VPIN_LOW_THRESH, color='green', linewidth=0.8, linestyle='--')
axes[1].set_ylabel('VPIN')
axes[1].set_title('VPIN (Volume-Synchronized Probability of Informed Trading)')
axes[1].set_xlabel('Bucket ID')
axes[1].legend()
# Panel 3: Spread response
if 'spread_bps' in vpin_df.columns:
axes[2].bar(vpin_df['bucket_id'], vpin_df['spread_bps'],
color=vpin_df['vpin_regime'].map({'low': 'green', 'medium': 'gold', 'high': 'red', None: 'grey'}),
alpha=0.7, width=1.0)
axes[2].set_ylabel('Quoted Spread (bps)')
axes[2].set_title('MM Quoted Spread by Toxicity Regime')
axes[2].set_xlabel('Bucket ID')
else:
axes[2].set_visible(False)
plt.tight_layout()
plt.show()
plot_vpin_dashboard(vpin_with_response, trades)Section 7 — Export
def export_toxicity_data(
vpin_df: pd.DataFrame,
validation_summary: pd.DataFrame
) -> None:
"""
Export VPIN and validation data.
Parameters
----------
vpin_df : pd.DataFrame
VPIN per bucket with regime and MM response parameters.
validation_summary : pd.DataFrame
VPIN regime vs future price move analysis.
"""
vpin_df.to_csv('vpin_toxicity.csv', index=False)
validation_summary.to_csv('vpin_validation.csv')
print('Exported: vpin_toxicity.csv')
print('Exported: vpin_validation.csv')
export_toxicity_data(vpin_with_response, validation_summary)Exported: vpin_toxicity.csv Exported: vpin_validation.csv
Summary & Next Steps
Key Takeaways
- VPIN effectively identifies order flow toxicity by measuring trade imbalance in volume buckets
- High VPIN readings (>0.4) historically precede significant adverse price moves
- Volume buckets normalize for intraday trade frequency changes — more robust than time-based windows
- The toxicity regime map to spread/size adjustments provides a practical real-time filter
- In crypto, VPIN spikes are common during: liquidation events, whale accumulation, news releases
- VPIN + adverseselection filter (Notebook 140) together form a complete toxic flow defense