TradeTerminal / options_flow.py
ArifKemall's picture
deploy: backend to HF Space (with README metadata)
85416dd
Raw
History Blame Contribute Delete
10.3 kB
"""
Options flow and unusual activity module.
Provides LEAPS detection, call/put ratio, and unusual volume analysis
via yfinance option chain data.
"""
import yfinance as yf
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
from typing import Optional
# ── Constants ──────────────────────────────────────────────────────────
LEAPS_MIN_DAYS = 365 # Expiry > 1 year = LEAPS
UNUSUAL_VOL_OI_THRESHOLD = 2.0 # volume / openInterest threshold for unusual activity
CPR_BULLISH = 3.0 # Call/Put ratio above this = bullish
CPR_BEARISH = 0.33 # Call/Put ratio below this = bearish
# Tickers to scan for the top LEAPS movers board
DEFAULT_SCAN_TICKERS = [
"AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "TSLA", "META",
"SPY", "QQQ", "IWM", "AMD", "INTC", "PLTR", "COIN",
"JPM", "BAC", "GS", "JNJ", "PFE", "UNH", "XOM", "CVX",
]
# ── Helpers ────────────────────────────────────────────────────────────
def _safe_ratio(num, den, default=0.0):
"""Avoid division by zero."""
if den == 0 or pd.isna(den):
return default
return float(num) / float(den)
def _detect_leaps(expiry_dates: list[str]) -> list[str]:
"""Filter expiries that are LEAPS-range (> 365 days out)."""
today = datetime.today()
leaps = []
for exp in expiry_dates:
try:
dt = datetime.strptime(exp, "%Y-%m-%d")
if (dt - today).days >= LEAPS_MIN_DAYS:
leaps.append(exp)
except ValueError:
continue
return leaps
def _analyze_chain(
calls: pd.DataFrame,
puts: pd.DataFrame,
expiry: str,
is_leaps: bool = False,
) -> dict:
"""Analyze a single option chain (calls + puts) for one expiry."""
result = {
"expiry": expiry,
"is_leaps": is_leaps,
"total_call_volume": 0,
"total_put_volume": 0,
"total_call_oi": 0,
"total_put_oi": 0,
"call_put_ratio": 0.0,
"top_calls": [],
"top_puts": [],
"unusual_calls": [],
"unusual_puts": [],
}
# --- Calls ---
active_calls = calls[calls["volume"] > 0].copy()
if not active_calls.empty:
active_calls["vol_oi_ratio"] = active_calls.apply(
lambda r: _safe_ratio(r["volume"], r["openInterest"]), axis=1
)
result["total_call_volume"] = int(calls["volume"].sum())
result["total_call_oi"] = int(calls["openInterest"].sum())
# Top calls by volume
top = active_calls.nlargest(5, "volume")[
["strike", "lastPrice", "volume", "openInterest",
"vol_oi_ratio", "impliedVolatility", "inTheMoney", "contractSymbol"]
]
result["top_calls"] = _rows_to_dicts(top)
# Unusual: vol_oi_ratio > threshold
unusual = active_calls[active_calls["vol_oi_ratio"] >= UNUSUAL_VOL_OI_THRESHOLD].nlargest(10, "volume")
result["unusual_calls"] = _rows_to_dicts(unusual)
# --- Puts ---
active_puts = puts[puts["volume"] > 0].copy()
if not active_puts.empty:
active_puts["vol_oi_ratio"] = active_puts.apply(
lambda r: _safe_ratio(r["volume"], r["openInterest"]), axis=1
)
result["total_put_volume"] = int(puts["volume"].sum())
result["total_put_oi"] = int(puts["openInterest"].sum())
top = active_puts.nlargest(5, "volume")[
["strike", "lastPrice", "volume", "openInterest",
"vol_oi_ratio", "impliedVolatility", "inTheMoney", "contractSymbol"]
]
result["top_puts"] = _rows_to_dicts(top)
unusual = active_puts[active_puts["vol_oi_ratio"] >= UNUSUAL_VOL_OI_THRESHOLD].nlargest(10, "volume")
result["unusual_puts"] = _rows_to_dicts(unusual)
# Overall call/put ratio
result["call_put_ratio"] = _safe_ratio(
result["total_call_volume"], result["total_put_volume"]
)
return result
def _rows_to_dicts(df: pd.DataFrame) -> list[dict]:
"""Convert DataFrame rows to list of dicts with safe JSON types."""
records = []
if df.empty:
return records
for _, row in df.iterrows():
rec = {}
for col in df.columns:
val = row[col]
if isinstance(val, (pd.Timestamp,)):
rec[col] = val.isoformat()
elif isinstance(val, (np.integer,)):
rec[col] = int(val)
elif isinstance(val, (np.floating,)):
rec[col] = float(val)
elif pd.isna(val):
rec[col] = None
else:
rec[col] = val
records.append(rec)
return records
# ── Public API ─────────────────────────────────────────────────────────
def fetch_options_flow(ticker: str) -> dict:
"""
Fetch full options flow data for a ticker:
- All expiries (near and far)
- LEAPS highlighted
- Unusual volume/OI spikes
- Call/Put volume ratio
Returns a structured dict for JSON response.
"""
try:
stock = yf.Ticker(ticker)
try:
expiry_dates = stock.options
except Exception:
expiry_dates = None
if not expiry_dates:
raise ValueError(f"No options data available for {ticker}")
has_leaps = False
leaps_expiries = _detect_leaps(expiry_dates)
if leaps_expiries:
has_leaps = True
all_expiries_data = []
unusual_activities = []
leaps_data = []
for i, exp in enumerate(expiry_dates):
is_leaps_flag = exp in leaps_expiries
try:
chain = stock.option_chain(exp)
except Exception:
continue
expiry_info = _analyze_chain(
chain.calls, chain.puts, expiry=exp, is_leaps=is_leaps_flag
)
if is_leaps_flag:
leaps_data.append(expiry_info)
# Mark highest volume/vol_oi strikes as unusual
for uc in expiry_info.get("unusual_calls", []):
uc["expiry"] = exp
uc["type"] = "leaps_call"
uc["signal"] = "BULLISH"
unusual_activities.append(uc)
for up in expiry_info.get("unusual_puts", []):
up["expiry"] = exp
up["type"] = "leaps_put"
up["signal"] = "BEARISH"
unusual_activities.append(up)
all_expiries_data.append(expiry_info)
# Sort unusual activities by volume descending
unusual_activities.sort(key=lambda x: x.get("volume", 0), reverse=True)
unusual_activities = unusual_activities[:20] # cap at 20
# Compute summary using the NEAREST expiry as primary
nearest = all_expiries_data[0] if all_expiries_data else {}
total_callVol = sum(e["total_call_volume"] for e in all_expiries_data)
total_putVol = sum(e["total_put_volume"] for e in all_expiries_data)
return {
"ticker": ticker,
"as_of": datetime.utcnow().isoformat(),
"has_leaps": has_leaps,
"leaps_expiries": leaps_expiries,
"summary": {
"total_call_volume": total_callVol,
"total_put_volume": total_putVol,
"call_put_ratio": round(_safe_ratio(total_callVol, total_putVol), 2),
"total_expiries": len(expiry_dates),
"nearest_call_put_ratio": nearest.get("call_put_ratio", 0.0),
"unusual_count": len(unusual_activities),
},
"all_expiries": all_expiries_data,
"leaps": leaps_data,
"unusual_activities": unusual_activities,
}
except ValueError:
raise
except Exception as e:
raise RuntimeError(f"Failed to fetch options data for {ticker}: {str(e)}")
def rank_single_ticker(sym: str) -> dict | None:
"""Fetch options flow for a single ticker, returning a compact summary dict."""
try:
data = fetch_options_flow(sym)
summary = data["summary"]
return {
"ticker": sym,
"has_leaps": data["has_leaps"],
"leaps_count": len(data["leaps"]),
"total_call_volume": summary["total_call_volume"],
"total_put_volume": summary["total_put_volume"],
"call_put_ratio": summary["call_put_ratio"],
"nearest_call_put_ratio": summary["nearest_call_put_ratio"],
"unusual_count": summary["unusual_count"],
"top_leaps_expiry": data["leaps_expiries"][-1] if data["leaps_expiries"] else None,
}
except Exception:
return None
def rank_tickers_by_leaps_activity(tickers: list[str]) -> list[dict]:
"""
Scan multiple tickers and rank them by options flow activity.
Returns list of {ticker, leaps_count, leaps_call_vol, leaps_put_vol, call_put_ratio, top_leaps_expiry}
sorted by total unusual activity.
"""
results = []
for sym in tickers:
try:
data = fetch_options_flow(sym)
summary = data["summary"]
results.append({
"ticker": sym,
"has_leaps": data["has_leaps"],
"leaps_count": len(data["leaps"]),
"total_call_volume": summary["total_call_volume"],
"total_put_volume": summary["total_put_volume"],
"call_put_ratio": summary["call_put_ratio"],
"nearest_call_put_ratio": summary["nearest_call_put_ratio"],
"unusual_count": summary["unusual_count"],
"top_leaps_expiry": data["leaps_expiries"][-1] if data["leaps_expiries"] else None,
})
except Exception:
# Skip tickers with no options data
continue
# Sort by total call volume + put volume descending
results.sort(key=lambda x: x.get("total_call_volume", 0) + x.get("total_put_volume", 0), reverse=True)
return results