Developing intuition about implied volatility
import pandas as pd
import numpy as np
import jax.numpy as jnp
from jax.scipy.stats import norm as jnorm
import matplotlib.pyplot as plt
import plotly.graph_objects as go
def data_calls(filepath):
calls = pd.read_csv(filepath)
calls["Mid"] = (calls["Bid"] + calls["Ask"]) / 2
return calls
calls = data_calls("/Users/aicha/Desktop/NewL/Py/Option Chain - 28/llycalls290325.csv")
display(calls.head(3))
Exp. Date | Last | Bid | Ask | Volume | Open Int. | Strike | T | Mid | |
---|---|---|---|---|---|---|---|---|---|
0 | 5/2/2025 | 87.95 | 184.95 | 192.55 | NaN | 4.00 | 690.00 | 0.008219 | 188.750 |
1 | 5/2/2025 | NaN | 182.40 | 190.00 | NaN | NaN | 692.50 | 0.008219 | 186.200 |
2 | 5/2/2025 | 126.90 | 179.95 | 187.80 | NaN | 18.00 | 695.00 | 0.008219 | 183.875 |
0. Model Details:
Takes in 6 key parameters:
• S: Current underlying price (e.g. equity, futures, commodity, etc.)
• K: Strike price
• T: Time to maturity (in years)
• r: Risk-free interest rate
• σ: Volatility of the underlying stock
• q: Dividend yield
The Black-Scholes model is parameterized by four terms, excluding the dividend yield (q). To estimate volatility, we use the available market price of the option. Since markets will always set a price for any asset, arbitrage opportunities exist if there's a mispricing, and this applies to options as well
• The function first calculates the estimated price of the option using the Black-Scholes formula.
• Then, it adjusts the volatility guess (sigma) using the Newton-Raphson method, repeating up to 100 iterations to reduce the difference between the estimated price and the market price to near zero (i.e., minimizing the error)
• Vega measures how sensitive the option price is to changes in volatility; it helps to scale and guide the adjustment of the volatility guess during each iteration
def black_scholes(S, K, T, r, sigma, q=0, otype="call"):
d1 = (jnp.log(S / K) + (r - q + 0.5 * sigma**2) * T) / (sigma * jnp.sqrt(T))
d2 = d1 - sigma * jnp.sqrt(T)
if otype == "call":
return S * jnp.exp(-q * T) * jnorm.cdf(d1) - K * jnp.exp(-r * T) * jnorm.cdf(d2)
return K * jnp.exp(-r * T) * jnorm.cdf(-d2) - S * jnp.exp(-q * T) * jnorm.cdf(-d1)
def solve_iv(S, K, T, r, market_price, sigma_guess=0.3, max_iter=100, tol=1e-6):
sigma = sigma_guess
for _ in range(max_iter):
price_est = black_scholes(S, K, T, r, sigma)
error = price_est - market_price
if abs(error) < tol:
return float(sigma)
vega = S * jnp.sqrt(T) * jnorm.pdf((jnp.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * jnp.sqrt(T)))
sigma -= error / vega
return float(sigma)
1. Baseline:
• As market price information is not available for this data, the "fair" trading price is employed as a proxy:
$$
Mid = \frac{(Bid + Ask)}{2}
$$
• Given the raw nature of the data, it is necessary to apply filters such as:
▪ Volume > 5 to exclude illiquid options
▪ 70% < S < 130% to remove deep OTM and ITM options, which tend to produce unstable IV estimates
# Set parameters of LLY from Yahoo Finance
S = 875.52 # 4.29.2025
r = 0.044 #10 Year Treasury Rate
q = 0.0
def c_and_f(calls_df, spot_price):
# Convert numeric columns efficiently
numeric_cols = ['Strike', 'Bid', 'Ask', 'Volume', 'T']
calls_df[numeric_cols] = calls_df[numeric_cols].apply(pd.to_numeric, errors='coerce')
# Calculate mid-price
calls_df = calls_df.assign(
Mid=lambda x: (x['Bid'] + x['Ask']) / 2,
Moneyness=lambda x: spot_price / x['Strike']
).dropna(subset=['Mid'])
# Apply liquidity and moneyness
return calls_df[
(calls_df['Volume'] > 5) &
(calls_df['Strike'].between(0.7*spot_price, 1.3*spot_price))
]
filtered_calls = c_and_f(calls, S)
# Calculate IVs (vectorized for better performance)
filtered_calls['IV'] = filtered_calls.apply(
lambda row: solve_iv(S, row['Strike'], row['T'], r, row['Mid']),
axis=1
).clip(0.05, 1.5) # Bound IVs between 5% and 150%
print(f"Processed {len(filtered_calls)} valid options")
print(filtered_calls[['Strike', 'T', 'Mid', 'IV']].dropna(subset=['IV']).head())
Processed 163 valid options Strike T Mid IV 49 820.0 0.008219 65.025 0.945029 53 830.0 0.008219 56.500 0.901480 55 835.0 0.008219 52.700 0.893554 57 840.0 0.008219 49.000 0.885318 58 845.0 0.008219 45.150 0.867681
Interactive 3D Visualization of Eli Lilly’s Implied Volatility Surface
# Interactive 3D surface with Plotly, add surface triangulation and layout adjustments
fig = go.Figure(data=[
go.Scatter3d(
x=filtered_calls["Moneyness"],
y=filtered_calls["T"],
z=filtered_calls["IV"],
mode='markers',
marker=dict(
size=5,
color=filtered_calls["IV"],
colorscale='Viridis',
opacity=0.8,
colorbar=dict(title="IV")
)
)
])
fig.update_traces(
marker=dict(
line=dict(width=0.1, color='DarkSlateGrey')
)
)
fig.update_layout(
title='LLY Implied Volatility Surface',
scene=dict(
xaxis_title='Moneyness (S/K)',
yaxis_title='Time to Expiry',
zaxis_title='Implied Volatility',
camera=dict(eye=dict(x=1.5, y=1.5, z=0.8))
),
width=1000,
height=800
)
fig.show()
moneyness = filtered_calls["Moneyness"].values
dtes = filtered_calls["T"].values
ivs = filtered_calls["IV"].values
fig = go.Figure(data=[go.Mesh3d(
x=moneyness,
y=dtes,
z=ivs,
intensity=ivs,
colorscale='Viridis',
opacity=0.8
)])
fig.update_layout(
title='Implied Volatility Surface',
scene=dict(
xaxis_title='Moneyness (S/K)',
yaxis_title='Time to Expiration',
zaxis_title='Implied Volatility',
camera=dict(eye=dict(x=1.5, y=1.5, z=1)),
),
width=900,
height=700
)
fig.show()
Takeaways
The IV typically increases with time to expiration, but the current data for Eli Lilly (LLY) show the opposite behavior. This deviation is driven by near-term event risk (specifically), the recent release of clinical trial results for its weight-loss pills (https://www.cnbc.com/2025/04/17/eli-lilly-weight-loss-pill-orforglipron-clears-first-late-stage-trial.html). The market anticipates heightened volatility following this news, causing a sharp IV spike in short-term options (e.g., May 2 expiry). This exemplifies binary event risk, where uncertainty concentrates in front-month contracts
When moneyness (S/K > 1), IV rises significantly, indicating that investors are pricing in greater uncertainty for ITM calls. This likely reflects hedging demand against further upside moves after the trial results.
The IV surface displays a reverse skew (not a U-shaped smile), with ITM calls commanding higher volatility than ATM or OTM options.