Volatility Smiles and Surfaces
Why Black-Scholes assumes constant volatility, why reality disagrees, and how quants model the difference.
The Constant Volatility Assumption
Black-Scholes assumes one single volatility number applies to all options on a given stock, regardless of strike price or expiry. If this were true, you could plug in any option's market price, solve for the implied volatility, and get the same number every time. Try this with real market data and you will quickly discover it does not work.
Different strikes and different expiries produce different implied volatilities. This is not a failure of the extraction method — it is a failure of the Black-Scholes assumption. The market is telling you that it does not believe returns are log-normally distributed with constant volatility.
Implied Volatility: Reverse-Engineering
Implied volatility (IV) is the volatility you must plug into Black-Scholes to match the observed market price. It is found numerically: there is no closed-form inverse of Black-Scholes. We use Brent's root-finding method to solve BS(σ) = Market Price for σ.
from scipy.optimize import brentq
from scipy.stats import norm
import numpy as np
def black_scholes_call(S, K, T, r, sigma):
d1 = (np.log(S / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
def implied_vol(market_price, S, K, T, r):
"""
Extract implied volatility from market price.
Uses Brent's method to solve: BS(sigma) = market_price
"""
def objective(sigma):
return black_scholes_call(S, K, T, r, sigma) - market_price
try:
iv = brentq(objective, 0.001, 5.0)
return iv
except ValueError:
return None
# Example: extract IV from a market price
S = 100
K = 100
T = 0.25
r = 0.05
market_price = 5.50 # Observed market price
iv = implied_vol(market_price, S, K, T, r)
print(f"Implied Volatility: {iv:.2%}")The Volatility Smile
Plot IV against strike price for a fixed expiry and you see a characteristic pattern. For equity indices, out-of-the-money puts have much higher IV than at-the-money options. This is called the volatility skew. For FX options, both OTM puts and OTM calls have elevated IV, forming a symmetric smile.
def build_smile(S, T, r, strikes, market_prices):
"""Build a volatility smile from market option prices."""
ivs = []
for K, price in zip(strikes, market_prices):
iv = implied_vol(price, S, K, T, r)
ivs.append(iv)
moneyness = K / S
print(f" K={K:>6.0f} K/S={moneyness:.2f} Price={price:>6.2f} IV={iv:.2%}")
return ivs
# Simulated market data showing a smile/skew
S = 100
strikes = [80, 85, 90, 95, 100, 105, 110, 115, 120]
market_prices = [21.50, 17.20, 13.10, 9.50, 6.40, 4.00, 2.30, 1.20, 0.55]
print("Volatility Smile:")
ivs = build_smile(S, 0.25, 0.05, strikes, market_prices)Why the Skew Exists
Crash protection. After the 1987 Black Monday crash (when the S&P 500 dropped 22% in one day), market participants permanently repriced downside risk. OTM puts are essentially crash insurance, and like any insurance, they cost more when the risk feels real. The market prices in fat tails that Black-Scholes does not model.
Supply and demand. Portfolio managers constantly buy puts to hedge their equity portfolios. This persistent demand pushes up put prices and their implied volatilities. There is no equivalent systematic demand for OTM calls.
The Volatility Surface
The full picture is a 3D surface: implied volatility as a function of both strike price and time to expiry. Short-dated options typically show a steeper skew than long-dated options (the smile flattens with time). This surface encodes the market's complete view of risk.
def build_surface(S, r, strikes, expiries, price_matrix):
"""
Build a volatility surface: IV across strikes AND expiries.
price_matrix[i][j] = market price for expiry i, strike j
"""
surface = []
for i, T in enumerate(expiries):
row = []
for j, K in enumerate(strikes):
iv = implied_vol(price_matrix[i][j], S, K, T, r)
row.append(iv)
surface.append(row)
# Print as table
header = "Expiry | " + " | ".join(f"K={k}" for k in strikes)
print(header)
print("-" * len(header))
for i, T in enumerate(expiries):
vals = " | ".join(f"{v:.1%}" if v else " N/A" for v in surface[i])
print(f"{T:>5.2f}y | {vals}")
return surface
# Example surface data
strikes = [90, 95, 100, 105, 110]
expiries = [0.08, 0.25, 0.50, 1.00] # 1M, 3M, 6M, 1Y
prices = [
[11.00, 7.10, 3.90, 1.60, 0.45], # 1 month
[13.10, 9.50, 6.40, 4.00, 2.30], # 3 months
[15.80, 12.30, 9.20, 6.70, 4.60], # 6 months
[19.50, 16.20, 13.20, 10.60, 8.30], # 1 year
]
print("
Volatility Surface:")
surface = build_surface(100, 0.05, strikes, expiries, prices)Key Surface Features
Skew: IV(OTM put) > IV(ATM) > IV(OTM call) for equities
Term structure: IV can be upward or downward sloping with time
Smile: symmetric U-shape common in FX markets
Smirk: asymmetric skew typical of equity index options
Why This Matters for Trading
Exotic options desks at banks like Barclays, HSBC, and Goldman Sachs in London need to price options that are not liquid in the market. They calibrate models (local vol, stochastic vol, SABR) to match the observed volatility surface, then use those models to price bespoke derivatives. If your model does not match the surface, your prices will be wrong and you will either overpay or underprice risk.
For systematic traders, changes in the volatility surface signal shifts in market sentiment. A steepening skew means increased demand for downside protection. A collapsing term structure means the market expects near-term turmoil to resolve quickly.
Interview Warning
Never say "the volatility smile is a failure of Black-Scholes." It is a feature, not a bug. Black-Scholes is a quoting convention — a common language for expressing option prices in terms of volatility. Everyone knows the assumptions are violated. The model is used precisely because it allows traders to communicate by quoting a single number (IV) rather than a raw price.