Lesson 3.4.7 · 75 min

Advanced models: Heston, SABR and local volatility

The volatility surface describes today's prices; a model says how they move. This lesson programs the three reference models, Heston, SABR and Dupire local volatility, checks them against QuantLib and calibrates them on a synthetic surface whose truth is known, to see what each one fits, what it does not and why the calibrated parameters are not those of the historical series.

Fig. — Heston (one calibration for the whole surface) and SABR with β = 1 (one per expiry) against the volatility of each ORX option, 4 June 2025, at 16 and 289 days
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)

Why other models are needed

Black-Scholes assumes a constant volatility. Lesson 3.4.5 showed that the market does not quote it that way: there is a smile and a term structure. A parametric surface such as SVI describes those prices, but it says nothing about how they will move tomorrow, nor does it serve to price an option whose payoff depends on the path (a barrier, a forward-start option) or to compute consistent hedges. That requires a model of the underlying that reproduces today's surface. There are two families:

Black-ScholesLocal volatilityStochastic vol.Dupire 1994Heston 1993SABR 2002Stochastic local
Mermaid source
flowchart TB
  B[Black-Scholes] --> L[Local volatility]
  B --> E[Stochastic vol.]
  L --> D[Dupire 1994]
  E --> H[Heston 1993]
  E --> S[SABR 2002]
  D --> SLV[Stochastic local]
  H --> SLV
ModelWhat is randomParametersFits the surfaceTypical use
Black-ScholesOnly the priceσNo (one σ)Quoting in volatility, reference
Local volatility (Dupire)Only the price; σ a function of (S, t)A whole surfaceExactly, by constructionEquity exotics, price consistency
HestonPrice and variance, correlatedv0, κ, θ, ξ, ρApproximately, with 5 numbersExotics, volatility dynamics
SABRPrice and volatility, correlatedα, β, ρ, ν per expiryVery well for each expiryInterest rates (swaptions), interpolating smiles

This lesson is about “concept and code”: each model is programmed, checked with QuantLib and calibrated on the ORX surface of June 4, 2025 from the course's synthetic data, whose truth is a known SSVI. That makes it possible to separate model error from quote noise.

The starting surface

As in lesson 3.4.5: the implied volatility of each out-of-the-money option with a positive bid, with the forward and the rate of each slice taken from the truth.

IN [01]python
import math
import numpy as np
import pandas as pd
from scipy.special import ndtr
from scipy.optimize import least_squares

sup = pd.read_parquet("opciones3_superficie.parquet")
ver = pd.read_parquet("opciones3_verdad.parquet")


def bs_norm(k, T, s):
    """Undiscounted Black-76 call divided by F."""
    sd = s * np.sqrt(T)
    d1 = -k / sd + sd / 2
    return ndtr(d1) - np.exp(k) * ndtr(d1 - sd)


def vega_norm(k, T, s):
    sd = s * np.sqrt(T)
    d1 = -k / sd + sd / 2
    return np.exp(-d1 * d1 / 2) * np.sqrt(T / (2 * math.pi))


def iv_norm(c, k, T):
    """Safeguarded Newton (lesson 3.4.4), normalized call."""
    lo, hi = np.full(len(c), 1e-4), np.full(len(c), 5.0)
    s = np.full(len(c), 0.3)
    for _ in range(60):
        d = bs_norm(k, T, s) - c
        lo, hi = np.where(d < 0, s, lo), np.where(d > 0, s, hi)
        paso = s - d / np.maximum(vega_norm(k, T, s), 1e-12)
        mal = (paso <= lo) | (paso >= hi) | ~np.isfinite(paso)
        s = np.where(mal, (lo + hi) / 2, paso)
    return s


hoy = pd.Timestamp("2025-06-04")
x = sup[sup["fecha"] == hoy].merge(ver, on=["fecha", "vencimiento"])
x["T"] = x["dias"] / 365
x["k"] = np.log(x["strike"] / x["forward"])
x = x[np.where(x["tipo"] == "C", x["k"] >= 0, x["k"] < 0)
      & (x["bid"] > 0)].copy()
D = np.exp(-x["tipo_libre"] * x["T"])
medio = (x["bid"] + x["ask"]) / 2 / (D * x["forward"])
# put -> call by parity (in units of F, undiscounted)
x["c"] = np.where(x["tipo"] == "C", medio, medio + 1 - np.exp(x["k"]))
x["iv"] = iv_norm(x["c"].to_numpy(), x["k"].to_numpy(),
                  x["T"].to_numpy())
print(len(x), "options in", x["dias"].nunique(), "expiries")
OUT [01]Market volatilities of June 4, 2025
341 options in 12 expiries

These are the same 341 options of the twelve expiries of lesson 3.4.5. To work with a single formula, each price is converted to an undiscounted call in units of the forward (the puts, by the parity of lesson 3.4.1): this way Heston, SABR and Black-76 are compared on the same magnitude.

Heston: the variance is random too

Heston (1993) adds to Black-Scholes a second equation for the instantaneous variance vtv_t:

dStSt=(r−q) dt+vt dWtS,dvt=κ(θ−vt) dt+ξvt dWtv,dWSdWv=ρ dt.\frac{dS_t}{S_t} = (r - q)\,dt + \sqrt{v_t}\,dW^S_t, \qquad dv_t = \kappa(\theta - v_t)\,dt + \xi\sqrt{v_t}\,dW^v_t, \qquad dW^S dW^v = \rho\,dt .
  • v0v_0: today's variance. θ\theta: the long-term variance it reverts to. κ\kappa: the speed of reversion (half-life ln⁡2/κ\ln 2/\kappa years).
  • ξ\xi: volatility of variance. It gives curvature to the smile (more expensive wings).
  • ρ\rho: correlation between price and variance. Negative, it tilts the smile: it is the leverage effect, the skew.
  • Feller condition, 2κθ≥ξ22\kappa\theta \ge \xi^2: if it holds, the variance never touches zero.

Its advantage is that the characteristic function of xT=ln⁡(ST/F)x_T = \ln(S_T/F) has a closed form. In the form of Albrecher et al. (2007), which avoids branch jumps of the complex logarithm:

φ(u)=exp⁡(C(u,T)+D(u,T) v0),d=(ρξiu−κ)2+ξ2(iu+u2),g=κ−ρξiu−dκ−ρξiu+d,\varphi(u) = \exp\big(C(u, T) + D(u, T)\,v_0\big), \quad d = \sqrt{(\rho\xi iu - \kappa)^2 + \xi^2(iu + u^2)}, \quad g = \frac{\kappa - \rho\xi iu - d}{\kappa - \rho\xi iu + d}, C=κθξ2[(κ−ρξiu−d) T−2ln⁡1−ge−dT1−g],D=κ−ρξiu−dξ2 1−e−dT1−ge−dT.C = \frac{\kappa\theta}{\xi^2}\left[(\kappa - \rho\xi iu - d)\,T - 2\ln\frac{1 - g e^{-dT}}{1 - g}\right], \qquad D = \frac{\kappa - \rho\xi iu - d}{\xi^2}\,\frac{1 - e^{-dT}}{1 - g e^{-dT}} .

With it, Lewis's (2001) formula gives the call as a real integral, with k=ln⁡(K/F)k = \ln(K/F) and the discount DTD_T:

C=DT[F−FKπ∫0∞Re⁡[e−iuk φ(u−i/2)]u2+1/4 du].C = D_T\left[F - \frac{\sqrt{FK}}{\pi}\int_0^\infty \frac{\operatorname{Re}\left[e^{-iuk}\,\varphi(u - i/2)\right]}{u^2 + 1/4}\,du\right].
IN [02]python
from scipy.integrate import quad
import QuantLib as ql


def heston_cf(u, T, v0, kappa, theta, xi, rho):
    """E[exp(i·u·ln(S_T/F))], form of Albrecher et al. (2007)."""
    iu = 1j * u
    b = kappa - rho * xi * iu
    d = np.sqrt(b ** 2 + xi ** 2 * (iu + u * u))
    g = (b - d) / (b + d)
    e = np.exp(-d * T)
    C = kappa * theta / xi ** 2 * (
        (b - d) * T - 2 * np.log((1 - g * e) / (1 - g)))
    Dv = (b - d) / xi ** 2 * (1 - e) / (1 - g * e)
    return np.exp(C + Dv * v0)


def heston_call(F, K, T, DT, p):
    k = math.log(K / F)
    f = lambda u: (np.exp(-1j * u * k)
                   * heston_cf(u - 0.5j, T, *p)).real / (u * u + 0.25)
    integral = quad(f, 0, 200, limit=400)[0]
    return DT * (F - math.sqrt(F * K) / math.pi * integral)


p = (0.04, 2.0, 0.04, 0.5, -0.7)        # v0, κ, θ, ξ, ρ
F, r, dias = 100.0, 0.03, 183
T, DT = dias / 365, math.exp(-0.03 * dias / 365)

d0 = ql.Date(4, 6, 2025)
ql.Settings.instance().evaluationDate = d0
dc = ql.Actual365Fixed()
plana = lambda t: ql.YieldTermStructureHandle(
    ql.FlatForward(d0, t, dc, ql.Continuous))
proc = ql.HestonProcess(plana(r), plana(r),        # q = r: F = S
                        ql.QuoteHandle(ql.SimpleQuote(F)), *p)
motor = ql.AnalyticHestonEngine(ql.HestonModel(proc))
for K in (80.0, 100.0, 120.0):
    op = ql.VanillaOption(ql.PlainVanillaPayoff(ql.Option.Call, K),
                          ql.EuropeanExercise(d0 + dias))
    op.setPricingEngine(motor)
    propio = heston_call(F, K, T, DT, p)
    print(f"K = {K:.0f}: own {propio:.6f}, QuantLib "
          f"{op.NPV():.6f}, difference {propio - op.NPV():.1e}")
OUT [02]Heston: characteristic function, Lewis and QuantLib
K = 80: own 20.458189, QuantLib 20.458189, difference -9.4e-13
K = 100: own 5.109865, QuantLib 5.109865, difference -2.0e-12
K = 120: own 0.158180, QuantLib 0.158180, difference -2.5e-12

The two implementations agree to the twelfth digit: the difference is the numerical integration error. A practical warning: QuantLib's time to expiry comes from the dates (183 calendar days with Actual365Fixed), so our own code has to use exactly the same TT; with T=0.5T = 0.5 the difference would be in the thousandths.

Calibrating Heston to the surface

Calibrating requires pricing hundreds of options at each iteration. quad is exact but slow; with a fixed Gauss-Legendre quadrature (160 nodes between 0 and 150) the integral is vectorized for all the options at once. The objective is the volatility error, approximated by the price error divided by the vega, which is much cheaper than inverting the volatility at each step:

IN [03]python
nodos, pesos = np.polynomial.legendre.leggauss(160)
U, W = (nodos + 1) * 75, pesos * 75          # from [-1, 1] to [0, 150]


def heston_norm(k, T, p):
    """Undiscounted Heston call, in units of F."""
    f = (np.exp(-1j * U * k[:, None])
         * heston_cf(U - 0.5j, T[:, None], *p)).real / (U * U + 0.25)
    return 1 - np.exp(k / 2) / math.pi * (f * W).sum(axis=1)


k, T, iv = (x[c].to_numpy() for c in ("k", "T", "iv"))
objetivo, vg = bs_norm(k, T, iv), vega_norm(k, T, iv)
res = least_squares(
    lambda p: (heston_norm(k, T, p) - objetivo) / vg,
    [0.05, 3.0, 0.05, 0.8, -0.7],
    bounds=([1e-4, 0.1, 1e-4, 0.05, -0.99], [1, 30, 1, 5, 0.99]))
v0, kappa, theta, xi, rho_h = ph = res.x
print(f"v0 = {v0:.4f} (vol {math.sqrt(v0):.1%}), κ = {kappa:.2f}, "
      f"θ = {theta:.4f} (vol {math.sqrt(theta):.1%})")
print(f"ξ = {xi:.3f}, ρ = {rho_h:.3f}, "
      f"Feller 2κθ − ξ² = {2 * kappa * theta - xi ** 2:.2f}")
x["iv_heston"] = iv_norm(heston_norm(k, T, ph), k, T)
err = 100 * (x["iv_heston"] - x["iv"])
print(err.groupby(x["dias"]).agg(
    lambda z: np.sqrt(np.mean(z ** 2))).round(2).to_dict())
OUT [03]Heston calibration with least squares
v0 = 0.0691 (vol 26.3%), κ = 7.55, θ = 0.0669 (vol 25.9%)
ξ = 1.995, ρ = -0.750, Feller 2κθ − ξ² = -2.97
{16: 2.51, 44: 0.52, 72: 0.76, 107: 0.87, 135: 0.86, 170: 0.78, 198: 0.71, 289: 0.49, 379: 0.6, 471: 0.88, 562: 1.28, 653: 1.61}

Calibrated Heston starts with a volatility of 26.3% (v0v_0) that reverts with κ=7.55\kappa = 7.55 (a half-life of about five weeks) to a long-term level of 25.9%, with ξ=1.995\xi = 1.995 and ρ=−0.750\rho = -0.750. The Feller condition does not hold (−2.97-2.97), as is usual when calibrating indices: to generate the steep skew of the short tenors, the model needs a very high volatility of variance. The error per expiry draws a U: 2.51 points at 16 days, between 0.49 and 0.88 from 44 to 471 days and 1.61 at 653. Five parameters are not enough for the 16-day skew and the almost-two-year one at the same time.

IN [04]python
S = pd.read_csv("opciones3_indice.csv", parse_dates=["fecha"]) \
    .set_index("fecha").loc[hoy, "cierre"]
c44 = x[x["dias"] == 44].iloc[0]
r44 = c44["tipo_libre"]
q44 = r44 - math.log(c44["forward"] / S) / c44["T"]
proc = ql.HestonProcess(plana(r44), plana(q44),
                        ql.QuoteHandle(ql.SimpleQuote(S)),
                        0.05, 3.0, 0.05, 0.8, -0.7)
modelo = ql.HestonModel(proc)
motor = ql.AnalyticHestonEngine(modelo)
ayudas = []
for f in x.itertuples():
    K = S * math.exp((r44 - q44) * f.T) * math.exp(f.k)
    a = ql.HestonModelHelper(
        ql.Period(int(f.dias), ql.Days), ql.NullCalendar(), S, K,
        ql.QuoteHandle(ql.SimpleQuote(f.iv)), plana(r44), plana(q44),
        ql.BlackCalibrationHelper.ImpliedVolError)
    a.setPricingEngine(motor)
    ayudas.append(a)
modelo.calibrate(ayudas, ql.LevenbergMarquardt(),
                 ql.EndCriteria(500, 50, 1e-8, 1e-8, 1e-8))
th_q, ka_q, xi_q, rho_q, v0_q = modelo.params()
print(f"QuantLib: v0 = {v0_q:.4f}, κ = {ka_q:.2f}, θ = {th_q:.4f}, "
      f"ξ = {xi_q:.3f}, ρ = {rho_q:.3f}")
iv_q = iv_norm(heston_norm(k, T, (v0_q, ka_q, th_q, xi_q, rho_q)),
               k, T)
for nombre, ivm in [("own", x["iv_heston"]), ("QuantLib", iv_q)]:
    e = 100 * np.sqrt(np.mean((ivm - x["iv"]) ** 2))
    print(f"Root mean squared error, {nombre} calibration: {e:.3f}")
OUT [04]The same calibration in QuantLib
QuantLib: v0 = 0.0710, κ = 9.16, θ = 0.0661, ξ = 2.261, ρ = -0.737
Root mean squared error, own calibration: 1.126
Root mean squared error, QuantLib calibration: 1.075

QuantLib arrives at different parameters (κ=9.16\kappa = 9.16, ξ=2.261\xi = 2.261) with a slightly smaller error (1.075 against 1.126 points). Both things have an explanation. QuantLib minimizes the exact volatility error, and our own calibration approximates it with price divided by vega. And the objective function is almost flat along a ridge on which κ\kappa and ξ\xi rise together: faster reversion offsets a more volatile variance. Two very different parameter sets give almost the same prices, and that is why Heston's calibrated parameters jump from one day to the next even when the surface barely changes.

SABR: one model per expiry

SABR (Hagan, Kumar, Lesniewski and Woodward, 2002) models the forward of an expiry and its volatility:

dFt=αtFtβ dWt,dαt=ν αt dZt,dW dZ=ρ dt.dF_t = \alpha_t F_t^{\beta}\,dW_t, \qquad d\alpha_t = \nu\,\alpha_t\,dZ_t, \qquad dW\,dZ = \rho\,dt .

β\beta sets how the volatility depends on the level (1 lognormal, 0 normal), α\alpha is the level, ρ\rho the tilt and ν\nu the volatility of volatility (the curvature). It has no mean reversion and does not link one expiry with another: it is calibrated slice by slice. Its appeal is that Hagan et al. gave a closed approximation of the implied volatility; with z=(ν/α)(FK)(1−β)/2ln⁡(F/K)z = (\nu/\alpha)(FK)^{(1-\beta)/2}\ln(F/K) and x(z)=ln⁡[(1−2ρz+z2+z−ρ)/(1−ρ)]x(z) = \ln\big[(\sqrt{1 - 2\rho z + z^2} + z - \rho)/(1 - \rho)\big]:

σimp≈α [1+((1−β)2α224(FK)1−β+ρβνα4(FK)(1−β)/2+2−3ρ224ν2)T](FK)(1−β)/2[1+(1−β)224ln⁡2FK+(1−β)41920ln⁡4FK]⋅zx(z).\sigma_{\text{imp}} \approx \frac{\alpha\,\big[1 + \big(\tfrac{(1-\beta)^2\alpha^2}{24 (FK)^{1-\beta}} + \tfrac{\rho\beta\nu\alpha}{4 (FK)^{(1-\beta)/2}} + \tfrac{2 - 3\rho^2}{24}\nu^2\big)T\big]} {(FK)^{(1-\beta)/2}\big[1 + \tfrac{(1-\beta)^2}{24}\ln^2\tfrac{F}{K} + \tfrac{(1-\beta)^4}{1920}\ln^4\tfrac{F}{K}\big]} \cdot\frac{z}{x(z)} .
IN [05]python
def hagan(K, F, T, a, b, rho, nu):
    K = np.asarray(K, float)
    lfk = np.log(F / K)
    fk = (F * K) ** ((1 - b) / 2)
    z = nu / a * fk * lfk
    xz = np.log((np.sqrt(1 - 2 * rho * z + z * z) + z - rho)
                / (1 - rho))
    zx = np.where(np.abs(z) < 1e-8, 1.0, z / np.where(xz == 0, 1, xz))
    den = fk * (1 + (1 - b) ** 2 / 24 * lfk ** 2
                + (1 - b) ** 4 / 1920 * lfk ** 4)
    corr = 1 + ((1 - b) ** 2 / 24 * a * a / fk ** 2
                + rho * b * nu * a / (4 * fk)
                + (2 - 3 * rho * rho) / 24 * nu * nu) * T
    return a / den * zx * corr


for K, b in [(90.0, 1.0), (105.0, 1.0), (105.0, 0.5)]:
    a = 0.2 if b == 1 else 2.0
    propio = float(hagan(K, 100.0, 0.5, a, b, -0.5, 0.6))
    # QuantLib arguments: strike, forward, T, α, β, ν, ρ
    qlv = ql.sabrVolatility(K, 100.0, 0.5, a, b, 0.6, -0.5)
    print(f"K = {K:.0f}, β = {b}: own {propio:.8f}, "
          f"QuantLib {qlv:.8f}")
OUT [05]Hagan's formula and QuantLib
K = 90, β = 1.0: own 0.21787385, QuantLib 0.21787385
K = 105, β = 1.0: own 0.19353697, QuantLib 0.19353697
K = 105, β = 0.5: own 0.19187243, QuantLib 0.19187243

Our own implementation agrees with ql.sabrVolatility in the eight digits shown, also with β=0.5\beta = 0.5. Watch the order of the arguments in QuantLib: strike, forward, tenor, α\alpha, β\beta, ν\nu and ρ\rho, with ν\nu before ρ\rho, the reverse of the usual notation.

IN [06]python
filas = []
for dias, g in x.groupby("dias"):
    F, T = g["forward"].iloc[0], g["T"].iloc[0]
    K, ivm = g["strike"].to_numpy(), g["iv"].to_numpy()
    s = least_squares(
        lambda q: hagan(K, F, T, q[0], 1.0, q[1], q[2]) - ivm,
        [0.2, -0.5, 1.0], bounds=([1e-3, -0.999, 1e-3], [3, 0.999, 20]))
    a, rho_s, nu = s.x
    x.loc[g.index, "iv_sabr"] = hagan(K, F, T, a, 1.0, rho_s, nu)
    filas.append({"dias": dias, "alfa": a, "rho": rho_s, "nu": nu,
                  "nu_raizT": nu * math.sqrt(T),
                  "rmse_sabr": 100 * np.sqrt(np.mean(s.fun ** 2)),
                  "rmse_heston": 100 * np.sqrt(np.mean(
                      (g["iv"] - x.loc[g.index, "iv_heston"]) ** 2))})
sabr = pd.DataFrame(filas).set_index("dias")
sabr.round(3)
OUT [06]SABR with β = 1, expiry by expiry
       alfa    rho     nu  nu_raizT  rmse_sabr  rmse_heston
dias
16    0.228 -0.797  3.045     0.638      0.945        2.506
44    0.230 -0.786  1.834     0.637      1.001        0.522
72    0.231 -0.778  1.501     0.667      0.746        0.759
107   0.229 -0.760  1.309     0.709      0.499        0.875
135   0.229 -0.758  1.189     0.723      0.425        0.855
170   0.228 -0.756  1.077     0.735      0.365        0.780
198   0.228 -0.755  1.014     0.747      0.302        0.707
289   0.227 -0.753  0.856     0.762      0.231        0.489
379   0.225 -0.750  0.758     0.773      0.183        0.596
471   0.225 -0.752  0.682     0.775      0.166        0.882
562   0.223 -0.752  0.628     0.779      0.144        1.281
653   0.222 -0.754  0.583     0.780      0.128        1.609

α\alpha stays between 0.222 and 0.231 at all tenors: with β=1\beta = 1 it is approximately the ATM volatility. ρ\rho ranges from −0.80 to −0.75 and ν\nu falls from 3.0 to 0.58, while νT\nu\sqrt{T} moves much less, from 0.64 to 0.78: the surface scales with the standard deviation (lesson 3.4.5). The SABR error falls with tenor, from 0.94 and 1.00 points in the first two slices to 0.13 in the last one. In the short slices, the strikes reach 10 standard deviations below the forward, and Hagan's form with β=1\beta = 1 cannot follow the wing and the center at the same time: it leaves errors that change sign along the smile, of ±1.4 points in the center and up to 2.9 in the farthest wing. Heston, with a single parameter set for everything, beats SABR only at 44 days (0.52 against 1.00).

Fig. — Root mean square error of Heston and SABR (β = 1) against market volatility, by expiry, ORX, 4 June 2025, in volatility points

The figure compares the two errors by tenor: Heston's U, highlighted, against the steady decline of SABR. A model with per-expiry parameters fits each slice better; one with global parameters is consistent across tenors and serves to price products that depend on several. The header figure shows the detail: at 16 days Heston falls 6.9 points short in the put wing and 2 points high at the money; at 289 days, both models almost touch the points.

Local volatility: Dupire's surface

The opposite path is to add no source of randomness and let the volatility be a deterministic function of price and time, dSt/St=(r−q) dt+σL(St,t) dWtdS_t/S_t = (r - q)\,dt + \sigma_L(S_t, t)\,dW_t. Dupire (1994) showed that there is a unique function σL\sigma_L that reproduces all of today's European prices, and that it is read from the surface itself. Written in total variance w(k,T)w(k, T) and moneyness relative to the forward of each tenor (Gatheral, 2006):

σL2(k,T)=∂w/∂T1−kw∂w∂k+14(−14−1w+k2w2)(∂w∂k)2+12∂2w∂k2.\sigma_L^2(k, T) = \frac{\partial w / \partial T} {1 - \frac{k}{w}\frac{\partial w}{\partial k} + \frac{1}{4}\left(-\frac{1}{4} - \frac{1}{w} + \frac{k^2}{w^2}\right)\left(\frac{\partial w}{\partial k}\right)^2 + \frac{1}{2}\frac{\partial^2 w}{\partial k^2}} .

The denominator is the g(k)g(k) of lesson 3.4.5 and the numerator, the calendar slope: with no arbitrage, both are positive and the local volatility exists. With arbitrage, it comes out negative or infinite, and that is why it is computed on a clean surface. Here, the true SSVI of the day (its ρ, η = 0.95 and γ = 0.5, which lesson 3.4.5 recovered with the fit) with θT\theta_T linearly interpolated between expiries:

IN [07]python
v = ver[ver["fecha"] == hoy]
RHO, ETA, GAM = float(v["svi_rho"].iloc[0]), 0.95, 0.5
T_n = np.r_[0, v["dias"] / 365]
TH_n = np.r_[0, v["theta"]]


def w_sup(k, T):
    th = np.interp(T, T_n, TH_n)
    phi = ETA / (th ** GAM * (1 + th) ** (1 - GAM))
    return th / 2 * (1 + RHO * phi * k
                     + np.sqrt((phi * k + RHO) ** 2 + 1 - RHO ** 2))


def var_local(k, T, h=1e-4):
    w = w_sup(k, T)
    w1 = (w_sup(k + h, T) - w_sup(k - h, T)) / (2 * h)
    w2 = (w_sup(k + h, T) - 2 * w + w_sup(k - h, T)) / h ** 2
    wt = (w_sup(k, T + h) - w_sup(k, T - h)) / (2 * h)
    g = (1 - k * w1 / (2 * w)) ** 2 - w1 ** 2 / 4 * (1 / w + 0.25) \
        + w2 / 2
    return wt / g


kk = np.array([-0.3, -0.2, -0.1, 0.0, 0.1, 0.2])
T30 = 30 / 365
print(pd.DataFrame({
    "k": kk, "implícita": 100 * np.sqrt(w_sup(kk, T30) / T30),
    "local": 100 * np.sqrt(var_local(kk, T30))}).round(2)
    .to_string(index=False))
for dias in (8, 30, 180):
    T, h = dias / 365, 0.005
    p_loc = (np.sqrt(var_local(h, T))
             - np.sqrt(var_local(-h, T))) / (2 * h)
    p_imp = (np.sqrt(w_sup(h, T) / T) - np.sqrt(w_sup(-h, T) / T)) \
        / (2 * h)
    print(f"{dias} days: local ATM slope {p_loc:.2f}, implied "
          f"{p_imp:.2f}, ratio {p_loc / p_imp:.2f}")
OUT [07]Local volatility from the SSVI
   k  implícita  local
-0.3      47.37  65.08
-0.2      40.52  54.41
-0.1      32.34  40.91
 0.0      22.13  22.09
 0.1      16.65  16.52
 0.2      18.96  22.79
8 days: local ATM slope -3.89, implied -2.11, ratio 1.84
30 days: local ATM slope -2.04, implied -1.09, ratio 1.86
180 days: local ATM slope -0.80, implied -0.44, ratio 1.80

At the money the two agree (22.1%), but toward the puts the local rises much faster: 65.1% against 47.4% at k=−0.3k = -0.3. The at-the-money slope of the local is 1.84, 1.86 and 1.80 times the implied at 8, 30 and 180 days: it is Derman's rule, which says that the implied volatility of a strike is approximately the average of the local along the path between the current price and that strike, and that is why it has half the slope. Toward the calls, the local drops below the implied and turns up again earlier (22.8% against 19.0% at k=0.2k = 0.2).

Fig. — Dupire local volatility against implied volatility at 30 days, true SSVI surface of ORX on 4 June 2025: local volatility has almost twice the slope at the money

The figure draws the two curves at 30 days: the local one, highlighted, is the implied one “unrolled”.

IN [08]python
rng = np.random.default_rng(347)
N, T44, pasos = 100_000, 44 / 365, 176      # 4 steps per day
dt = T44 / pasos
rejilla = np.linspace(-2.0, 1.5, 701)
X = np.zeros(N)                               # ln(S_t / F_t)
for i in range(pasos):
    s2 = np.interp(X, rejilla, var_local(rejilla, (i + 0.5) * dt))
    X += -0.5 * s2 * dt + np.sqrt(s2 * dt) * rng.standard_normal(N)
ST = np.exp(X) / np.exp(X).mean()          # exact martingale
filas = []
for k_ in (-0.2, -0.1, -0.05, 0.0, 0.05):
    c = np.maximum(ST - math.exp(k_), 0).mean()
    filas.append({"k": k_,
                  "MC": 100 * iv_norm(np.array([c]), np.array([k_]),
                                      np.array([T44]))[0],
                  "superficie": 100 * math.sqrt(w_sup(k_, T44) / T44)})
mc = pd.DataFrame(filas)
mc["dif"] = mc["MC"] - mc["superficie"]
print(f"E[S_T/F] before normalizing: {np.exp(X).mean():.5f}")
mc.round(2)
OUT [08]Monte Carlo with local volatility: does it reproduce the smile?
E[S_T/F] before normalizing: 0.99983
      k     MC  superficie   dif
0 -0.20  37.78       37.93 -0.15
1 -0.10  30.81       30.81  0.00
2 -0.05  26.72       26.69  0.03
3  0.00  22.29       22.22  0.07
4  0.05  18.29       18.22  0.07

Simulating the index with the local volatility of each instant and pricing the 44-day calls reproduces the smile of the surface with differences of −0.15 to 0.07 points, the Monte Carlo error with 100,000 paths and the discretization error with 176 steps. The mean of ST/FS_T/F comes out at 0.99983 before normalizing: the −σ2/2-\sigma^2/2 term of the drift keeps the martingale except for sampling error. That is the property that defines Dupire's model: by construction, it returns all of today's European prices.

What today's surface does not decide

Local volatility and Heston calibrated to the same surface give the same European prices today (local exactly, Heston approximately) and yet they are very different models:

  • Future skew. In local volatility the future smile flattens: the function σL(S,t)\sigma_L(S, t) is fixed today and, at long tenors, its dependence on price is smooth. In stochastic volatility models, the smile regenerates. A forward-start option or a cliquet are worth different amounts in the two.
  • Delta. With local volatility, when the index falls, volatility rises along the function itself; the delta includes that effect. With Heston, the variance moves on its own, correlated. The hedge of an option (lesson 3.6.2) changes with the model.
  • Calibration vs estimation. No calibrated parameter is a statistical measure. The index in the data was simulated with a Heston-type variance with κ = 8, ξ = 0.40 and ρ = −0.72 under the real-world measure (data README); calibrated to prices, Heston asks for ξ ≈ 2.0 and ρ = −0.75, because prices carry a risk premium and the surface does not have a Heston shape.

In bank practice they are combined: stochastic local volatility (SLV) models use Heston dynamics multiplied by a local function that is calibrated so that today's surface comes out exactly.

Exercises

  1. With the calibrated Heston parameters, halve ξ and compute the implied volatility of the 44-day slice at k=−0.2k = -0.2, 00 and 0.10.1. What changes in the smile? Does the Feller condition hold now?
Solution
IN [09]python
kq = np.array([-0.2, 0.0, 0.1])
Tq = np.full(3, 44 / 365)
for nombre, pp in [("calibrated", ph),
                   ("ξ / 2", (v0, kappa, theta, xi / 2, rho_h))]:
    vol = 100 * iv_norm(heston_norm(kq, Tq, pp), kq, Tq)
    feller = 2 * pp[1] * pp[2] - pp[3] ** 2
    print(f"{nombre}: {np.round(vol, 2)}, Feller {feller:.2f}")
OUT [09]Heston with half the volatility of variance
calibrated: [38.54 22.03 16.66], Feller -2.97
ξ / 2: [34.2  24.87 19.65], Feller 0.02

With half of ξ\xi, the smile flattens: the volatility of the put wing falls from 38.5% to 34.2%, that of the money rises from 22.0% to 24.9% and that of the call wing, from 16.7% to 19.7%. The volatility of variance gives curvature and, with a negative ρ\rho, slope; in addition, in Heston more ξ\xi cheapens the money (ATM implied volatility is a concave function of variance). Feller goes from −2.97 to 0.02: it just holds.

  1. Calibrate SABR with β = 0.5 instead of 1 on the 16- and 289-day slices and compare the error with that of β = 1. Why does the value of β matter little in equities for fitting a single expiry?
Solution
IN [10]python
for dias in (16, 289):
    g = x[x["dias"] == dias]
    F, T = g["forward"].iloc[0], g["T"].iloc[0]
    K, ivm = g["strike"].to_numpy(), g["iv"].to_numpy()
    s = least_squares(
        lambda q: hagan(K, F, T, q[0], 0.5, q[1], q[2]) - ivm,
        [0.2 * F ** 0.5, -0.5, 1.0],
        bounds=([1e-3, -0.999, 1e-3], [500, 0.999, 20]))
    print(f"{dias} days, β = 0.5: ρ = {s.x[1]:.3f}, ν = {s.x[2]:.3f}, "
          f"error {100 * np.sqrt(np.mean(s.fun ** 2)):.3f} "
          f"(β = 1: {sabr.loc[dias, 'rmse_sabr']:.3f})")
OUT [10]SABR with β = 0.5
16 days, β = 0.5: ρ = -0.792, ν = 2.818, error 1.088 (β = 1: 0.945)
289 days, β = 0.5: ρ = -0.693, ν = 0.703, error 0.375 (β = 1: 0.231)

With β=0.5\beta = 0.5 the error rises a little (from 0.945 to 1.088 at 16 days and from 0.231 to 0.375 at 289) and ρ\rho adjusts to compensate (−0.79 and −0.69). β\beta and ρ\rho both generate slope: β<1\beta < 1 makes volatility rise when the price falls, and a negative ρ\rho does too. With a single expiry, the two effects almost substitute for each other and the fit does not separate them well; that is why β\beta is fixed in advance (1 in equities, often 0.5 or less in rates) and α\alpha, ρ\rho and ν\nu are calibrated.

  1. A local volatility model calibrated to June 4, 2025 exactly reproduces all the European prices of that day. Explain why that is not enough to price an option that starts in three months with the strike equal to the price on that day (forward start).
Solution

A forward-start option sets its strike in three months at the price of that day, so its value depends on the smile that will exist then, conditional on the path: on the future skew. Today's European prices only fix the distribution of the price at each expiry separately, not how they chain together. Local volatility and stochastic volatility calibrated to the same surface give equal marginal distributions and different future smiles: the local one, flatter (the function σL\sigma_L flattens with tenor); the stochastic one, regenerated. Pricing the product requires choosing the dynamics, and checking it against prices of products that reveal it (the forward start itself, cliquets or options on the VIX) or using a stochastic local model.

Each answer is folded under its question

Self-assessment

  1. Heston calibrated to the June 4, 2025 surface gives a volatility of variance ξ ≈ 2.0, while the variance of the index in the data was simulated with ξ = 0.40. What does it indicate?

    • AThat the calibration has failed
    • BThat the index has more volatility than was simulated
    • CThat the calibrated parameters are those that reproduce prices (pricing measure) with the Heston structure, not statistical estimates from the series: to generate the short-term skew, Heston needs a much higher volatility of variance
    • DThat more expiries need to be used
    Show answer

    Answer C. That the calibrated parameters are those that reproduce prices (pricing measure) with the Heston structure, not statistical estimates from the series: to generate the short-term skew, Heston needs a much higher volatility of variance

    The surface in the data is not Heston: it is an SSVI with a risk premium. Heston approximates it with the parameters that best reproduce the prices, and it only achieves the steep skew of the short tenors with a large ξ. Calibrating and estimating are different questions.

  2. What does the Feller condition, 2κθ ≥ ξ², do in the Heston model?

    • AIt guarantees there is no arbitrage in the surface
    • BIt makes the correlation negative
    • CIt ensures that the calibration converges
    • DIt guarantees that the variance does not reach zero; if it does not hold, it can touch zero (and rise again), which complicates simulation but does not prevent pricing with the characteristic function
    Show answer

    Answer D. It guarantees that the variance does not reach zero; if it does not hold, it can touch zero (and rise again), which complicates simulation but does not prevent pricing with the characteristic function

    With 2κθ < ξ² the variance process reaches zero. The semi-analytic formula remains valid; Monte Carlo schemes need to handle zero (truncated or reflected). The lesson's calibration violates it, as is usual for indices.

  3. Near the money and at short tenors, how does the skew of local volatility compare with that of implied volatility?

    • ALocal is approximately twice as steep: implied volatility is, to a first approximation, an average of local volatility along the path between spot and strike
    • BThey are equal
    • CLocal is half
    • DLocal has no skew
    Show answer

    Answer A. Local is approximately twice as steep: implied volatility is, to a first approximation, an average of local volatility along the path between spot and strike

    It is Derman's rule: the implied volatility of a strike averages local volatility between the current price and that strike. In the data, the ratio of at-the-money slopes comes out between 1.80 and 1.86 at 8, 30 and 180 days.

  4. In SABR with β = 1, the calibrated ν falls with tenor from 3.0 (16 days) to 0.58 (653 days). What pattern does it hide?

    • ANone: it is calibration noise
    • BThat ν·√T changes much less (from 0.64 to 0.78): the surface in the data scales the smile with the standard deviation, and a per-expiry SABR reproduces it with a ν that falls approximately like 1/√T
    • CThat the volatility of volatility rises with tenor
    • DThat β = 0 should be used
    Show answer

    Answer B. That ν·√T changes much less (from 0.64 to 0.78): the surface in the data scales the smile with the standard deviation, and a per-expiry SABR reproduces it with a ν that falls approximately like 1/√T

    With β = 1 and a similar α at all tenors, the curvature and slope of the smile in k depend on ν√T. The SSVI in the data has the same shape in standard deviations at all tenors (lesson 3.4.5), so ν√T varies little; what deviates at the short tenors is the weight of the very far wings in the fit.

Key points

  • Heston makes the variance stochastic (reversion κ to θ, volatility of variance ξ, correlation ρ with the price); its characteristic function has a closed form and Lewis's formula prices a European option with one integral. It agrees with QuantLib to the twelfth digit.
  • Calibrated to the whole surface, Heston leaves root mean squared errors of 0.5 to 2.5 volatility points per expiry: it cannot have both the steep 16-day skew and the gentle one at almost two years. Its calibrated parameters (ξ ≈ 2.0) are not those of the series (ξ = 0.40).
  • SABR is calibrated expiry by expiry with Hagan's formula, which agrees with QuantLib; with β = 1 it fits each slice with errors of 0.13 to 1.0 points, smaller the longer the tenor.
  • Dupire local volatility comes from the implied surface: its variance is the derivative of w with respect to tenor divided by g(k). It reproduces all of today's prices (a simulation checks it) and its skew near the money is 1.8 times the implied one.
  • Fitting today's prices is not enough: local and stochastic models can give the same surface and different dynamics (future skew, hedging), and that is where they differ for exotic options and for the delta.

Resources

Data