Spaces:
Sleeping
Sleeping
File size: 10,347 Bytes
7a8d58d b334bf5 7a8d58d b334bf5 7a8d58d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 | """
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
|