Volatility Smile Calibration (cantaro86)¶
Background¶
Volatility Smile and Model Calibration¶
Based on cantaro86's notebook "4.2 Volatility smile and model calibration.ipynb" from the Financial-Models-Numerical-Methods repository: https://github.com/cantaro86/Financial-Models-Numerical-Methods
This self-contained module demonstrates:
- How Merton jump-diffusion, Variance Gamma, and Heston stochastic volatility models produce implied volatility smiles, while Black-Scholes produces a flat smile.
- Fourier-based option pricing via Gil-Pelaez inversion and Lewis FFT.
- Calibration of model parameters to synthetic market data.
- The practical observation that the Feller condition is rarely satisfied in calibrated Heston models.
All implementations are self-contained with no external model library dependencies. Only numpy, scipy, and matplotlib are required.
References: [1] Rebonato, R. (1999) "Volatility and Correlation", Wiley. [2] Gatheral, J. (2006) "The Volatility Surface: A Practitioner's Guide", Wiley. [3] Cui, del Bano Rollin, Germano (2017) "Full and fast calibration of the Heston stochastic volatility model", European J. of Operational Research. [4] Schoutens, W. (2004) "Levy Processes in Finance: Pricing Financial Derivatives", Wiley.
Code¶
```python """ Volatility Smile and Model Calibration =======================================
Based on cantaro86's notebook "4.2 Volatility smile and model calibration.ipynb" from the Financial-Models-Numerical-Methods repository: https://github.com/cantaro86/Financial-Models-Numerical-Methods
This self-contained module demonstrates: 1. How Merton jump-diffusion, Variance Gamma, and Heston stochastic volatility models produce implied volatility smiles, while Black-Scholes produces a flat smile. 2. Fourier-based option pricing via Gil-Pelaez inversion and Lewis FFT. 3. Calibration of model parameters to synthetic market data. 4. The practical observation that the Feller condition is rarely satisfied in calibrated Heston models.
All implementations are self-contained with no external model library dependencies. Only numpy, scipy, and matplotlib are required.
References: [1] Rebonato, R. (1999) "Volatility and Correlation", Wiley. [2] Gatheral, J. (2006) "The Volatility Surface: A Practitioner's Guide", Wiley. [3] Cui, del Bano Rollin, Germano (2017) "Full and fast calibration of the Heston stochastic volatility model", European J. of Operational Research. [4] Schoutens, W. (2004) "Levy Processes in Finance: Pricing Financial Derivatives", Wiley. """
import numpy as np import scipy.stats as ss import scipy.optimize as scpo from scipy.fftpack import ifft from scipy.interpolate import interp1d from scipy.integrate import quad from functools import partial import matplotlib.pyplot as plt import warnings
warnings.filterwarnings("ignore")
=============================================================================¶
Section 1: Characteristic Functions¶
=============================================================================¶
def cf_normal(u, t, mu, sigma): """ Characteristic function of a Normal random variable X ~ N(mut, sigma^2t).
This is the characteristic function of the log-return under Black-Scholes,
where the log-price increment over time t is normally distributed.
Parameters
----------
u : array_like
Fourier variable.
t : float
Time horizon.
mu : float
Drift (risk-neutral: r - 0.5*sigma^2).
sigma : float
Diffusion coefficient.
Returns
-------
complex ndarray
phi(u) = exp(i*u*mu*t - 0.5*u^2*sigma^2*t)
"""
return np.exp(1j * u * mu * t - 0.5 * u**2 * sigma**2 * t)
def cf_merton(u, t, mu, sig, lam, muJ, sigJ): """ Characteristic function of the Merton jump-diffusion model at time t.
The log-price follows:
X_t = mu*t + sig*W_t + sum_{i=1}^{N_t} J_i
where N_t ~ Poisson(lam*t) and J_i ~ N(muJ, sigJ^2).
Parameters
----------
u : array_like
Fourier variable.
t : float
Time horizon.
mu : float
Drift of the continuous part (risk-neutral adjusted).
sig : float
Diffusion coefficient of the continuous part.
lam : float
Jump intensity (average number of jumps per year).
muJ : float
Mean of the jump size (in log space).
sigJ : float
Standard deviation of the jump size.
Returns
-------
complex ndarray
"""
return np.exp(
t * (
1j * u * mu
- 0.5 * u**2 * sig**2
+ lam * (np.exp(1j * u * muJ - 0.5 * u**2 * sigJ**2) - 1)
)
)
def cf_VG(u, t, mu, theta, sigma, kappa): """ Characteristic function of the Variance Gamma process at time t.
The VG process is a Brownian motion with drift, time-changed by a
Gamma process. The log-price is:
X_t = mu*t + theta*G_t + sigma*W_{G_t}
where G_t is a Gamma process with unit mean rate and variance rate kappa.
Parameters
----------
u : array_like
Fourier variable.
t : float
Time horizon.
mu : float
Additional drift (risk-neutral adjusted).
theta : float
Drift of the Brownian motion subordinated by the Gamma process.
sigma : float
Volatility of the subordinated Brownian motion.
kappa : float
Variance rate of the Gamma time change.
Returns
-------
complex ndarray
"""
return np.exp(
t * (
1j * mu * u
- np.log(1 - 1j * theta * kappa * u + 0.5 * kappa * sigma**2 * u**2) / kappa
)
)
def cf_heston(u, t, v0, mu, kappa, theta, sigma, rho): """ Characteristic function of the Heston stochastic volatility model.
Uses the Schoutens (2004) formulation which is numerically more stable
than the original Heston (1993) formulation. The key difference is using
g2 = 1/g1 and (xi - d) terms instead of (xi + d), which avoids
exponentially growing terms.
The Heston model:
dS_t = mu*S_t*dt + sqrt(v_t)*S_t*dW_1
dv_t = kappa*(theta - v_t)*dt + sigma*sqrt(v_t)*dW_2
dW_1*dW_2 = rho*dt
Parameters
----------
u : array_like
Fourier variable.
t : float
Time horizon.
v0 : float
Initial variance.
mu : float
Drift (risk-free rate under risk-neutral measure).
kappa : float
Mean reversion speed of variance.
theta : float
Long-run variance level.
sigma : float
Volatility of variance (vol of vol).
rho : float
Correlation between stock and variance Brownian motions.
Returns
-------
complex ndarray
Notes
-----
The Feller condition 2*kappa*theta > sigma^2 ensures that the variance
process v_t stays strictly positive. In practice, calibrated parameters
frequently violate this condition.
"""
xi = kappa - sigma * rho * u * 1j
d = np.sqrt(xi**2 + sigma**2 * (u**2 + 1j * u))
g1 = (xi + d) / (xi - d)
g2 = 1.0 / g1
cf = np.exp(
1j * u * mu * t
+ (kappa * theta) / (sigma**2) * (
(xi - d) * t - 2 * np.log((1 - g2 * np.exp(-d * t)) / (1 - g2))
)
+ (v0 / sigma**2) * (xi - d) * (1 - np.exp(-d * t)) / (1 - g2 * np.exp(-d * t))
)
return cf
=============================================================================¶
Section 2: Fourier Pricing Functions (Gil-Pelaez Inversion)¶
=============================================================================¶
def Q1(k, cf, right_lim): """ Probability of being in-the-money under the stock numeraire measure.
Computes P(X < k) using the Gil-Pelaez inversion formula, where X is the
log-moneyness log(K/S0) and cf is the characteristic function of the
log-price increment.
Parameters
----------
k : float
Log-moneyness: log(K / S0).
cf : callable
Characteristic function cf(u).
right_lim : float
Upper integration limit (use np.inf for exact, or large finite value).
Returns
-------
float
Probability under stock numeraire.
"""
def integrand(u):
return np.real(
(np.exp(-u * k * 1j) / (u * 1j)) * cf(u - 1j) / cf(-1.0000000000001j)
)
return 0.5 + (1.0 / np.pi) * quad(integrand, 1e-15, right_lim, limit=2000)[0]
def Q2(k, cf, right_lim): """ Probability of being in-the-money under the money market numeraire.
Computes P(X < k) using the Gil-Pelaez inversion formula under the
risk-neutral measure.
Parameters
----------
k : float
Log-moneyness: log(K / S0).
cf : callable
Characteristic function cf(u).
right_lim : float
Upper integration limit.
Returns
-------
float
Probability under money market numeraire.
"""
def integrand(u):
return np.real(np.exp(-u * k * 1j) / (u * 1j) * cf(u))
return 0.5 + (1.0 / np.pi) * quad(integrand, 1e-15, right_lim, limit=2000)[0]
def fourier_call_price(S0, K, r, T, cf): """ European call price via Fourier inversion of the characteristic function.
Uses the decomposition:
Call = S0 * Q1 - K * exp(-rT) * Q2
where Q1, Q2 are the in-the-money probabilities under the stock and
money market numeraire measures respectively.
Parameters
----------
S0 : float
Spot price.
K : float
Strike price.
r : float
Risk-free interest rate.
T : float
Time to maturity.
cf : callable
Characteristic function of the log-price increment cf(u).
Returns
-------
float
European call option price.
"""
k = np.log(K / S0)
call = S0 * Q1(k, cf, np.inf) - K * np.exp(-r * T) * Q2(k, cf, np.inf)
return call
=============================================================================¶
Section 3: Lewis FFT Pricing¶
=============================================================================¶
def fft_lewis(K_array, S0, r, T, cf, N=2**15, B=500, interp="cubic"): """ Option pricing via Lewis (2001) FFT representation.
Uses the Lewis formula:
Call(K) = S0 - sqrt(S0*K) * exp(-rT) / pi * integral
where the integral is evaluated using IFFT with Simpson quadrature weights
and cubic spline interpolation.
Parameters
----------
K_array : array_like
Array of strike prices.
S0 : float
Spot price.
r : float
Risk-free interest rate.
T : float
Time to maturity.
cf : callable
Characteristic function cf(u) of the log-price increment.
N : int, optional
Number of FFT grid points (should be power of 2). Default is 2^15.
B : float, optional
Integration upper bound. Default is 500.
interp : str, optional
Interpolation method: "cubic" or "linear". Default is "cubic".
Returns
-------
ndarray
Array of European call option prices corresponding to K_array.
Notes
-----
The integrand involves cf(x - 0.5j) / (x^2 + 0.25) which is the Lewis
representation. Simpson weights are applied for accuracy, and the IFFT
provides simultaneous evaluation at all log-moneyness grid points.
"""
K_array = np.asarray(K_array, dtype=float)
dx = B / N
x = np.arange(N) * dx # integration grid [0, dx, 2*dx, ..., (N-1)*dx]
# Simpson quadrature weights: 1, 4/3, 2/3, 4/3, 2/3, ..., 4/3, 1
weight = 3 + (-1) ** (np.arange(N) + 1)
weight[0] = 1
weight[N - 1] = 1
dk = 2 * np.pi / B
b = N * dk / 2.0
ks = -b + dk * np.arange(N) # log-moneyness grid
# Build integrand for IFFT
integrand = (
np.exp(-1j * b * np.arange(N) * dx)
* cf(x - 0.5j)
* 1.0 / (x**2 + 0.25)
* weight * dx / 3.0
)
integral_value = np.real(ifft(integrand) * N)
# Interpolate to requested strikes
spline = interp1d(ks, integral_value, kind=interp)
log_moneyness = np.log(S0 / K_array)
prices = S0 - np.sqrt(S0 * K_array) * np.exp(-r * T) / np.pi * spline(log_moneyness)
return prices
=============================================================================¶
Section 4: Black-Scholes Formula and Implied Volatility¶
=============================================================================¶
def bs_call_price(S0, K, T, r, sigma): """ Black-Scholes European call option price.
Parameters
----------
S0 : float
Spot price.
K : float
Strike price.
T : float
Time to maturity in years.
r : float
Risk-free interest rate.
sigma : float
Volatility.
Returns
-------
float
Call option price.
"""
if sigma <= 0 or T <= 0:
return max(S0 - K * np.exp(-r * T), 0.0)
d1 = (np.log(S0 / K) + (r + 0.5 * sigma**2) * T) / (sigma * np.sqrt(T))
d2 = d1 - sigma * np.sqrt(T)
return S0 * ss.norm.cdf(d1) - K * np.exp(-r * T) * ss.norm.cdf(d2)
def bs_put_price(S0, K, T, r, sigma): """ Black-Scholes European put option price via put-call parity. """ return bs_call_price(S0, K, T, r, sigma) - S0 + K * np.exp(-r * T)
def implied_vol(price, S0, K, T, r, payoff="call"): """ Extract implied volatility from an option price using Brent's method.
Finds sigma such that BS(S0, K, T, r, sigma) = price.
Parameters
----------
price : float
Observed option price.
S0 : float
Spot price.
K : float
Strike price.
T : float
Time to maturity.
r : float
Risk-free interest rate.
payoff : str, optional
"call" or "put". Default is "call".
Returns
-------
float
Implied volatility, or -1 if extraction fails.
"""
bs_func = bs_call_price if payoff == "call" else bs_put_price
# Check that the price is within no-arbitrage bounds
intrinsic = max(S0 - K * np.exp(-r * T), 0.0) if payoff == "call" else max(K * np.exp(-r * T) - S0, 0.0)
if price < intrinsic - 1e-10:
return -1.0
def obj(vol):
return bs_func(S0, K, T, r, vol) - price
try:
iv = scpo.brentq(obj, 1e-12, 10.0, xtol=1e-12, maxiter=200)
return iv
except (ValueError, RuntimeError):
# Fallback: minimization-based approach
try:
res = scpo.minimize_scalar(
lambda vol: (bs_func(S0, K, T, r, vol) - price) ** 2,
bounds=(1e-12, 10.0),
method="bounded",
)
if res.success and res.fun < 1e-16:
return res.x
except Exception:
pass
return -1.0
=============================================================================¶
Section 5: Helper -- Build Risk-Neutral Characteristic Functions¶
=============================================================================¶
def _build_cf_bs(r, sigma, T): """Build BS characteristic function with risk-neutral drift.""" mu_rn = r - 0.5 * sigma**2 return partial(cf_normal, t=T, mu=mu_rn, sigma=sigma)
def _build_cf_merton(r, sig, lam, muJ, sigJ, T): """Build Merton CF with risk-neutral drift adjustment.""" m = lam * (np.exp(muJ + 0.5 * sigJ2) - 1) # compensator mu_rn = r - 0.5 * sig2 - m return partial(cf_merton, t=T, mu=mu_rn, sig=sig, lam=lam, muJ=muJ, sigJ=sigJ)
def _build_cf_vg(r, theta, sigma, kappa, T): """Build VG CF with risk-neutral drift adjustment (omega).""" w = -np.log(1 - theta * kappa - 0.5 * kappa * sigma**2) / kappa mu_rn = r - w return partial(cf_VG, t=T, mu=mu_rn, theta=theta, sigma=sigma, kappa=kappa)
def _build_cf_heston(r, v0, kappa, theta, sigma, rho, T): """Build Heston CF (Schoutens formulation).""" return partial(cf_heston, t=T, v0=v0, mu=r, kappa=kappa, theta=theta, sigma=sigma, rho=rho)
=============================================================================¶
Section 6: Pricing Wrappers for Strike Arrays¶
=============================================================================¶
def price_bs_strikes(strikes, S0, r, T, sigma): """Price European calls for an array of strikes using BS FFT.""" cf = _build_cf_bs(r, sigma, T) return fft_lewis(strikes, S0, r, T, cf)
def price_merton_strikes(strikes, S0, r, T, sig, lam, muJ, sigJ): """Price European calls for an array of strikes using Merton FFT.""" cf = _build_cf_merton(r, sig, lam, muJ, sigJ, T) return fft_lewis(strikes, S0, r, T, cf)
def price_vg_strikes(strikes, S0, r, T, theta, sigma, kappa): """Price European calls for an array of strikes using VG FFT.""" cf = _build_cf_vg(r, theta, sigma, kappa, T) return fft_lewis(strikes, S0, r, T, cf)
def price_heston_strikes(strikes, S0, r, T, v0, kappa, theta, sigma, rho): """Price European calls for an array of strikes using Heston FFT.""" cf = _build_cf_heston(r, v0, kappa, theta, sigma, rho, T) return fft_lewis(strikes, S0, r, T, cf)
=============================================================================¶
Main Demonstration¶
=============================================================================¶
if name == "main":
# -------------------------------------------------------------------------
# Common parameters
# -------------------------------------------------------------------------
S0 = 100.0
K_atm = 100.0
T = 1.0
r = 0.05
# Model parameters chosen so that processes have comparable variance
# BS
bs_sigma = 0.20
# Merton jump-diffusion
mert_sig = 0.12
mert_lam = 0.8
mert_muJ = -0.10
mert_sigJ = 0.15
# Variance Gamma
vg_sigma = 0.12
vg_theta = -0.14
vg_kappa = 0.20
# Heston stochastic volatility
hest_v0 = 0.04
hest_kappa = 1.5
hest_theta = 0.04
hest_sigma = 0.6
hest_rho = -0.7
strikes = np.arange(50, 151, 5, dtype=float)
# -------------------------------------------------------------------------
# Part A: Generate Option Prices Across Strikes
# -------------------------------------------------------------------------
print("=" * 72)
print("PART A: Volatility Smile Generation")
print("=" * 72)
print(f"\nParameters: S0={S0}, T={T}, r={r}")
print(f" BS: sigma={bs_sigma}")
print(f" Merton: sig={mert_sig}, lam={mert_lam}, muJ={mert_muJ}, sigJ={mert_sigJ}")
print(f" VG: sigma={vg_sigma}, theta={vg_theta}, kappa={vg_kappa}")
print(f" Heston: v0={hest_v0}, kappa={hest_kappa}, theta={hest_theta}, "
f"sigma={hest_sigma}, rho={hest_rho}")
print("\nPricing options across strikes K = [50, 55, ..., 150] using Lewis FFT...")
bs_prices = price_bs_strikes(strikes, S0, r, T, bs_sigma)
mert_prices = price_merton_strikes(strikes, S0, r, T, mert_sig, mert_lam, mert_muJ, mert_sigJ)
vg_prices = price_vg_strikes(strikes, S0, r, T, vg_theta, vg_sigma, vg_kappa)
hest_prices = price_heston_strikes(strikes, S0, r, T, hest_v0, hest_kappa, hest_theta, hest_sigma, hest_rho)
# Replace any negative prices from FFT with a tiny positive value
for arr in [bs_prices, mert_prices, vg_prices, hest_prices]:
arr[arr < 0] = 1e-10
# -------------------------------------------------------------------------
# Part B: Verify Fourier Inversion vs FFT for a Single Strike
# -------------------------------------------------------------------------
print("\n--- Verification: Fourier Inversion vs FFT at K=100 ---")
cf_bs = _build_cf_bs(r, bs_sigma, T)
cf_mert = _build_cf_merton(r, mert_sig, mert_lam, mert_muJ, mert_sigJ, T)
cf_vg = _build_cf_vg(r, vg_theta, vg_sigma, vg_kappa, T)
cf_hest = _build_cf_heston(r, hest_v0, hest_kappa, hest_theta, hest_sigma, hest_rho, T)
for name, cf_func in [("BS", cf_bs), ("Merton", cf_mert), ("VG", cf_vg), ("Heston", cf_hest)]:
fi_price = fourier_call_price(S0, K_atm, r, T, cf_func)
fft_price = fft_lewis(np.array([K_atm]), S0, r, T, cf_func)[0]
print(f" {name:8s}: Fourier inversion = {fi_price:.8f}, FFT = {fft_price:.8f}, "
f"diff = {abs(fi_price - fft_price):.2e}")
# -------------------------------------------------------------------------
# Part C: Extract Implied Volatilities
# -------------------------------------------------------------------------
print("\nExtracting implied volatilities...")
iv_bs = np.array([implied_vol(bs_prices[i], S0, strikes[i], T, r) for i in range(len(strikes))])
iv_mert = np.array([implied_vol(mert_prices[i], S0, strikes[i], T, r) for i in range(len(strikes))])
iv_vg = np.array([implied_vol(vg_prices[i], S0, strikes[i], T, r) for i in range(len(strikes))])
iv_hest = np.array([implied_vol(hest_prices[i], S0, strikes[i], T, r) for i in range(len(strikes))])
# Report ATM implied vols
atm_idx = np.argmin(np.abs(strikes - S0))
print(f"\n--- ATM Implied Volatilities (K={strikes[atm_idx]:.0f}) ---")
print(f" BS: {iv_bs[atm_idx]:.6f} (input sigma = {bs_sigma})")
print(f" Merton: {iv_mert[atm_idx]:.6f}")
print(f" VG: {iv_vg[atm_idx]:.6f}")
print(f" Heston: {iv_hest[atm_idx]:.6f}")
# Report wing implied vols
low_idx = np.argmin(np.abs(strikes - 70))
high_idx = np.argmin(np.abs(strikes - 130))
print(f"\n--- Wing Implied Volatilities ---")
print(f" {'Model':8s} K={strikes[low_idx]:.0f} (left wing) "
f"K={strikes[atm_idx]:.0f} (ATM) K={strikes[high_idx]:.0f} (right wing)")
print(f" {'BS':8s} {iv_bs[low_idx]:.6f} "
f"{iv_bs[atm_idx]:.6f} {iv_bs[high_idx]:.6f}")
print(f" {'Merton':8s} {iv_mert[low_idx]:.6f} "
f"{iv_mert[atm_idx]:.6f} {iv_mert[high_idx]:.6f}")
print(f" {'VG':8s} {iv_vg[low_idx]:.6f} "
f"{iv_vg[atm_idx]:.6f} {iv_vg[high_idx]:.6f}")
print(f" {'Heston':8s} {iv_hest[low_idx]:.6f} "
f"{iv_hest[atm_idx]:.6f} {iv_hest[high_idx]:.6f}")
# -------------------------------------------------------------------------
# Part D: Smile Visualization
# -------------------------------------------------------------------------
print("\nGenerating volatility smile plots...")
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 5))
fig.suptitle("Volatility Smile: Why Models Matter", fontsize=14)
# Left panel: Option prices look nearly identical
ax1.plot(strikes, bs_prices, label="Black-Scholes", linewidth=2)
ax1.plot(strikes, mert_prices, "--", label="Merton", linewidth=2)
ax1.plot(strikes, vg_prices, "-.", label="Variance Gamma", linewidth=2)
ax1.plot(strikes, hest_prices, ":", label="Heston", linewidth=2)
ax1.set_xlim([70, 150])
ax1.set_ylim([0, 40])
ax1.set_xlabel("Strike K")
ax1.set_ylabel("Call Price")
ax1.set_title("Option Prices (nearly indistinguishable)")
ax1.legend()
ax1.grid(True, alpha=0.3)
# Right panel: Implied volatilities are dramatically different
valid_bs = iv_bs > 0
valid_mert = iv_mert > 0
valid_vg = iv_vg > 0
valid_hest = iv_hest > 0
ax2.plot(strikes[valid_bs], iv_bs[valid_bs], label="Black-Scholes (flat)", linewidth=2)
ax2.plot(strikes[valid_mert], iv_mert[valid_mert], "--", label="Merton", linewidth=2)
ax2.plot(strikes[valid_vg], iv_vg[valid_vg], "-.", label="Variance Gamma", linewidth=2)
ax2.plot(strikes[valid_hest], iv_hest[valid_hest], ":", label="Heston", linewidth=2)
ax2.set_xlabel("Strike K")
ax2.set_ylabel("Implied Volatility")
ax2.set_title("Implied Volatilities (dramatically different)")
ax2.legend()
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("smile_comparison.png", dpi=150, bbox_inches="tight")
plt.show()
print(" Saved: smile_comparison.png")
# =========================================================================
# Part E: Calibration to Synthetic Market Data
# =========================================================================
print("\n" + "=" * 72)
print("PART E: Model Calibration to Synthetic Market Data")
print("=" * 72)
# Generate synthetic "market" data from Heston with KNOWN parameters
true_v0 = 0.04
true_kappa = 1.5
true_theta = 0.04
true_sigma = 0.6
true_rho = -0.7
calib_strikes = np.arange(70, 131, 2, dtype=float)
market_prices = price_heston_strikes(
calib_strikes, S0, r, T, true_v0, true_kappa, true_theta, true_sigma, true_rho
)
market_prices[market_prices < 0] = 1e-10
print(f"\nSynthetic market data generated from Heston model:")
print(f" True parameters: v0={true_v0}, kappa={true_kappa}, theta={true_theta}, "
f"sigma={true_sigma}, rho={true_rho}")
print(f" Feller condition: 2*kappa*theta = {2*true_kappa*true_theta:.4f} vs "
f"sigma^2 = {true_sigma**2:.4f} --> "
f"{'SATISFIED' if 2*true_kappa*true_theta > true_sigma**2 else 'VIOLATED'}")
print(f" Number of strikes: {len(calib_strikes)}")
print(f" Strike range: [{calib_strikes[0]:.0f}, {calib_strikes[-1]:.0f}]")
# -----------------------------------------------------------------
# Calibrate Merton model
# -----------------------------------------------------------------
print("\n--- Calibrating Merton model ---")
def f_mert_calib(x, sig, lam, muJ, sigJ):
return price_merton_strikes(x, S0, r, T, sig, lam, muJ, sigJ)
try:
mert_p0 = [0.15, 1.0, -0.05, 0.2]
mert_bounds = ([1e-6, 1e-6, -2.0, 1e-6], [2.0, 10.0, 2.0, 2.0])
params_mert, _ = scpo.curve_fit(
f_mert_calib, calib_strikes, market_prices,
p0=mert_p0, bounds=mert_bounds, maxfev=5000
)
mert_calib_sig, mert_calib_lam, mert_calib_muJ, mert_calib_sigJ = params_mert
mert_calib_prices = price_merton_strikes(
calib_strikes, S0, r, T, mert_calib_sig, mert_calib_lam, mert_calib_muJ, mert_calib_sigJ
)
mert_mae = np.mean(np.abs(market_prices - mert_calib_prices))
print(f" Calibrated: sig={mert_calib_sig:.4f}, lam={mert_calib_lam:.4f}, "
f"muJ={mert_calib_muJ:.4f}, sigJ={mert_calib_sigJ:.4f}")
print(f" MAE = {mert_mae:.6f}")
except Exception as e:
print(f" Merton calibration failed: {e}")
mert_calib_prices = None
mert_mae = np.inf
# -----------------------------------------------------------------
# Calibrate VG model
# -----------------------------------------------------------------
print("\n--- Calibrating Variance Gamma model ---")
def f_vg_calib(x, theta, sigma, kappa):
return price_vg_strikes(x, S0, r, T, theta, sigma, kappa)
try:
vg_p0 = [-0.1, 0.2, 0.3]
vg_bounds = ([-2.0, 1e-6, 1e-6], [2.0, 3.0, 5.0])
params_vg, _ = scpo.curve_fit(
f_vg_calib, calib_strikes, market_prices,
p0=vg_p0, bounds=vg_bounds, maxfev=5000
)
vg_calib_theta, vg_calib_sigma, vg_calib_kappa = params_vg
vg_calib_prices = price_vg_strikes(
calib_strikes, S0, r, T, vg_calib_theta, vg_calib_sigma, vg_calib_kappa
)
vg_mae = np.mean(np.abs(market_prices - vg_calib_prices))
print(f" Calibrated: theta={vg_calib_theta:.4f}, sigma={vg_calib_sigma:.4f}, "
f"kappa={vg_calib_kappa:.4f}")
print(f" MAE = {vg_mae:.6f}")
except Exception as e:
print(f" VG calibration failed: {e}")
vg_calib_prices = None
vg_mae = np.inf
# -----------------------------------------------------------------
# Calibrate Heston model (unconstrained)
# -----------------------------------------------------------------
print("\n--- Calibrating Heston model (unconstrained) ---")
def f_hest_calib(x, rho, sigma, theta_h, kappa_h, v0_h):
return price_heston_strikes(x, S0, r, T, v0_h, kappa_h, theta_h, sigma, rho)
try:
hest_p0 = [-0.5, 0.8, 0.05, 2.0, 0.05]
hest_bounds = ([-0.999, 1e-6, 1e-6, 1e-6, 1e-6], [0.999, 5.0, 1.0, 20.0, 1.0])
params_hest, _ = scpo.curve_fit(
f_hest_calib, calib_strikes, market_prices,
p0=hest_p0, bounds=hest_bounds, maxfev=5000, xtol=1e-6
)
h_rho, h_sigma, h_theta, h_kappa, h_v0 = params_hest
hest_calib_prices = price_heston_strikes(
calib_strikes, S0, r, T, h_v0, h_kappa, h_theta, h_sigma, h_rho
)
hest_mae = np.mean(np.abs(market_prices - hest_calib_prices))
feller_val = 2 * h_kappa * h_theta - h_sigma**2
print(f" Calibrated: v0={h_v0:.4f}, kappa={h_kappa:.4f}, theta={h_theta:.4f}, "
f"sigma={h_sigma:.4f}, rho={h_rho:.4f}")
print(f" Feller condition: 2*kappa*theta - sigma^2 = {feller_val:.4f} --> "
f"{'SATISFIED' if feller_val > 0 else 'VIOLATED'}")
print(f" MAE = {hest_mae:.6f}")
except Exception as e:
print(f" Heston unconstrained calibration failed: {e}")
hest_calib_prices = None
hest_mae = np.inf
h_rho = h_sigma = h_theta = h_kappa = h_v0 = None
# -----------------------------------------------------------------
# Calibrate Heston model (Feller-constrained)
# -----------------------------------------------------------------
print("\n--- Calibrating Heston model (Feller-constrained via SLSQP) ---")
def hest_objective(x, prices, strike_arr):
"""Least-squares objective for Heston calibration."""
rho_c, sigma_c, theta_c, kappa_c, v0_c = x
try:
model_prices = price_heston_strikes(
strike_arr, S0, r, T, v0_c, kappa_c, theta_c, sigma_c, rho_c
)
return np.sum((model_prices - prices) ** 2)
except Exception:
return 1e10
def feller_constraint(x):
"""Feller condition: 2*kappa*theta - sigma^2 > 0."""
_, sigma_c, theta_c, kappa_c, _ = x
return 2 * kappa_c * theta_c - sigma_c**2 - 1e-6
try:
x0_feller = [-0.5, 0.4, 0.10, 3.0, 0.05]
bounds_feller = [(-0.999, 0.999), (1e-4, 5.0), (1e-4, 2.0), (1e-4, 30.0), (1e-4, 1.0)]
cons = {"fun": feller_constraint, "type": "ineq"}
result_feller = scpo.minimize(
hest_objective, x0=x0_feller,
args=(market_prices, calib_strikes),
method="SLSQP", bounds=bounds_feller,
constraints=cons, tol=1e-6,
options={"maxiter": 1000}
)
hf_rho, hf_sigma, hf_theta, hf_kappa, hf_v0 = result_feller.x
hest_feller_prices = price_heston_strikes(
calib_strikes, S0, r, T, hf_v0, hf_kappa, hf_theta, hf_sigma, hf_rho
)
hest_feller_mae = np.mean(np.abs(market_prices - hest_feller_prices))
feller_val_f = 2 * hf_kappa * hf_theta - hf_sigma**2
print(f" Optimizer message: {result_feller.message}")
print(f" Calibrated: v0={hf_v0:.4f}, kappa={hf_kappa:.4f}, theta={hf_theta:.4f}, "
f"sigma={hf_sigma:.4f}, rho={hf_rho:.4f}")
print(f" Feller condition: 2*kappa*theta - sigma^2 = {feller_val_f:.4f} --> "
f"{'SATISFIED' if feller_val_f > 0 else 'VIOLATED'}")
print(f" MAE = {hest_feller_mae:.6f}")
except Exception as e:
print(f" Heston Feller-constrained calibration failed: {e}")
hest_feller_prices = None
hest_feller_mae = np.inf
# -------------------------------------------------------------------------
# Calibration Summary Table
# -------------------------------------------------------------------------
print("\n" + "=" * 72)
print("CALIBRATION RESULTS SUMMARY")
print("=" * 72)
print(f"\n {'Model':<30s} {'MAE':>12s}")
print(f" {'-'*30} {'-'*12}")
print(f" {'Merton':<30s} {mert_mae:>12.6f}")
print(f" {'Variance Gamma':<30s} {vg_mae:>12.6f}")
if hest_mae < np.inf:
print(f" {'Heston (unconstrained)':<30s} {hest_mae:>12.6f}")
if hest_feller_mae < np.inf:
print(f" {'Heston (Feller-constrained)':<30s} {hest_feller_mae:>12.6f}")
print(f"\n Note: The Feller condition (2*kappa*theta > sigma^2) is rarely satisfied")
print(f" in practice. The unconstrained Heston fit is typically better than the")
print(f" Feller-constrained one because the constraint limits the vol-of-vol,")
print(f" which controls the convexity (curvature) of the smile.")
# -------------------------------------------------------------------------
# Part F: Calibration Visualization
# -------------------------------------------------------------------------
print("\nGenerating calibration plots...")
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
fig.suptitle("Model Calibration to Synthetic Heston Market Data", fontsize=14)
# Panel 1: Price fit comparison
ax = axes[0, 0]
ax.plot(calib_strikes, market_prices, "ko", markersize=4, label="Market (Heston truth)", zorder=5)
if mert_calib_prices is not None:
ax.plot(calib_strikes, mert_calib_prices, "-", label="Merton fit", linewidth=1.5)
if vg_calib_prices is not None:
ax.plot(calib_strikes, vg_calib_prices, "--", label="VG fit", linewidth=1.5)
if hest_calib_prices is not None:
ax.plot(calib_strikes, hest_calib_prices, "-.", label="Heston (unconstr.) fit", linewidth=1.5)
if hest_feller_prices is not None:
ax.plot(calib_strikes, hest_feller_prices, ":", label="Heston (Feller) fit", linewidth=1.5)
ax.set_xlabel("Strike K")
ax.set_ylabel("Call Price")
ax.set_title("Calibrated Model Prices vs Market")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# Panel 2: Pricing errors
ax = axes[0, 1]
if mert_calib_prices is not None:
ax.plot(calib_strikes, np.abs(market_prices - mert_calib_prices), label="Merton", linewidth=1.5)
if vg_calib_prices is not None:
ax.plot(calib_strikes, np.abs(market_prices - vg_calib_prices), "--", label="VG", linewidth=1.5)
if hest_calib_prices is not None:
ax.plot(calib_strikes, np.abs(market_prices - hest_calib_prices), "-.", label="Heston (unconstr.)", linewidth=1.5)
if hest_feller_prices is not None:
ax.plot(calib_strikes, np.abs(market_prices - hest_feller_prices), ":", label="Heston (Feller)", linewidth=1.5)
ax.set_xlabel("Strike K")
ax.set_ylabel("Absolute Error")
ax.set_title("Pricing Errors by Model")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# Panel 3: Implied volatility smile from calibrated models
ax = axes[1, 0]
market_iv = np.array([implied_vol(market_prices[i], S0, calib_strikes[i], T, r)
for i in range(len(calib_strikes))])
valid_market = market_iv > 0
ax.plot(calib_strikes[valid_market], market_iv[valid_market], "ko", markersize=4,
label="Market IV", zorder=5)
for label, cal_prices in [
("Merton", mert_calib_prices),
("VG", vg_calib_prices),
("Heston (unconstr.)", hest_calib_prices),
("Heston (Feller)", hest_feller_prices),
]:
if cal_prices is not None:
cal_iv = np.array([implied_vol(cal_prices[i], S0, calib_strikes[i], T, r)
for i in range(len(calib_strikes))])
valid_cal = cal_iv > 0
ax.plot(calib_strikes[valid_cal], cal_iv[valid_cal], label=label, linewidth=1.5)
ax.set_xlabel("Strike K")
ax.set_ylabel("Implied Volatility")
ax.set_title("Calibrated Implied Volatility Smiles")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
# Panel 4: ATM zoom of implied volatility
ax = axes[1, 1]
zoom_mask = (calib_strikes >= 85) & (calib_strikes <= 115)
ax.plot(calib_strikes[valid_market & zoom_mask], market_iv[valid_market & zoom_mask],
"ko", markersize=5, label="Market IV", zorder=5)
for label, cal_prices in [
("Merton", mert_calib_prices),
("VG", vg_calib_prices),
("Heston (unconstr.)", hest_calib_prices),
("Heston (Feller)", hest_feller_prices),
]:
if cal_prices is not None:
cal_iv = np.array([implied_vol(cal_prices[i], S0, calib_strikes[i], T, r)
for i in range(len(calib_strikes))])
valid_cal = cal_iv > 0
ax.plot(calib_strikes[valid_cal & zoom_mask], cal_iv[valid_cal & zoom_mask],
label=label, linewidth=1.5)
ax.axvline(S0, color="black", linewidth=0.8, linestyle="--", label="ATM")
ax.set_xlabel("Strike K")
ax.set_ylabel("Implied Volatility")
ax.set_title("ATM Zoom: Implied Volatility Comparison")
ax.legend(fontsize=9)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("calibration_results.png", dpi=150, bbox_inches="tight")
plt.show()
print(" Saved: calibration_results.png")
# -------------------------------------------------------------------------
# Part G: Side-by-Side Smile Comparison (Full Range)
# -------------------------------------------------------------------------
print("\nGenerating side-by-side smile comparison...")
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 5))
fig.suptitle("Implied Volatility Smiles: Theoretical vs Calibrated", fontsize=14)
# Left: theoretical smiles from Part A
ax1.plot(strikes[valid_bs], iv_bs[valid_bs], label="BS (flat)", linewidth=2)
ax1.plot(strikes[valid_mert], iv_mert[valid_mert], "--", label="Merton", linewidth=2)
ax1.plot(strikes[valid_vg], iv_vg[valid_vg], "-.", label="VG", linewidth=2)
ax1.plot(strikes[valid_hest], iv_hest[valid_hest], ":", label="Heston", linewidth=2)
ax1.set_xlabel("Strike K")
ax1.set_ylabel("Implied Volatility")
ax1.set_title("Theoretical Smiles (known parameters)")
ax1.legend()
ax1.grid(True, alpha=0.3)
# Right: calibrated smiles from Part E
ax2.plot(calib_strikes[valid_market], market_iv[valid_market], "ko", markersize=4,
label="Market IV (truth)", zorder=5)
for label, cal_prices, ls in [
("Merton (calib)", mert_calib_prices, "-"),
("VG (calib)", vg_calib_prices, "--"),
("Heston unconstr. (calib)", hest_calib_prices, "-."),
("Heston Feller (calib)", hest_feller_prices, ":"),
]:
if cal_prices is not None:
cal_iv = np.array([implied_vol(cal_prices[i], S0, calib_strikes[i], T, r)
for i in range(len(calib_strikes))])
valid_cal = cal_iv > 0
ax2.plot(calib_strikes[valid_cal], cal_iv[valid_cal], ls, label=label, linewidth=2)
ax2.set_xlabel("Strike K")
ax2.set_ylabel("Implied Volatility")
ax2.set_title("Calibrated Smiles (fit to Heston market data)")
ax2.legend(fontsize=9)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("smile_side_by_side.png", dpi=150, bbox_inches="tight")
plt.show()
print(" Saved: smile_side_by_side.png")
print("\n" + "=" * 72)
print("Done. All demonstrations complete.")
print("=" * 72)
```
Exercises¶
Exercise 1. Model calibration finds parameters that best fit market prices. Explain the difference between calibration to vanilla options and calibration to exotic options.
Solution to Exercise 1
Vanilla calibration fits to European calls/puts, which constrain the marginal distributions of \(S_T\) at each maturity. Exotic calibration must also match path-dependent features (barriers, autocallable triggers). A model calibrated to vanillas may misprice exotics if it has the wrong dynamics (e.g., local vol matches vanilla prices but mishandles forward smiles). Exotic calibration requires richer models (stochastic vol, jumps).
Exercise 2. Regularization adds a penalty term \(\lambda \|\Theta - \Theta_0\|^2\) to the calibration objective. Explain its purpose and how \(\lambda\) affects the result.
Solution to Exercise 2
Regularization prevents overfitting to noisy market data by penalizing large deviations from a prior \(\Theta_0\). Large \(\lambda\) pulls parameters toward \(\Theta_0\) (underfitting); small \(\lambda\) allows data-driven parameters (potential overfitting). Optimal \(\lambda\) balances fit quality with parameter stability. L-curve or cross-validation methods can select \(\lambda\).
Exercise 3. Calibration stability means small changes in market data produce small parameter changes. Explain why the Heston model can be unstable and how to improve stability.
Solution to Exercise 3
Instability arises from parameter correlations: \(\kappa\) and \(\theta\) are nearly interchangeable for short maturities (only \(\kappa\theta\) matters). Small data changes can cause large swings between \((\kappa, \theta)\) pairs with similar products. Remedies: (1) fix \(\kappa\) or \(\theta\) and calibrate the other; (2) regularize toward previous calibration; (3) use Tikhonov regularization; (4) calibrate to a well-conditioned reparametrization.
Exercise 4. Compare least-squares (price error), implied volatility error, and relative price error as calibration objectives. Which is most robust?
Solution to Exercise 4
Price error: simple but ATM-biased (large prices dominate). IV error: equal weighting across strikes but requires inverting BS for each model price (slow). Relative price error: normalizes by market price but unstable for small prices (OTM). Most robust: IV error with vega weighting, which balances all regions of the smile and has direct financial interpretation. The choice depends on the trading application.