Chapter 10
Options Greeks: From Theory to Computation
Options Greeks: From Theory to Computation
Docker image: ml4t
Purpose
Derive Black-Scholes pricing and Greeks from first principles, implement implied volatility via root-finding, and validate the computations against the vendor-supplied values in the AlgoSeek options dataset.
Learning Objectives
- Implement Black-Scholes call/put pricing and verify put-call parity.
- Solve for implied volatility numerically using Brent's method.
- Code all five Greeks (Delta, Gamma, Vega, Theta, Rho) and visualize their behavior across moneyness and time to expiration.
- Quantify residuals between in-house and vendor-computed Greeks and explain the residual sources.
Book Reference
Chapter 2 §2.2 (asset-class market data landscape — derivatives).
Prerequisites
- Basic calculus (partial derivatives) and the standard normal distribution.
- Options terminology from
07_sp500_options_eda. - The AlgoSeek S&P 500 options EDA parquet at
$ML4T_DATA_PATH/equities/market/sp500/options_eda/.
"""Options Greeks — Black-Scholes pricing, IV computation, and Greeks validation."""
import numpy as np
import plotly.express as px
import plotly.graph_objects as go
import polars as pl
from plotly.subplots import make_subplots
from scipy import stats
from scipy.optimize import brentq
from data import load_sp500_options_eda# Production defaults
RISK_FREE_RATE = 0.015 # 3-month Treasury rate, ~typical pre-COVID 2020
N_GREEKS_VALIDATE = 500 # Sample size for Greeks comparison
N_IV_VALIDATE = 200 # Sample size for IV recovery comparison1. The Black-Scholes Framework
The Black-Scholes model (1973) provides closed-form solutions for European option prices under specific assumptions. Understanding these assumptions is critical for practitioners - model limitations explain many real-world pricing phenomena.
Model Assumptions
| Assumption | Reality | Implication |
|---|---|---|
| Log-normal returns | Fat tails exist | Underprices tail risk |
| Constant volatility | Vol changes over time | Need to re-estimate σ |
| No dividends | Stocks pay dividends | Use dividend-adjusted models |
| No transaction costs | Costs exist | Greeks less useful for small positions |
| Continuous trading | Markets close | Weekend/overnight gaps |
| European exercise | Many options are American | Early exercise premium missed |
| Constant risk-free rate | Rates vary | Use term-matched rates |
Despite these limitations, Black-Scholes remains the industry standard for quoting volatility and computing Greeks. The model's tractability outweighs its theoretical shortcomings for most practical applications.
The Black-Scholes Formula
For a European call option:
For a European put option:
Where:
- = Current stock price
- = Strike price
- = Time to expiration (in years)
- = Risk-free interest rate
- = Volatility (annualized standard deviation of log returns)
- = Cumulative normal distribution function
And , are:
# Small helper functions for d1 and d2 (tightly coupled, <=5 lines each)
def d1(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""Compute d1 parameter for Black-Scholes formula."""
return (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
def d2(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""Compute d2 parameter: d2 = d1 - sigma * sqrt(T)."""
return d1(S, K, T, r, sigma) - sigma * np.sqrt(T)Call Pricing
def bs_call_price(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""Black-Scholes price for a European call option."""
if T <= 0:
return max(S - K, 0) # Intrinsic value at expiration
d_1 = d1(S, K, T, r, sigma)
d_2 = d2(S, K, T, r, sigma)
return S * stats.norm.cdf(d_1) - K * np.exp(-r * T) * stats.norm.cdf(d_2)Put Pricing
def bs_put_price(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""Black-Scholes price for a European put option."""
if T <= 0:
return max(K - S, 0) # Intrinsic value at expiration
d_1 = d1(S, K, T, r, sigma)
d_2 = d2(S, K, T, r, sigma)
return K * np.exp(-r * T) * stats.norm.cdf(-d_2) - S * stats.norm.cdf(-d_1)Unified Pricing Function
def bs_price(
S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call"
) -> float:
"""Black-Scholes option price (call or put)."""
if option_type.lower() == "call":
return bs_call_price(S, K, T, r, sigma)
else:
return bs_put_price(S, K, T, r, sigma)Verify Put-Call Parity
A fundamental relationship that must hold for European options:
This provides a sanity check for our implementation.
# Test parameters
S, K, T, r, sigma = 100, 100, 0.25, 0.05, 0.20
call_price = bs_call_price(S, K, T, r, sigma)
put_price = bs_put_price(S, K, T, r, sigma)
# Put-call parity check
lhs = call_price - put_price
rhs = S - K * np.exp(-r * T)
print("=== Black-Scholes Implementation Test ===")
print(f"Parameters: S={S}, K={K}, T={T}, r={r}, σ={sigma}")
print(f"\nCall price: ${call_price:.4f}")
print(f"Put price: ${put_price:.4f}")
print("\nPut-Call Parity Check:")
print(f" C - P = {lhs:.6f}")
print(f" S - Ke^(-rT) = {rhs:.6f}")
print(f" Difference: {abs(lhs - rhs):.2e}")Output
=== Black-Scholes Implementation Test === Parameters: S=100, K=100, T=0.25, r=0.05, σ=0.2 Call price: $4.6150 Put price: $3.3728 Put-Call Parity Check: C - P = 1.242220 S - Ke^(-rT) = 1.242220 Difference: 7.11e-15
2. Implied Volatility Computation
Implied volatility (IV) is the volatility value that, when plugged into Black-Scholes, produces the observed market price. Since there's no closed-form solution, we must solve numerically:
We'll use Brent's method (a robust root-finding algorithm) to solve this.
def implied_volatility(
market_price: float,
S: float,
K: float,
T: float,
r: float,
option_type: str = "call",
bounds: tuple = (0.001, 5.0),
) -> float | None:
"""Compute implied volatility using Brent's method.
Returns the volatility that makes BS price equal market_price, or None.
"""
if T <= 0:
return None
# Define objective function: BS_price(sigma) - market_price = 0
def objective(sigma):
return bs_price(S, K, T, r, sigma, option_type) - market_price
try:
# Check if solution exists within bounds
f_low = objective(bounds[0])
f_high = objective(bounds[1])
if f_low * f_high > 0:
# No sign change - no solution in bounds
return None
# Brent's method for root finding
iv = brentq(objective, bounds[0], bounds[1], xtol=1e-8)
return iv
except (ValueError, RuntimeError):
return None# Test IV computation
test_vol = 0.25
test_call_price = bs_call_price(S, K, T, r, test_vol)
recovered_iv = implied_volatility(test_call_price, S, K, T, r, "call")
print("=== Implied Volatility Test ===")
print(f"Original volatility: {test_vol:.4f}")
print(f"Generated call price: ${test_call_price:.4f}")
print(f"Recovered IV: {recovered_iv:.4f}")
print(f"Error: {abs(test_vol - recovered_iv):.2e}")Output
=== Implied Volatility Test === Original volatility: 0.2500 Generated call price: $5.5984 Recovered IV: 0.2500 Error: 9.26e-12
3. The Greeks: Measuring Option Sensitivities
Greeks measure how option prices change with respect to various inputs. They're essential for:
- Hedging: Neutralizing unwanted exposures
- Risk Management: Understanding portfolio sensitivities
- Trading: Identifying mispriced options
Summary of Greeks
| Greek | Symbol | Measures | Formula |
|---|---|---|---|
| Delta | ∂V/∂S | Price sensitivity to underlying | |
| Gamma | ∂²V/∂S² | Delta sensitivity to underlying | |
| Vega | ∂V/∂σ | Price sensitivity to volatility | |
| Theta | ∂V/∂t | Price sensitivity to time (decay) | |
| Rho | ∂V/∂r | Price sensitivity to interest rate |
Delta ()
Delta measures the rate of change of option price with respect to the underlying:
Interpretation:
- Call delta ranges from 0 to 1
- Put delta ranges from -1 to 0
- ATM options have |Δ| ≈ 0.5
- Delta also approximates probability of finishing ITM
def delta(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> float:
"""Compute Black-Scholes delta."""
if T <= 0:
if option_type.lower() == "call":
return 1.0 if S > K else 0.0
else:
return -1.0 if S < K else 0.0
d_1 = d1(S, K, T, r, sigma)
if option_type.lower() == "call":
return stats.norm.cdf(d_1)
else:
return stats.norm.cdf(d_1) - 1Gamma ()
Gamma measures the rate of change of delta (option's "acceleration"):
Where is the standard normal PDF.
Interpretation:
- Gamma is highest for ATM options near expiration
- Same for calls and puts (by put-call parity)
- High gamma = delta changes rapidly = harder to hedge
def gamma(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""
Compute Black-Scholes gamma (same for calls and puts).
"""
if T <= 0:
return 0.0
d_1 = d1(S, K, T, r, sigma)
return stats.norm.pdf(d_1) / (S * sigma * np.sqrt(T))Vega ()
Vega measures sensitivity to implied volatility:
Interpretation:
- Usually quoted per 1% change in volatility (divide by 100)
- Highest for ATM options with longer time to expiration
- Same for calls and puts
def vega(S: float, K: float, T: float, r: float, sigma: float) -> float:
"""
Compute Black-Scholes vega.
Returns vega per 1 point (100%) change in volatility.
Divide by 100 for vega per 1% change.
"""
if T <= 0:
return 0.0
d_1 = d1(S, K, T, r, sigma)
return S * np.sqrt(T) * stats.norm.pdf(d_1)Theta ()
Theta measures time decay - how option value erodes as time passes:
Interpretation:
- Usually negative (options lose value over time)
- Accelerates as expiration approaches
- Deep ITM puts can have positive theta
def theta(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> float:
"""
Compute Black-Scholes theta (per year).
Divide by 365 for daily theta.
"""
if T <= 0:
return 0.0
d_1 = d1(S, K, T, r, sigma)
d_2 = d2(S, K, T, r, sigma)
term1 = -S * sigma * stats.norm.pdf(d_1) / (2 * np.sqrt(T))
if option_type.lower() == "call":
term2 = -r * K * np.exp(-r * T) * stats.norm.cdf(d_2)
else:
term2 = r * K * np.exp(-r * T) * stats.norm.cdf(-d_2)
return term1 + term2Rho ()
Rho measures sensitivity to interest rates:
Interpretation:
- Less important for short-dated options
- Higher rates benefit calls, hurt puts
- Often the least-monitored Greek
def rho(S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call") -> float:
"""
Compute Black-Scholes rho (per 1 point change in rate).
Divide by 100 for rho per 1% change.
"""
if T <= 0:
return 0.0
d_2 = d2(S, K, T, r, sigma)
if option_type.lower() == "call":
return K * T * np.exp(-r * T) * stats.norm.cdf(d_2)
else:
return -K * T * np.exp(-r * T) * stats.norm.cdf(-d_2)All Greeks Summary Function
def compute_all_greeks(
S: float, K: float, T: float, r: float, sigma: float, option_type: str = "call"
) -> dict:
"""Compute all Greeks for an option."""
return {
"delta": delta(S, K, T, r, sigma, option_type),
"gamma": gamma(S, K, T, r, sigma),
"vega": vega(S, K, T, r, sigma) / 100, # Per 1% vol change
"theta": theta(S, K, T, r, sigma, option_type) / 365, # Daily
"rho": rho(S, K, T, r, sigma, option_type) / 100, # Per 1% rate change
}
# Test the Greeks
test_greeks = compute_all_greeks(S=100, K=100, T=0.25, r=0.05, sigma=0.20)
print("=== Greeks for ATM Call (S=K=100, T=0.25yr, σ=20%) ===")
for greek, value in test_greeks.items():
print(f"{greek.capitalize():>6}: {value:>10.6f}")Output
=== Greeks for ATM Call (S=K=100, T=0.25yr, σ=20%) === Delta: 0.569460 Gamma: 0.039288 Vega: 0.196440 Theta: -0.028696 Rho: 0.130828
4. Greeks Visualization
Understanding how Greeks behave across different strikes and times to expiration is crucial for option traders.
# Generate data for visualization
strikes = np.linspace(80, 120, 41)
S_0 = 100
r_0 = 0.05
sigma_0 = 0.20
times = [0.25, 0.5, 1.0] # 3mo, 6mo, 1yr
# Compute Greeks across strikes for different expirations
greeks_data = []
for T_val in times:
for K_val in strikes:
greeks = compute_all_greeks(S_0, K_val, T_val, r_0, sigma_0, "call")
greeks_data.append(
{
"strike": K_val,
"moneyness": S_0 / K_val,
"time_to_exp": f"{int(T_val * 12)}M",
"T": T_val,
**greeks,
}
)
greeks_df = pl.DataFrame(greeks_data)Delta vs Moneyness
Delta transitions from 0 (deep OTM) to 1 (deep ITM), with the steepest slope at ATM. Shorter-dated options have sharper transitions.
fig = px.line(
greeks_df.to_pandas(),
x="strike",
y="delta",
color="time_to_exp",
title="Call Delta vs Strike Price",
labels={"strike": "Strike Price ($)", "delta": "Delta", "time_to_exp": "Expiration"},
)
fig.add_vline(x=100, line_dash="dash", line_color="gray", annotation_text="ATM")
fig.update_layout(height=400)
fig.show()Output
Gamma Concentration Near ATM
Gamma peaks at ATM and increases dramatically as expiration approaches. This is why short-dated ATM options are difficult to hedge.
fig = px.line(
greeks_df.to_pandas(),
x="strike",
y="gamma",
color="time_to_exp",
title="Gamma vs Strike Price",
labels={"strike": "Strike Price ($)", "gamma": "Gamma", "time_to_exp": "Expiration"},
)
fig.add_vline(x=100, line_dash="dash", line_color="gray", annotation_text="ATM")
fig.update_layout(height=400)
fig.show()Output
Theta Decay Acceleration
Theta (time decay) accelerates as expiration approaches. Options lose value faster in their final weeks.
fig = px.line(
greeks_df.to_pandas(),
x="strike",
y="theta",
color="time_to_exp",
title="Daily Theta vs Strike Price",
labels={
"strike": "Strike Price ($)",
"theta": "Theta ($/day)",
"time_to_exp": "Expiration",
},
)
fig.add_vline(x=100, line_dash="dash", line_color="gray", annotation_text="ATM")
fig.update_layout(height=400)
fig.show()Output
Vega Term Structure
Longer-dated options have higher vega - they're more sensitive to volatility changes. This makes sense: more time means more opportunity for volatility to impact the final payoff.
fig = px.line(
greeks_df.to_pandas(),
x="strike",
y="vega",
color="time_to_exp",
title="Vega vs Strike Price",
labels={
"strike": "Strike Price ($)",
"vega": "Vega ($/1% vol)",
"time_to_exp": "Expiration",
},
)
fig.add_vline(x=100, line_dash="dash", line_color="gray", annotation_text="ATM")
fig.update_layout(height=400)
fig.show()Output
5. Validation Against AlgoSeek Data
Now we validate our Greeks computations against the pre-computed values in the AlgoSeek options dataset. We'll use a risk-free rate from FRED.
options = load_sp500_options_eda(
symbols=["AAPL"],
start_date="2020-01-01",
end_date="2020-12-31",
)
print(f"Loaded {len(options):,} option records")
print(f"Columns: {options.columns}")Output
Loaded 598,046 option records Columns: ['symbol', 'call_put', 'option_style', 'strike', 'expiration', 'years_to_maturity', 'days_to_maturity', 'underlying_price', 'bid', 'ask', 'mid_price', 'implied_vol', 'theo_price', 'delta', 'gamma', 'theta', 'vega', 'rho', 'iv_convergence', 'year', 'timestamp']
# Filter to liquid options for validation
# ATM options with reasonable time to expiration
options_filtered = options.filter(
(pl.col("days_to_maturity").is_between(20, 90))
& (pl.col("implied_vol").is_not_null())
& (pl.col("implied_vol") > 0.05)
& (pl.col("implied_vol") < 2.0)
& (pl.col("delta").is_not_null())
)
print(f"Filtered to {len(options_filtered):,} options")
options_filtered.head(5)Output
Filtered to 185,450 options
shape: (5, 21) ┌────────┬──────────┬──────────────┬────────┬───┬─────────┬────────────────────┬──────┬────────────┐ │ symbol ┆ call_put ┆ option_style ┆ strike ┆ … ┆ rho ┆ iv_convergence ┆ year ┆ timestamp │ │ --- ┆ --- ┆ --- ┆ --- ┆ ┆ --- ┆ --- ┆ --- ┆ --- │ │ str ┆ str ┆ str ┆ f64 ┆ ┆ f64 ┆ str ┆ i64 ┆ date │ ╞════════╪══════════╪══════════════╪════════╪═══╪═════════╪════════════════════╪══════╪════════════╡ │ AAPL ┆ C ┆ A ┆ 185.0 ┆ … ┆ 0.11109 ┆ IntrVal_FlatExtrap ┆ 2020 ┆ 2020-01-02 │ │ ┆ ┆ ┆ ┆ ┆ ┆ ol ┆ ┆ │ │ AAPL ┆ C ┆ A ┆ 190.0 ┆ … ┆ 0.11409 ┆ IntrVal_FlatExtrap ┆ 2020 ┆ 2020-01-02 │ │ ┆ ┆ ┆ ┆ ┆ ┆ ol ┆ ┆ │ │ AAPL ┆ C ┆ A ┆ 195.0 ┆ … ┆ 0.1171 ┆ IntrVal_FlatExtrap ┆ 2020 ┆ 2020-01-02 │ │ ┆ ┆ ┆ ┆ ┆ ┆ ol ┆ ┆ │ │ AAPL ┆ C ┆ A ┆ 200.0 ┆ … ┆ 0.11925 ┆ IntrVal_PutCallPai ┆ 2020 ┆ 2020-01-02 │ │ ┆ ┆ ┆ ┆ ┆ ┆ r ┆ ┆ │ │ AAPL ┆ C ┆ A ┆ 205.0 ┆ … ┆ 0.12231 ┆ IntrVal_PutCallPai ┆ 2020 ┆ 2020-01-02 │ │ ┆ ┆ ┆ ┆ ┆ ┆ r ┆ ┆ │ └────────┴──────────┴──────────────┴────────┴───┴─────────┴────────────────────┴──────┴────────────┘
| symbol | call_put | option_style | strike | expiration | years_to_maturity | days_to_maturity | underlying_price | bid | ask | mid_price | implied_vol | theo_price | delta | gamma | theta | vega | rho | iv_convergence | year | timestamp |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| str | str | str | f64 | date | f64 | i32 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | f64 | str | i64 | date |
| "AAPL" | "C" | "A" | 185.0 | 2020-01-24 | 0.0601093 | 22 | 300.59 | 113.6 | 117.5 | 115.55 | 0.39649 | 115.77483 | 1.0 | 0.0 | -0.00839 | 0.00003 | 0.11109 | "IntrVal_FlatExtrapol" | 2020 | 2020-01-02 |
| "AAPL" | "C" | "A" | 190.0 | 2020-01-24 | 0.0601093 | 22 | 300.59 | 108.65 | 112.5 | 110.575 | 0.39649 | 110.77984 | 1.0 | 0.0 | -0.00862 | 0.00003 | 0.11409 | "IntrVal_FlatExtrapol" | 2020 | 2020-01-02 |
| "AAPL" | "C" | "A" | 195.0 | 2020-01-24 | 0.0601093 | 22 | 300.59 | 103.65 | 107.5 | 105.575 | 0.39649 | 105.78485 | 1.0 | 0.0 | -0.00886 | 0.00003 | 0.1171 | "IntrVal_FlatExtrapol" | 2020 | 2020-01-02 |
| "AAPL" | "C" | "A" | 200.0 | 2020-01-24 | 0.0601093 | 22 | 300.59 | 98.65 | 102.5 | 100.575 | 0.65569 | 100.86079 | 0.99558 | 0.00027 | -0.02242 | 0.0096 | 0.11925 | "IntrVal_PutCallPair" | 2020 | 2020-01-02 |
| "AAPL" | "C" | "A" | 205.0 | 2020-01-24 | 0.0601093 | 22 | 300.59 | 93.7 | 97.5 | 95.6 | 0.60891 | 95.8556 | 0.99588 | 0.00027 | -0.02092 | 0.00902 | 0.12231 | "IntrVal_PutCallPair" | 2020 | 2020-01-02 |
# Compute our Greeks for each option
validation_results = []
for row in options_filtered.head(N_GREEKS_VALIDATE).iter_rows(named=True):
S = row["underlying_price"]
K = row["strike"]
T = row["years_to_maturity"]
sigma = row["implied_vol"]
opt_type = "call" if row["call_put"] == "C" else "put"
# Our computed values
our_delta = delta(S, K, T, RISK_FREE_RATE, sigma, opt_type)
our_gamma = gamma(S, K, T, RISK_FREE_RATE, sigma)
our_vega = vega(S, K, T, RISK_FREE_RATE, sigma) / 100
our_theta = theta(S, K, T, RISK_FREE_RATE, sigma, opt_type) / 365
# AlgoSeek values
algoseek_delta = row["delta"]
algoseek_gamma = row["gamma"]
algoseek_vega = row["vega"]
algoseek_theta = row["theta"]
validation_results.append(
{
"symbol": row["symbol"],
"strike": K,
"days_to_exp": row["days_to_maturity"],
"option_type": opt_type,
"iv": sigma,
"our_delta": our_delta,
"algoseek_delta": algoseek_delta,
"our_gamma": our_gamma,
"algoseek_gamma": algoseek_gamma,
"our_vega": our_vega,
"algoseek_vega": algoseek_vega,
"our_theta": our_theta,
"algoseek_theta": algoseek_theta,
}
)
validation_df = pl.DataFrame(validation_results)# Calculate validation errors
validation_df = validation_df.with_columns(
delta_error=(pl.col("our_delta") - pl.col("algoseek_delta")).abs(),
gamma_error=(pl.col("our_gamma") - pl.col("algoseek_gamma")).abs(),
vega_error=(pl.col("our_vega") - pl.col("algoseek_vega")).abs(),
theta_error=(pl.col("our_theta") - pl.col("algoseek_theta")).abs(),
)
# Summary statistics
print("=== Greeks Validation Summary ===")
print(f"Options validated: {len(validation_df)}")
print()
for greek in ["delta", "gamma", "vega", "theta"]:
error_col = f"{greek}_error"
stats_row = validation_df.select(
pl.col(error_col).mean().alias("mean"),
pl.col(error_col).median().alias("median"),
pl.col(error_col).max().alias("max"),
)
print(f"{greek.capitalize()}:")
print(f" Mean error: {stats_row['mean'][0]:.6f}")
print(f" Median error: {stats_row['median'][0]:.6f}")
print(f" Max error: {stats_row['max'][0]:.6f}")
print()Output
=== Greeks Validation Summary === Options validated: 500 Delta: Mean error: 0.002226 Median error: 0.000782 Max error: 0.010556 Gamma: Mean error: 0.000068 Median error: 0.000030 Max error: 0.000877 Vega: Mean error: 0.002328 Median error: 0.001139 Max error: 0.021729 Theta: Mean error: 0.001260 Median error: 0.000943 Max error: 0.004631
Validation Scatter Plots
# Build all four panels in a single cell — splitting figure construction across
# cells lets papermill flush the inline backend mid-render and capture the
# Delta+Gamma intermediate, leaving Vega/Theta empty in the published figure.
fig = make_subplots(rows=2, cols=2, subplot_titles=["Delta", "Gamma", "Vega", "Theta"])
# Helper: draw scatter + reference 45° line into one panel.
def _add_validation_panel(row: int, col: int, x_col: str, y_col: str, name: str) -> None:
x_vals = validation_df[x_col].to_list()
y_vals = validation_df[y_col].to_list()
fig.add_trace(
go.Scatter(
x=x_vals,
y=y_vals,
mode="markers",
marker=dict(size=4, opacity=0.5),
name=name,
),
row=row,
col=col,
)
lo = min(min(x_vals), min(y_vals))
hi = max(max(x_vals), max(y_vals))
fig.add_trace(
go.Scatter(
x=[lo, hi],
y=[lo, hi],
mode="lines",
line=dict(dash="dash", color="red"),
showlegend=False,
),
row=row,
col=col,
)
_add_validation_panel(1, 1, "algoseek_delta", "our_delta", "Delta")
_add_validation_panel(1, 2, "algoseek_gamma", "our_gamma", "Gamma")
_add_validation_panel(2, 1, "algoseek_vega", "our_vega", "Vega")
_add_validation_panel(2, 2, "algoseek_theta", "our_theta", "Theta")
fig.update_layout(
height=600,
title_text="Our Greeks vs AlgoSeek (perfect = diagonal line)",
showlegend=False,
)
fig.update_xaxes(title_text="AlgoSeek", row=2, col=1)
fig.update_xaxes(title_text="AlgoSeek", row=2, col=2)
fig.update_yaxes(title_text="Our Calculation", row=1, col=1)
fig.update_yaxes(title_text="Our Calculation", row=2, col=1)
fig.show()Output
[省略较大 image/png 输出]
Sources of Discrepancy
Small differences between our calculations and AlgoSeek's pre-computed values can arise from:
- Risk-free rate: We used a constant rate; they may use term-matched rates
- Dividend handling: We ignored dividends; they may adjust for them
- American vs European: Our formulas assume European exercise
- Numerical precision: Different root-finding algorithms
- Timestamp differences: Greeks change throughout the day
6. Computing IV from Market Prices
Let's demonstrate computing implied volatility from the observed option prices and compare to the provided IV values.
# Compute IV for a sample of options
iv_validation = []
for row in options_filtered.head(N_IV_VALIDATE).iter_rows(named=True):
S = row["underlying_price"]
K = row["strike"]
T = row["years_to_maturity"]
market_price = row["mid_price"]
opt_type = "call" if row["call_put"] == "C" else "put"
if market_price is not None and market_price > 0:
computed_iv = implied_volatility(market_price, S, K, T, RISK_FREE_RATE, opt_type)
if computed_iv is not None:
iv_validation.append(
{
"symbol": row["symbol"],
"strike": K,
"days_to_exp": row["days_to_maturity"],
"option_type": opt_type,
"market_price": market_price,
"computed_iv": computed_iv,
"algoseek_iv": row["implied_vol"],
}
)
iv_df = pl.DataFrame(iv_validation)# Compare IVs
iv_df = iv_df.with_columns(
iv_diff=(pl.col("computed_iv") - pl.col("algoseek_iv")).abs(),
iv_pct_diff=((pl.col("computed_iv") - pl.col("algoseek_iv")).abs() / pl.col("algoseek_iv"))
* 100,
)
print("=== IV Validation Summary ===")
print(f"Options validated: {len(iv_df)}")
print(f"Mean IV difference: {iv_df['iv_diff'].mean():.4f}")
print(f"Median IV difference: {iv_df['iv_diff'].median():.4f}")
print(f"Mean % difference: {iv_df['iv_pct_diff'].mean():.2f}%")Output
=== IV Validation Summary === Options validated: 179 Mean IV difference: 0.0237 Median IV difference: 0.0005 Mean % difference: 7.26%
# Scatter plot
fig = px.scatter(
iv_df.to_pandas(),
x="algoseek_iv",
y="computed_iv",
color="option_type",
title="Computed IV vs AlgoSeek IV",
labels={
"algoseek_iv": "AlgoSeek IV",
"computed_iv": "Our Computed IV",
"option_type": "Type",
},
opacity=0.6,
)
fig.add_trace(
go.Scatter(
x=[0, 1],
y=[0, 1],
mode="lines",
line=dict(dash="dash", color="gray"),
name="Perfect Match",
)
)
fig.update_layout(height=500)
fig.show()Output
7. Practical Considerations
When Black-Scholes Breaks Down
| Scenario | Problem | Alternative Approach |
|---|---|---|
| Deep OTM options | Log-normal underprices tails | Use jump-diffusion or local vol |
| Short-dated ATM | Gamma explosion | Use realized vol, not IV |
| Dividend stocks | Wrong forward price | Use dividend-adjusted models |
| American options | Early exercise value | Use binomial tree or approximations |
| Vol clustering | Constant vol assumption | Use GARCH or stochastic vol |
The Greeks in Practice
For Hedging:
- Delta-hedge by trading underlying shares
- Gamma tells you how often to rebalance
- Vega exposure from vol moves often dominates
For Trading:
- Greeks help identify relative value
- High gamma + low premium = potential mispricing
- Theta/vega ratio useful for vol trades
For Risk Management:
- Aggregate portfolio Greeks
- Stress test under extreme scenarios
- Greeks are local approximations - large moves need full repricing
8. Key Takeaways
- Black-Scholes pricing recovers put-call parity to numerical precision (~7e-15 on the test parameters); the same code base solves IV via Brent's method to ~10⁻¹¹ accuracy on a closed-loop test.
- Greeks computed in-house track the AlgoSeek vendor values closely on AAPL options (20–90 DTE, IV ∈ [0.05, 2]): mean absolute errors are ~2×10⁻³ for delta and vega, ~7×10⁻⁵ for gamma, ~1×10⁻³ for daily theta.
- The remaining residual reflects modeling choices that the vendor handles differently — term-matched risk-free rates, dividend treatment, American early-exercise premium, and intraday timestamp drift in the inputs.
- Cross-sectional IV recovery (≈180 options) yields a median absolute IV diff of ~5×10⁻⁴ and a mean of ~0.024, with the residual concentrated in deep OTM contracts where the loss surface is shallow.
- Greeks are local sensitivities — the visualizations show how Δ steepens and Γ peaks near ATM as expiration approaches, which is also the regime where the Black-Scholes assumption set is most stressed.
Next Steps
- Chapter 8: Build features from options data (IV signals, skew measures).
- Chapter 9: Evaluate IV-based signals for equity prediction.
- Chapter 12+: ML models using options-derived features.
- Chapter 16: Strategy backtests including the
sp500_optionsshort-straddle case study and thesp500_equity_option_analyticsIV-features case study.
