One volatility for every strike and time
Black-Scholes prices a call with one volatility, yet the market quotes a different implied volatility for every strike and maturity. The local volatility model reproduces all those prices at once by letting the volatility depend on the price level and the time, sigma(S, t), while keeping a single source of randomness. Bruno Dupire showed in 1994 that this function is not a guess: it is determined by the surface of call prices C(K, T) through a formula, so the market's option prices can be turned directly into a model for pricing exotic options consistently with them.
# What "one volatility" means: a call priced with Black-Scholes at three strikes, all with the same sigma
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(s, k, t, r, q, sigma):
d1 = (math.log(s / k) + (r - q + sigma ** 2 / 2) * t) / (sigma * math.sqrt(t))
d2 = d1 - sigma * math.sqrt(t)
return s * math.exp(-q * t) * norm_cdf(d1) - k * math.exp(-r * t) * norm_cdf(d2)
for k in (90, 100, 110):
print(k, round(bs_call(100.0, k, 1.0, 0.03, 0.01, 0.2), 4)) # 90 14.6592 100 8.8273 110 4.8947 one sigma, three different pricesDupire's formula
Dupire's formula expresses the local variance with three derivatives of the call-price surface: sigma_loc^2(K, T) = (dC/dT + (r - q) * K * dC/dK + q * C) / (0.5 * K^2 * d2C/dK2). The numerator contains the time slope of prices (it must be non-negative, otherwise a longer option is cheaper than a shorter one: a calendar arbitrage) and the denominator the butterfly d2C/dK2, proportional to the market's implied probability density of the price, which must be positive. With real quotes, which are noisy and sparse, the derivatives are taken by finite differences on a smoothed surface: (f(x+h) - f(x-h)) / (2h) for the first derivative and (f(x+h) - 2 f(x) + f(x-h)) / h^2 for the second. A noisy surface gives wild local volatilities, so smoothing the implied-volatility surface first is standard practice.
# Dupire's formula recovers a constant volatility from Black-Scholes prices (finite differences on the price surface)
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(s, k, t, r, q, sigma):
d1 = (math.log(s / k) + (r - q + sigma ** 2 / 2) * t) / (sigma * math.sqrt(t))
return s * math.exp(-q * t) * norm_cdf(d1) - k * math.exp(-r * t) * norm_cdf(d1 - sigma * math.sqrt(t))
def local_variance(call, dc_dt, dc_dk, d2c_dk2, strike, r, q):
return (dc_dt + (r - q) * strike * dc_dk + q * call) / (0.5 * strike ** 2 * d2c_dk2)
s, k, t, r, q, sigma = 100.0, 105.0, 1.0, 0.03, 0.01, 0.2
hk, ht = 0.5, 0.002
c = bs_call(s, k, t, r, q, sigma)
dc_dt = (bs_call(s, k, t + ht, r, q, sigma) - bs_call(s, k, t - ht, r, q, sigma)) / (2 * ht)
dc_dk = (bs_call(s, k + hk, t, r, q, sigma) - bs_call(s, k - hk, t, r, q, sigma)) / (2 * hk)
d2c_dk2 = (bs_call(s, k + hk, t, r, q, sigma) - 2 * c + bs_call(s, k - hk, t, r, q, sigma)) / hk ** 2
print(round(math.sqrt(local_variance(c, dc_dt, dc_dk, d2c_dk2, k, r, q)), 3)) # 0.2 the constant volatility comes backUsing the surface in a simulation
To simulate, tabulate sigma_loc on a grid of prices (for each time slice) and look it up at the current price at every step, interpolating linearly between grid points and holding the end values flat beyond the grid. One step is then the usual log-Euler move with that volatility, S * exp((mu - sigma^2/2) dt + sigma sqrt(dt) dz). Compared with Heston, local volatility fits today's option prices exactly by construction, but its forward smile dynamics are unrealistic (the skew flattens as time passes), which matters for products sensitive to the future smile. Many desks therefore use stochastic-local volatility, a blend of the two.
# A lookup on a strike grid (linear inside, flat outside) and one simulation step with the volatility it returns
import math
def lookup_local_vol(strikes, vols, k):
if k <= strikes[0]:
return vols[0]
if k >= strikes[-1]:
return vols[-1]
for i in range(1, len(strikes)):
if k <= strikes[i]:
weight = (k - strikes[i - 1]) / (strikes[i] - strikes[i - 1])
return vols[i - 1] + weight * (vols[i] - vols[i - 1])
strikes, vols = [90.0, 100.0, 110.0], [0.25, 0.20, 0.18]
print(lookup_local_vol(strikes, vols, 95.0), round(lookup_local_vol(strikes, vols, 105.0), 4), lookup_local_vol(strikes, vols, 200.0)) # 0.225 0.19 0.18
s = 100.0
sigma = lookup_local_vol(strikes, vols, s)
print(round(s * math.exp((0.05 - sigma ** 2 / 2) * (1 / 252) + sigma * math.sqrt(1 / 252) * 0.5), 4)) # 100.6439# A calendar arbitrage shows up as a negative numerator: a longer option priced below a shorter one
def numerator(call, dc_dt, dc_dk, strike, r, q):
return dc_dt + (r - q) * strike * dc_dk + q * call
print(round(numerator(10.0, 1.2, -0.45, 100.0, 0.03, 0.01), 6)) # 0.4 prices rise with maturity: a valid local variance
print(numerator(10.0, -2.0, -0.45, 100.0, 0.03, 0.01) < 0) # True prices fall with maturity: no model can fit this- Local volatility
sigma(S, t)reproduces the whole call-price surface with one source of randomness; Dupire's formula gives it directly from the prices. - Dupire:
(dC/dT + (r - q) K dC/dK + q C) / (0.5 K^2 d2C/dK2); take the derivatives by finite differences on a smoothed, arbitrage-free surface. - Simulate by looking up
sigma_locat the current price each step; a perfect fit today does not guarantee realistic smile dynamics tomorrow.
Next up: the SABR model, a stochastic-volatility model with a closed-form smile.
Lab · what's graded
- Differentiate numerically with
finite_difference25 pts - Apply Dupire's formula in
local_variance25 pts - Read the surface with
lookup_local_vol25 pts - Simulate a step with
local_vol_step25 pts