Developing intuition about implied volatility

I spent my end-of-studies internship in the Markets and Treasury department. There, I explored one of the most unintuitive components of option pricing: implied volatility, which represents the market’s expectation of the future volatility of the underlying asset over the remaining life of the option. Consequently, for any given strike price, options on the same asset with different maturities typically exhibit different Implied Volatility levels.

The Black-Scholes model is the most basic option pricing model and assumes that asset returns are normally distributed with constant mean and volatility. In reality, market conditions are rarely stable—asset returns often become significantly more volatile during events such as earnings releases, major announcements, market structure breakdowns, or changes in policy.

In this project, I aim to explore a simple model, focusing exclusively on Eli Lilly call options given their plans to release initial results from late-stage clinical trials on their experimental once-daily obesity pill, orforglipron, which could potentially provide a needle-free alternative for weight loss and diabetes. One strategic aspect of their approach is positioning these medications as lifestyle enhancement products rather than medical interventions, thereby mitigating the perception of invasive treatments associated with injections. Other companies such as Novo Nordisk, Merck, and Pfizer are expected to join this wave, potentially ushering in a period of heightened uncertainty, making future market conditions more difficult to predict and likely accompanied by increased volatility.

The first section will cover the Black-Scholes model. The second will explain how to compute implied volatility. The third will present visualizations of Eli Lilly call options and their implied volatility surface.
In [1]:
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
$$ C = Se^{-qT}N(d_1) - Ke^{-rT}N(d_2) $$
$$ d_1 = \frac{\ln(S/K) + (r - q + 0.5\sigma^2)T}{\sigma\sqrt{T}} $$$$ d_2 = d_1 - \sigma\sqrt{T} $$


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

In [2]:
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

In [3]:
# Set parameters of LLY from Yahoo Finance
S = 875.52 # 4.29.2025
r = 0.044  #10 Year Treasury Rate
q = 0.0    
In [4]:
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

In [5]:
# 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()
In [6]:
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.