Building an Unusual Options Activity Detection System with Python

Building an Unusual Options Activity Detection System with Python
Options markets are the only major financial arena without dark pools. Every trade prints to the tape in real-time, and institutional investors account for 70–80% of that volume (LuxAlgo). That transparency creates a unique opportunity: if you can systematically separate noise from institutional conviction, you’re reading the order flow of the smartest money in the market.
Unusual Options Activity (UOA) is the signal. It’s defined as options volume that deviates 3–10x from the daily average, exceeds open interest (OI), and concentrates in specific strikes and expirations (FinBrain). But detecting it isn’t just about setting a high volume threshold — it’s about building a pipeline that filters false positives, accounts for market-maker inventory, and produces a signal you can actually act on.
Here’s how to build that system in Python.
The Core Metrics: What Actually Matters
Before writing code, you need to define what “unusual” means quantitatively. Three metrics form the backbone of any UOA detector:
| Metric | Normal Activity | Unusual Activity |
|---|---|---|
| Volume vs. 20-day average | 0.5–1.5x | 3–10x+ |
| Volume vs. Open Interest | Volume < OI (closing positions) | Volume > 1.25x OI (new positions) (Navnoor Bawa) |
| Put/Call Ratio | 0.7–1.0 | < 0.7 (bullish), > 1.0 (bearish), > 1.5 (extreme) (FinBrain) |
| Implied Volatility | Stable | Spiking with volume |
The volume-to-OI ratio is the single most important filter. When volume exceeds OI, it means traders are opening new positions rather than closing existing ones. That’s the difference between someone hedging an existing stock position and someone making a directional bet with fresh capital.
The Detection Function
Here’s a production-ready Python function that flags UOA from an options chain DataFrame. It assumes you have OHLCV data per contract, with columns for volume, open_interest, call_put, and strike.
import pandas as pd
import numpy as np
from datetime import timedelta
def detect_uoa(df, vol_mult=3.0, oi_ratio=1.25, min_dollar_volume=500_000):
"""
Detect unusual options activity from an options chain DataFrame.
Parameters:
- df: DataFrame with columns ['symbol', 'expiration', 'strike', 'call_put',
'volume', 'open_interest', 'close', 'date']
- vol_mult: Volume multiplier vs 20-day average to flag (default 3.0)
- oi_ratio: Volume/OI ratio threshold (default 1.25)
- min_dollar_volume: Minimum premium traded to filter retail noise
Returns:
- DataFrame of flagged UOA events with scores
"""
df = df.copy()
df['dollar_volume'] = df['volume'] * df['close']
# 1. Rolling 20-day average volume per contract
df['avg_volume_20d'] = df.groupby(
['symbol', 'expiration', 'strike', 'call_put']
)['volume'].transform(
lambda x: x.rolling(20, min_periods=5).mean().shift(1)
)
# 2. Volume multiplier
df['vol_ratio'] = df['volume'] / df['avg_volume_20d'].replace(0, np.nan)
# 3. Volume vs Open Interest ratio
df['vol_oi_ratio'] = df['volume'] / df['open_interest'].replace(0, np.nan)
# 4. Base anomaly flags
df['is_high_volume'] = df['vol_ratio'] >= vol_mult
df['is_new_position'] = df['vol_oi_ratio'] >= oi_ratio
df['is_large_trade'] = df['dollar_volume'] >= min_dollar_volume
# 5. Combined UOA flag
df['is_uoa'] = (
df['is_high_volume'] &
df['is_new_position'] &
df['is_large_trade']
)
# 6. Score the anomaly (0-100)
score = 0.0
score += np.clip(df['vol_ratio'] / 10, 0, 1) * 40 # Volume spike weight
score += np.clip(df['vol_oi_ratio'] / 3, 0, 1) * 30 # New position weight
score += np.clip(df['dollar_volume'] / 5_000_000, 0, 1) * 30 # Size weight
df['uoa_score'] = np.where(df['is_uoa'], score * 100, 0)
# 7. Put/Call context per symbol per day
pc_ratio = df.groupby(['symbol', 'date']).apply(
lambda x: x.loc[x['call_put'] == 'put', 'volume'].sum() /
max(x.loc[x['call_put'] == 'call', 'volume'].sum(), 1)
).reset_index(name='pc_ratio')
df = df.merge(pc_ratio, on=['symbol', 'date'], how='left')
# 8. Flag directional bias
df['direction'] = 'neutral'
df.loc[df['pc_ratio'] < 0.7, 'direction'] = 'bullish'
df.loc[df['pc_ratio'] > 1.0, 'direction'] = 'bearish'
df.loc[df['pc_ratio'] > 1.5, 'direction'] = 'extreme_bearish'
return df[df['is_uoa']].sort_values('uoa_score', ascending=False)
The function does six things:
- Computes a rolling 20-day volume baseline per contract — this is your “normal” benchmark.
- Calculates the volume multiplier — the 3x threshold (FinBrain).
- Calculates volume-to-OI ratio — flagging at >1.25 for new positions (Navnoor Bawa).
- Filters by dollar volume — a $5,000 trade is retail noise; a $500,000 trade is institutional.
- Scores the anomaly — weighting volume spike (40%), new position strength (30%), and trade size (30%).
- Adds put/call context — a spike in puts when the overall ratio is below 0.7 is more meaningful than a spike in a heavily bearish environment.
The Academic Foundation: Why This Works
The signal isn’t just folklore. Pan & Poteshman (2006) established that options order flow contains predictive information for future stock returns (Navnoor Bawa). Johnson & So (2012) extended this by showing that put/call ratios predict future returns — the academic target signal is roughly 40 basis points for ETFs.
But here’s the honest part: when you build a machine learning model on top of this flow data, the achievable R² is 0.001–0.01 (Navnoor Bawa). That sounds tiny, but it’s actually realistic and tradeable. In financial prediction, an R² of 0.005 with a Sharpe ratio above 1.0 is institutional-grade.
The best-performing ML features in Navnoor Bawa’s implementation were:
- RSI (~26% importance) — momentum context
- GARCH volatility forecast (~26%) — expected vs. realized vol
- Volume (~20%) — raw flow intensity
- Gamma exposure (~21%) — dealer hedging pressure
A Random Forest + XGBoost ensemble on these features captures non-linear relationships that a simple threshold approach misses. But the threshold approach is your foundation — ML enhances it, it doesn’t replace it.
Pitfalls: Why Most UOA Signals Fail
UOA detection is 20% math and 80% filtering. Here are the failure modes you’ll hit:
1. Hedging vs. Speculation Ambiguity
A massive put purchase could be a directional bearish bet — or a portfolio manager hedging a $500M long position. The volume looks identical. The only differentiator is context: is the stock at an all-time high? Is there an earnings announcement in 48 hours? Is the broader market selling off? Always check the macro context before acting (FinBrain).
2. Market Maker Inventory Management
When you see a huge call sweep, you’re often seeing a market maker covering their short delta, not a new bullish bet. Dealers are forced to buy calls when they’re short gamma and the underlying rallies. This mechanical flow is not directional information (dxFeed). Filter by checking if the trade was at the bid or ask — trades at the ask are buyers-initiated (directional); trades at the bid are sellers-initiated (possibly hedging).
3. Timing: Being Right but Early
UOA can be correct weeks before the move happens. If you’re trading options, theta bleeds you dry while you wait. The academic 40bp signal is for the underlying stock over a horizon of days to weeks — not for short-dated options (Navnoor Bawa). Trade the stock or use longer-dated options.
4. Confirmation Bias
Once you see a “bullish” call sweep, it’s tempting to find reasons the stock will go up. This is backwards. The signal should confirm your independent thesis, not create one. If you have no view on the stock, an UOA signal is not a reason to trade.
5. The Social Sentiment Trap
A 2025 paper found that naive aggregate StockTwits sentiment predicts next-day direction at only 47.63% accuracy — worse than random (arXiv). Social sentiment is garbage unless you filter for expert identification and noise reduction. Don’t combine UOA with “what Twitter thinks.” Combine it with what the options tape says.
Building the Full Pipeline
Your production system should look like this:
- Data ingestion: Real-time options chain via Polygon, Tradier, or Theta Data.
- Preprocessing: Normalize for splits, handle illiquid contracts, filter spreads > $0.50.
- UOA detection: The function above, run every minute on new prints.
- Feature enrichment: Add RSI, GARCH vol forecast, gamma exposure.
- ML scoring: Random Forest + XGBoost ensemble on the UOA-flagged subset.
- Execution: Only act on signals that score in the top decile AND align with your independent thesis.
The system isn’t a crystal ball. It’s a filter that surfaces the 0.1% of options prints that carry institutional conviction. The edge is real — 40 basis points per signal, compounded over hundreds of signals a year — but it’s thin. You need proper risk management, position sizing, and the patience to let the signal play out.
Build the detector, add the ML layer, respect the pitfalls. The tape is talking — you just need to learn how to listen.
← Back to all posts

