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:

  1. Computes a rolling 20-day volume baseline per contract — this is your “normal” benchmark.
  2. Calculates the volume multiplier — the 3x threshold (FinBrain).
  3. Calculates volume-to-OI ratio — flagging at >1.25 for new positions (Navnoor Bawa).
  4. Filters by dollar volume — a $5,000 trade is retail noise; a $500,000 trade is institutional.
  5. Scores the anomaly — weighting volume spike (40%), new position strength (30%), and trade size (30%).
  6. 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:

  1. Data ingestion: Real-time options chain via Polygon, Tradier, or Theta Data.
  2. Preprocessing: Normalize for splits, handle illiquid contracts, filter spreads > $0.50.
  3. UOA detection: The function above, run every minute on new prints.
  4. Feature enrichment: Add RSI, GARCH vol forecast, gamma exposure.
  5. ML scoring: Random Forest + XGBoost ensemble on the UOA-flagged subset.
  6. 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