Une volatilité pour chaque strike et chaque date
Black-Scholes valorise un call avec une seule volatilité, alors que le marché cote une volatilité implicite différente pour chaque strike et chaque échéance. Le modèle de volatilité locale reproduit tous ces prix à la fois en laissant la volatilité dépendre du niveau de prix et du temps, sigma(S, t), tout en gardant une seule source d'aléa. Bruno Dupire a montré en 1994 que cette fonction n'est pas une supposition : elle est déterminée par la surface des prix de call C(K, T) au moyen d'une formule, de sorte que les prix d'options du marché se transforment directement en un modèle pour valoriser des options exotiques de façon cohérente avec eux.
# 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 pricesLa formule de Dupire
La formule de Dupire exprime la variance locale avec trois dérivées de la surface des prix de call : sigma_loc^2(K, T) = (dC/dT + (r - q) * K * dC/dK + q * C) / (0.5 * K^2 * d2C/dK2). Le numérateur contient la pente temporelle des prix (elle doit être positive ou nulle, sinon une option plus longue coûte moins qu'une plus courte : un arbitrage calendaire) et le dénominateur le papillon d2C/dK2, proportionnel à la densité de probabilité implicite du marché pour le prix, qui doit être positive. Avec des cotations réelles, bruitées et clairsemées, les dérivées se calculent par différences finies sur une surface lissée : (f(x+h) - f(x-h)) / (2h) pour la dérivée première et (f(x+h) - 2 f(x) + f(x-h)) / h^2 pour la seconde. Une surface bruitée donne des volatilités locales sauvages : lisser d'abord la surface de volatilité implicite est la pratique courante.
# 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 backUtiliser la surface dans une simulation
Pour simuler, on tabule sigma_loc sur une grille de prix (pour chaque tranche de temps) et on la consulte au prix courant à chaque pas, en interpolant linéairement entre les points et en gardant les valeurs d'extrémité constantes au-delà de la grille. Un pas est alors le mouvement log-Euler habituel avec cette volatilité, S * exp((mu - sigma^2/2) dt + sigma sqrt(dt) dz). Comparée à Heston, la volatilité locale ajuste exactement les prix d'options d'aujourd'hui par construction, mais la dynamique future de son smile est irréaliste (le skew s'aplatit avec le temps), ce qui compte pour les produits sensibles au smile futur. Beaucoup de salles utilisent donc la volatilité locale-stochastique, un mélange des deux.
# 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- La volatilité locale
sigma(S, t)reproduit toute la surface des prix de call avec une seule source d'aléa ; la formule de Dupire la donne directement à partir des prix. - Dupire :
(dC/dT + (r - q) K dC/dK + q C) / (0.5 K^2 d2C/dK2); calculez les dérivées par différences finies sur une surface lissée et sans arbitrage. - Simulez en consultant
sigma_locau prix courant à chaque pas ; un ajustement parfait aujourd'hui ne garantit pas une dynamique réaliste du smile demain.
À suivre : le modèle SABR, un modèle à volatilité stochastique avec un smile en forme close.
Lab : ce qui est noté
- Dériver numériquement avec
finite_difference25 pts - Appliquer la formule de Dupire dans
local_variance25 pts - Lire la surface avec
lookup_local_vol25 pts - Simuler un pas avec
local_vol_step25 pts