The claim we test: Black-Scholes assumes a single, constant volatility — so it predicts the same implied vol at every strike. The market flatly disagrees: implied vol curves into a smile/skew. The Heston model, by letting volatility itself be random, can bend to fit that curve. This piece pits the two against a real SPX-style implied-vol surface and measures who fits, by how much, and at what cost.
import numpy as np
import matplotlib.pyplot as plt
np.random.seed(42)
plt.rcParams["figure.dpi"] = 1101.The disagreement
Black-Scholes takes one volatility and returns one price per strike. Invert real option prices back into implied vols and — if BS were right — you'd get a flat line: the same at every strike. Instead, equity index options show a pronounced skew: deep out-of-the-money puts trade at much higher implied vol than at-the-money or upside calls.
Why? Two BS assumptions break:
- Volatility isn't constant — it clusters, spikes, and mean-reverts.
- Returns aren't Gaussian — crashes are fatter and more frequent than a normal distribution allows, and they cluster in falling markets.
The skew is the market pricing in exactly those two facts. Any single-vol model is structurally unable to reproduce it.
2.The contenders
| Black-Scholes | Heston | |
|---|---|---|
| Volatility | Constant | Stochastic — its own mean-reverting process |
| Free parameters | 1 | 5 |
| Returns distribution | Log-normal (Gaussian log-returns) | Fat-tailed, skewed |
| Can fit the smile? | No — flat by construction | Yes |
| Closed form? | Yes (simple) | Semi-closed (characteristic function) |
Black-Scholes assumes — the GBM from Tutorial 1.
Heston adds a second equation for variance :
with . The five parameters: (mean-reversion speed), (long-run variance), (vol-of-vol), (spot-vol correlation, the skew driver), and (initial variance).
3.The test
We build a representative SPX implied-vol skew (ATM ≈ 20%, steep put wing — the shape index options actually trade) and give both models the same job: match it.
- Black-Scholes gets one degree of freedom — its best move is a single flat line, so we set it to the ATM vol.
- Heston gets calibrated: we search its five parameters to minimise the gap to the market smile.
Note: we use a representative parametric skew so the notebook runs without a live option-chain subscription; the calibration machinery is identical for a real SPX/SPY chain.
# --- representative SPX 3M implied-vol skew (moneyness K/S -> implied vol) ---
S0, T, r, q = 100.0, 0.25, 0.03, 0.0
moneyness = np.array([0.80, 0.85, 0.90, 0.95, 1.00, 1.05, 1.10, 1.15, 1.20])
strikes = moneyness * S0
# market smile: high vol in the put wing, gentle rise in far calls (typical equity skew)
mkt_iv = np.array([0.32, 0.285, 0.255, 0.228, 0.205, 0.192, 0.188, 0.191, 0.198])
fig, ax = plt.subplots(figsize=(9, 5))
ax.plot(moneyness, mkt_iv * 100, "o-", color="black", label="Market implied vol (SPX-style skew)")
ax.axhline(mkt_iv[4] * 100, color="steelblue", ls="--", label="Black-Scholes (single vol = ATM)")
ax.set_xlabel("Moneyness K / S"); ax.set_ylabel("Implied volatility (%)")
ax.set_title("The disagreement: market skew vs the flat line BS must draw")
ax.legend(); plt.tight_layout(); plt.show()Black-Scholes is a horizontal line. The market is a curve. That gap — biggest in the crash-protection put wing — is the mispricing a single-vol model bakes into every out-of-the-money option.
3.1Calibrate Heston (QuantLib pricing + bounded optimisation)
A single-maturity smile under-determines five parameters — unconstrained optimisers happily wander into absurd values ( in the thousands) that fit the curve for the wrong reasons. So we calibrate with economically sensible bounds: mean-reversion , vol-of-vol , correlation , variances . QuantLib prices; SciPy searches.
import QuantLib as ql
from scipy.optimize import least_squares
today = ql.Date(15, 6, 2024)
ql.Settings.instance().evaluationDate = today
day_count = ql.Actual365Fixed()
calendar = ql.NullCalendar()
spot_h = ql.QuoteHandle(ql.SimpleQuote(S0))
r_ts = ql.YieldTermStructureHandle(ql.FlatForward(today, r, day_count))
q_ts = ql.YieldTermStructureHandle(ql.FlatForward(today, q, day_count))
maturity = ql.Period(3, ql.Months)
expiry = today + maturity
def heston_ivs(params):
"""Price every strike under Heston, return Black-Scholes implied vols."""
v0, kappa, theta, xi, rho = params
proc = ql.HestonProcess(r_ts, q_ts, spot_h, v0, kappa, theta, xi, rho)
eng = ql.AnalyticHestonEngine(ql.HestonModel(proc))
bsm = ql.BlackScholesMertonProcess(spot_h, q_ts, r_ts,
ql.BlackVolTermStructureHandle(
ql.BlackConstantVol(today, calendar, 0.20, day_count)))
ivs = []
for K in strikes:
opt = ql.VanillaOption(ql.PlainVanillaPayoff(ql.Option.Call, K),
ql.EuropeanExercise(expiry))
opt.setPricingEngine(eng)
try:
ivs.append(opt.impliedVolatility(opt.NPV(), bsm))
except Exception:
ivs.append(np.nan)
return np.array(ivs)
def residuals(p):
return np.nan_to_num(heston_ivs(p) - mkt_iv, nan=0.5)
x0 = [0.042, 2.0, 0.045, 0.8, -0.7] # v0, kappa, theta, xi, rho
bounds = ([0.005, 0.5, 0.005, 0.05, -0.95], [0.25, 8.0, 0.25, 2.0, -0.05])
res = least_squares(residuals, x0, bounds=bounds, xtol=1e-12, ftol=1e-12)
v0, kappa, theta, xi, rho = res.x
print("Calibrated Heston parameters (bounded):")
print(f" v0 (initial var) = {v0:.4f} -> vol {np.sqrt(v0):.1%}")
print(f" theta (long-run var) = {theta:.4f} -> vol {np.sqrt(theta):.1%}")
print(f" kappa (mean-reversion) = {kappa:.2f}")
print(f" xi (vol-of-vol) = {xi:.2f}")
print(f" rho (spot-vol corr) = {rho:.2f} <- negative = downside skew")3.2Recover Heston's implied-vol smile
Price each strike under the calibrated model and invert to implied vol — same axis as the market curve.
heston_iv = heston_ivs(res.x)
bs_iv = np.full_like(mkt_iv, mkt_iv[4]) # flat at ATM
print("moneyness market heston BS(flat)")
for m, mk, he, bs in zip(moneyness, mkt_iv, heston_iv, bs_iv):
print(f" {m:.2f} {mk:6.1%} {he:6.1%} {bs:6.1%}")4.The scoreboard
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5))
# left: the three curves
ax1.plot(moneyness, mkt_iv * 100, "o-", color="black", label="Market", lw=2)
ax1.plot(moneyness, heston_iv * 100, "s--", color="crimson", label="Heston (calibrated)")
ax1.plot(moneyness, bs_iv * 100, "--", color="steelblue", label="Black-Scholes (flat)")
ax1.set_xlabel("Moneyness K / S"); ax1.set_ylabel("Implied vol (%)")
ax1.set_title("Fit to the smile"); ax1.legend()
# right: absolute pricing error in vol points
bs_err = np.abs(bs_iv - mkt_iv) * 100
he_err = np.abs(heston_iv - mkt_iv) * 100
x = np.arange(len(moneyness)); w = 0.38
ax2.bar(x - w/2, bs_err, w, color="steelblue", label="Black-Scholes")
ax2.bar(x + w/2, he_err, w, color="crimson", label="Heston")
ax2.set_xticks(x); ax2.set_xticklabels([f"{m:.2f}" for m in moneyness], rotation=45)
ax2.set_xlabel("Moneyness K / S"); ax2.set_ylabel("|error| (vol points)")
ax2.set_title("Absolute implied-vol error by strike"); ax2.legend()
plt.tight_layout(); plt.show()
print(f"Mean abs error — Black-Scholes: {bs_err.mean():.2f} vol pts")
print(f"Mean abs error — Heston: {he_err.mean():.2f} vol pts")
print(f"Worst strike (put wing) — BS: {bs_err[0]:.2f} pts vs Heston: {he_err[0]:.2f} pts")The picture is decisive where it matters most — the put wing. Black-Scholes underprices crash protection by a wide margin (its flat line sits far below the market's elevated put vols), while Heston tracks the curve because its negative generates exactly that downside skew. Across the surface Heston's mean error is a fraction of Black-Scholes'.
5.Verdict
Heston wins the fit — decisively — and it isn't close in the wings. With five parameters versus one, that's expected; the real question is whether the extra complexity earns its keep. The answer depends on the job:
- Pricing a single vanilla at one strike? Black-Scholes with that strike's own implied vol is simpler and exact. The smile is a lookup table; you don't need a model to read one row.
- Pricing a book across many strikes consistently, or anything exotic (barriers, cliquets, forward-starts) whose value depends on the dynamics of vol? Heston is close to essential — a flat vol would misprice the smile-sensitive payoff.
What each model really is:
- Black-Scholes isn't wrong so much as incomplete — it's the quoting convention (implied vol) more than a dynamic model. Its flat smile is a feature of using one number, not a bug to be fixed.
- Heston buys smile-consistency and fat tails at the cost of five parameters that must be recalibrated as the surface moves, and a fit that can still miss very short maturities (where jumps, not stochastic vol, drive the steep skew).
The honest trade-off: more parameters always fit better in-sample. Heston earns trust because its parameters are economically interpretable ( is the skew, the smile convexity, the vol term structure) — it's fitting the smile for the right reasons, not just curve-fitting.
Where to go next: for the very short-dated skew Heston struggles with, add jumps (Bates, Heston + Merton jumps); to fit the entire observed surface exactly rather than approximately, Dupire local volatility; and for the constant-vol baseline both are measured against, revisit the Black-Scholes tutorial.
