From CBOE delayed data to Polygon.io's institutional options feed. Calculate Greeks, compute max pain, build an options scanner, and visualize IV surfaces — all with code.
Options data is the closest thing to a crystal ball in finance. While social media tells you what retail traders say, options flow tells you what institutional money does. A $5M call sweep on AAPL expiring in 2 weeks is a far stronger signal than 10,000 bullish StockTwits posts.
The options market is roughly 40x the size of the stock market in notional value. Every trade in the options market expresses a view on direction, timing, and magnitude — information that doesn't exist in stock trades alone.
An options chain shows all available contracts for a given stock: calls and puts at every strike price, for every expiration date. Each contract has:
Price sensitivity. How much the option moves per $1 move in the stock. Also approximates probability of expiring ITM.
Rate of change of Delta. High near ATM and near expiration. The "acceleration" of options pricing.
Time decay. How much value the option loses per day. Accelerates near expiration.
Volatility sensitivity. How much the option moves per 1% change in implied volatility.
The CBOE is where options were invented. Their website provides free delayed options data including the VIX, put/call ratios, and basic options chains. For programmatic access, their market data portal offers delayed snapshots. The data is 15-20 minutes delayed but perfectly fine for end-of-day analysis and backtesting.
Tradier is a brokerage-as-a-service platform with an excellent free API tier. With a paper trading account (free), you get real-time options chains, streaming quotes, and order execution in sandbox mode. Delayed data is completely free with just an API key — no brokerage account needed.
import requests TRADIER_KEY = "YOUR_TRADIER_KEY" headers = {"Authorization": f"Bearer {TRADIER_KEY}", "Accept": "application/json"} # Get available expiration dates exp_url = "https://api.tradier.com/v1/markets/options/expirations?symbol=AAPL" expirations = requests.get(exp_url, headers=headers).json() dates = expirations["expirations"]["date"] print(f"Available expirations: {dates[:5]}") # Get full options chain for nearest expiration chain_url = f"https://api.tradier.com/v1/markets/options/chains?symbol=AAPL&expiration={dates[0]}" chain = requests.get(chain_url, headers=headers).json() for opt in chain["options"]["option"][:5]: print(f" {opt['option_type'].upper()} {opt['strike']} — Bid: ${opt['bid']}, Ask: ${opt['ask']}, " f"Vol: {opt['volume']}, OI: {opt['open_interest']}, IV: {opt.get('greeks', {}).get('mid_iv', 'N/A')}")
Polygon.io provides the most complete options data API for developers. Their Options Starter plan ($29/mo) includes real-time options chains and snapshots. The Business plan ($199/mo) adds historical options data, Greeks, and the full OPRA feed. This is the go-to for building production options analytics.
Unusual Whales specializes in options flow — not just the raw chain data, but the interpreted flow: large trades, sweeps, dark pool prints, and unusual activity. Their API identifies institutional-sized options bets and categorizes them by sentiment, size, and urgency. Essential for flow-based trading strategies.
The TD Ameritrade (now Schwab) API provides free real-time options chains with a funded brokerage account. The data includes full Greeks, IV, and all standard chain data. Since Schwab's minimum account is $0, this is effectively free. The API is well-documented and supports OAuth 2.0.
Yahoo Finance (via yfinance) provides free options chain data including bid/ask, volume, open interest, and implied volatility. The data is delayed 15-20 minutes. While not suitable for real-time trading, it's perfect for end-of-day analysis, backtesting options strategies, and learning.
import yfinance as yf ticker = yf.Ticker("AAPL") # List all expiration dates expirations = ticker.options print(f"Expirations: {expirations[:5]}") # Get options chain for nearest expiration chain = ticker.option_chain(expirations[0]) # Calls print("\\nTop 5 Calls by Volume:") top_calls = chain.calls.nlargest(5, "volume") for _, c in top_calls.iterrows(): print(f" CALL {c['strike']} — Bid: ${c['bid']}, Ask: ${c['ask']}, " f"Vol: {c['volume']}, OI: {c['openInterest']}, IV: {c['impliedVolatility']:.1%}") # Put/Call ratio total_call_oi = chain.calls["openInterest"].sum() total_put_oi = chain.puts["openInterest"].sum() pcr = total_put_oi / total_call_oi if total_call_oi > 0 else 0 print(f"\\nPut/Call OI Ratio: {pcr:.2f}")
import numpy as np from scipy.stats import norm from scipy.optimize import brentq class BlackScholes: """Black-Scholes Greeks calculator.""" @staticmethod def d1(S, K, T, r, sigma): return (np.log(S / K) + (r + sigma**2 / 2) * T) / (sigma * np.sqrt(T)) @staticmethod def d2(S, K, T, r, sigma): return BlackScholes.d1(S, K, T, r, sigma) - sigma * np.sqrt(T) @staticmethod def call_price(S, K, T, r, sigma): d1 = BlackScholes.d1(S, K, T, r, sigma) d2 = BlackScholes.d2(S, K, T, r, sigma) return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2) @staticmethod def put_price(S, K, T, r, sigma): d1 = BlackScholes.d1(S, K, T, r, sigma) d2 = BlackScholes.d2(S, K, T, r, sigma) return K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1) @staticmethod def greeks(S, K, T, r, sigma, option_type="call"): d1 = BlackScholes.d1(S, K, T, r, sigma) d2 = BlackScholes.d2(S, K, T, r, sigma) sqrt_T = np.sqrt(T) # Delta delta = norm.cdf(d1) if option_type == "call" else norm.cdf(d1) - 1 # Gamma (same for calls and puts) gamma = norm.pdf(d1) / (S * sigma * sqrt_T) # Theta (per day) common = -(S * norm.pdf(d1) * sigma) / (2 * sqrt_T) if option_type == "call": theta = common - r * K * np.exp(-r * T) * norm.cdf(d2) else: theta = common + r * K * np.exp(-r * T) * norm.cdf(-d2) theta /= 365 # per day # Vega (per 1% IV change) vega = S * sqrt_T * norm.pdf(d1) / 100 return {"delta": delta, "gamma": gamma, "theta": theta, "vega": vega} @staticmethod def implied_volatility(market_price, S, K, T, r, option_type="call"): """Solve for IV using Brent's method.""" price_fn = BlackScholes.call_price if option_type == "call" else BlackScholes.put_price try: iv = brentq(lambda s: price_fn(S, K, T, r, s) - market_price, 0.01, 5.0) return iv except: return float('nan') # Example: AAPL $230 call, 30 days to expiry S = 228.50 # Current price K = 230.00 # Strike T = 30/365 # Time to expiry (years) r = 0.045 # Risk-free rate sigma = 0.25 # Implied volatility (25%) price = BlackScholes.call_price(S, K, T, r, sigma) greeks = BlackScholes.greeks(S, K, T, r, sigma, "call") print(f"Call Price: ${price:.2f}") print(f"Delta: {greeks['delta']:.4f}") print(f"Gamma: {greeks['gamma']:.4f}") print(f"Theta: ${greeks['theta']:.4f}/day") print(f"Vega: ${greeks['vega']:.4f}/1% IV")
Max Pain is the strike price where the total payout to options holders is minimized — i.e., where market makers lose the least money. The theory suggests that stocks tend to gravitate toward the max pain price at expiration because market makers hedge their positions dynamically. While controversial, many traders use max pain as a weekly price target.
import yfinance as yf import numpy as np def calculate_max_pain(symbol, expiration=None): """Calculate max pain for a given stock and expiration.""" ticker = yf.Ticker(symbol) if expiration is None: expiration = ticker.options[0] # Nearest expiration chain = ticker.option_chain(expiration) calls = chain.calls[["strike", "openInterest"]].dropna() puts = chain.puts[["strike", "openInterest"]].dropna() strikes = sorted(set(calls["strike"].tolist() + puts["strike"].tolist())) total_pain = {} for test_price in strikes: # Call pain: sum of (test_price - strike) * OI for ITM calls call_pain = 0 for _, row in calls.iterrows(): if test_price > row["strike"]: call_pain += (test_price - row["strike"]) * row["openInterest"] # Put pain: sum of (strike - test_price) * OI for ITM puts put_pain = 0 for _, row in puts.iterrows(): if test_price < row["strike"]: put_pain += (row["strike"] - test_price) * row["openInterest"] total_pain[test_price] = call_pain + put_pain max_pain_strike = min(total_pain, key=total_pain.get) return { "symbol": symbol, "expiration": expiration, "max_pain": max_pain_strike, "current_price": ticker.info.get("regularMarketPrice", 0), "pain_by_strike": total_pain } result = calculate_max_pain("AAPL") print(f"AAPL Max Pain: ${result['max_pain']} (Current: ${result['current_price']})")
An options scanner monitors the entire options market for unusual activity: volume spikes, large open interest changes, and IV rank extremes. Here's a production-ready scanner:
import yfinance as yf from dataclasses import dataclass from typing import List @dataclass class OptionsAlert: symbol: str alert_type: str # "unusual_volume", "iv_spike", "large_oi_change" option_type: str # "call" or "put" strike: float expiration: str volume: int open_interest: int iv: float vol_oi_ratio: float score: float class OptionsScanner: """Scan for unusual options activity.""" def scan(self, symbols: List[str], min_volume=100, min_vol_oi=2.0) -> List[OptionsAlert]: alerts = [] for symbol in symbols: try: ticker = yf.Ticker(symbol) for exp in ticker.options[:3]: # Check next 3 expirations chain = ticker.option_chain(exp) for opt_type, df in [("call", chain.calls), ("put", chain.puts)]: for _, row in df.iterrows(): vol = row.get("volume", 0) or 0 oi = row.get("openInterest", 0) or 0 iv = row.get("impliedVolatility", 0) or 0 if vol < min_volume: continue vol_oi = vol / oi if oi > 0 else 999 if vol_oi >= min_vol_oi: score = min(vol_oi * 20, 100) # Normalize to 0-100 alerts.append(OptionsAlert( symbol=symbol, alert_type="unusual_volume", option_type=opt_type, strike=row["strike"], expiration=exp, volume=int(vol), open_interest=int(oi), iv=iv, vol_oi_ratio=vol_oi, score=score )) except Exception as e: print(f"Error scanning {symbol}: {e}") return sorted(alerts, key=lambda x: x.score, reverse=True) # Scan top 20 S&P 500 names scanner = OptionsScanner() symbols = ["AAPL", "MSFT", "GOOGL", "AMZN", "NVDA", "META", "TSLA", "JPM", "V", "JNJ"] alerts = scanner.scan(symbols, min_volume=500, min_vol_oi=3.0) print(f"Found {len(alerts)} unusual activity alerts:") for a in alerts[:10]: print(f" {a.symbol} {a.option_type.upper()} ${a.strike} exp:{a.expiration} — " f"Vol:{a.volume} OI:{a.open_interest} Ratio:{a.vol_oi_ratio:.1f}x IV:{a.iv:.0%} Score:{a.score:.0f}")
The IV surface is a 3D visualization of implied volatility across all strike prices and expiration dates. It reveals the market's volatility expectations and can identify mispriced options. The "volatility smile" (IV is higher for deep OTM and ITM options) and "volatility term structure" (IV varies by expiration) are both visible in the surface.
import yfinance as yf import numpy as np import pandas as pd def build_iv_surface(symbol): """Build IV surface data from options chain.""" ticker = yf.Ticker(symbol) current_price = ticker.info.get("regularMarketPrice", 0) surface_data = [] for exp in ticker.options[:6]: # Next 6 expirations chain = ticker.option_chain(exp) dte = (pd.Timestamp(exp) - pd.Timestamp.now()).days for _, row in chain.calls.iterrows(): iv = row.get("impliedVolatility", 0) if iv > 0 and iv < 3: # Filter outliers moneyness = row["strike"] / current_price if 0.8 <= moneyness <= 1.2: # +/- 20% from spot surface_data.append({ "strike": row["strike"], "moneyness": moneyness, "dte": dte, "iv": iv, "expiration": exp }) return pd.DataFrame(surface_data) surface = build_iv_surface("AAPL") print(surface.head(10))
| API | Price | Chains | Greeks | Flow | Historical | Real-Time |
|---|---|---|---|---|---|---|
| Yahoo (yfinance) | Free | Yes (delayed) | No (calculate) | No | No | 15min delay |
| Tradier | Free / $0* | Yes | Basic | No | No | Yes (with acct) |
| Polygon.io | $29-199/mo | Yes | Yes ($199) | No | Yes ($199) | Yes |
| Unusual Whales | $40/mo | No | No | Yes (best) | Yes | Yes |
| TD/Schwab | Free* | Yes | Yes | No | No | Yes |
| CBOE | Free (delayed) | Basic | No | No | No | 20min delay |
* "Free" requires a funded brokerage account ($0 minimum)
OPRA (Options Price Reporting Authority) is the consolidated tape for all US options exchanges. It includes every trade, quote, and open interest update from all 16 options exchanges. The raw OPRA feed processes 100+ billion messages per day — more data than all stock exchanges combined. This is why options data is expensive: the sheer volume of data requires significant infrastructure. Polygon.io at $199/mo is actually cheap for OPRA-quality data.
Black-Scholes is fine for European-style options and quick Greeks calculations. For American-style options (which can be exercised early), the Binomial (Cox-Ross-Rubinstein) model is more accurate, especially for deep ITM puts and high-dividend stocks. In practice, the difference is small for ATM options with 30+ days to expiry. Use Black-Scholes for speed, Binomial for accuracy on edge cases.