Estimate risk-neutral crash odds using OTM options, spline smoothing, and Breeden-Litzenberger in Python.
The options market tells you what traders will pay to insure against a crash.
With the right methodology, you can turn these prices into a probability estimate for a major drop.
For example, given option’s market consensus, you can estimate:
This article shows you how. We’ll discuss how to estimate risk-neutral crash odds using options and a proven technique from financial theory.
The complete Python notebook for the analysis is provided below.
1. How to Extract Implied Crash Probabilities
Implied crash probabilities show what the market believes about tail risk.
It can be used to estimate in real-time the probability traders assign to a severe selloff.
Why It Matters
Markets underreact or overreact to risks and relying on backward-looking data will miss shifts in sentiment.
Therefore, we need a foward looking measure to derive implied probabilities.
When crash risk rises, the options market shows it before headlines or economic releases.
The Theory
This approach uses a key result from options pricing:
Breeden-Litzenberger Formula
For this, we use the Breeden-Litzenberger formula:
Here, f(K) is the risk-neutral probability density at strike K, C is the call price, r is the risk-free rate, and T is time to expiration.
The second derivative with respect to strike translates the curve of call prices into an implied probability distribution.
This distribution reflects the market’s collective expectations, under the risk-neutral measure, of where the underlying asset could settle at expiry.
Put-Call Parity (for Consistency)
To construct a smooth call price curve, put-call parity allows conversion between put and call prices:
- P(K): European put price at strike K
- S0​: Current spot price
Crash Probability Integration
Once you have the risk-neutral density, the probability of a crash (terminal price falling below the crash threshold Kcrash​) is:
Kcrash=(1−p)⋅S0​, where p is the desired crash percentage (e.g. 0.15 for a 15% drop).
How to Do It and Steps Involved
2. Extracting Crash Odds in Python
Step 1: Set Your Parameters
Let’s start by setting the parameters in one place.
A few are self-explanatory (e.g. ticker, crash percent).
Here’s what matters for the less obvious controls:
- SMOOTH_S: This sets how aggressively the spline smooths the fitted option price curve. A higher value forces the spline to ignore more local noise, creating a gentler, less jagged curve. This makes the second derivative more stable but may flatten out real features in the price data.
- DX: This is the spacing between strike prices in the numerical grid for density calculations. Smaller DX means more points, higher resolution, and smoother-looking results, but it slows down computation.
- N_SAMPLES: This is the number of simulated prices you draw from the risk-neutral density for the histogram plot. More samples yield a more reliable histogram at the cost of computation time and memory.
import numpy as np
import pandas as pd
import yfinance as yf
from scipy.interpolate import UnivariateSpline
from scipy.integrate import trapezoid
import matplotlib.pyplot as plt
TICKER = "SPY" # Underlying ticker (e.g., "AAPL", "QQQ")
TARGET_MONTHS = 6 # Expiry horizon in months (higher = longer-term risk)
CRASH_PCT = 0.10 # Crash threshold as percent drop (higher = deeper crash)
RISK_FREE = 0.04 # Annual risk-free rate (affects discounting)
SMOOTH_S = 1e4 # Spline smoothness (higher = smoother fit)
DX = 1.0 # Strike grid step (lower = finer detail)
BID_ASK_MAX = 1.5 # Max bid-ask spread allowed (lower = stricter filtering)
MIN_BID = 0.05 # Minimum mid-quote (higher = exclude cheap, illiquid options)
N_SAMPLES = 40000 # Simulated samples (higher = smoother histogram)
Step 2: Select Expiry and Fetch Option Chain
Select the expiry closest to the target maturity and download the option chain. The function below does that based on the target_months param.
def pick_expiration(tkr: yf.Ticker, target_months: float) -> str:
today = pd.Timestamp.today().normalize()
best = None
best_diff = 1e9
for exp in tkr.options:
dt = pd.to_datetime(exp)
if dt <= today:
continue
days = (dt - today).days
months = days / 30.4375
diff = abs(months - target_months)
if diff < best_diff:
best_diff = diff
best = exp
if best is None:
raise ValueError("No valid expirations found.")
return best
tkr = yf.Ticker(TICKER)
expiry = pick_expiration(tkr, TARGET_MONTHS)
chain = tkr.option_chain(expiry)
Step 3: Get Spot Price and Time to Expiry
Get the spot price and calculate the expiry horizon in years and months.
S0 = tkr.history(period="1d")["Close"].iloc[-1]
today = pd.Timestamp.today()
dt_exp = pd.to_datetime(expiry)
days = (dt_exp - today).days
T = max(days, 1) / 365.0
months = days / 30.4375
Step 4: Clean Option Quotes and Build OTM Call Grid
This step filters noisy market data and constructs a consistent, liquid set of OTM call prices across strikes.
Clean inputs are very important for a stable risk-neutral density.
Next, we convert OTM puts to their equivalent call values (via put-call parity), to get a continuous set of option prices.
Newsletter