Faire tourner la formule à l'envers
La formule de Black–Scholes transforme une volatilité en prix. Sur le marché, c'est l'inverse : le prix est coté et la volatilité est inconnue. La volatilité implicite est le sigma pour lequel la formule redonne le prix du marché. Il n'y a pas d'inverse algébrique, donc on cherche : on calcule le prix pour un sigma d'essai, on regarde l'écart de prix (modèle moins marché), et on déplace sigma jusqu'à l'annuler. La recherche la plus simple essaie une grille de valeurs et garde la meilleure.
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(spot, strike, rate, sigma, t):
d1 = (math.log(spot / strike) + (rate + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return spot * norm_cdf(d1) - strike * math.exp(-rate * t) * norm_cdf(d1 - sigma * math.sqrt(t))
market = 8.0 # the quoted price of the 100-strike, 6-month call on a stock at 100
for sigma in (0.10, 0.15, 0.20, 0.25, 0.30):
model = bs_call(100, 100, 0.05, sigma, 0.5)
print(sigma, round(model, 4), "error", round(model - market, 4))Pourquoi il n'y a qu'une seule réponse
Le prix du call croît régulièrement avec sigma : le véga est positif, donc l'écart ne passe par zéro qu'une fois. La réponse est donc unique, et on sait dans quel sens bouger : si le modèle est trop cher, on baisse sigma. Une solution n'existe que si le prix du marché est strictement compris entre le *plancher intrinsèque* max(S - K * exp(-rT), 0) (volatilité nulle) et le spot S (volatilité infinie). En dehors, la cotation contredit l'absence d'arbitrage, et la bonne réponse est une erreur, pas un nombre.
# The bounds: no volatility can explain a price outside them
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(spot, strike, rate, sigma, t):
d1 = (math.log(spot / strike) + (rate + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return spot * norm_cdf(d1) - strike * math.exp(-rate * t) * norm_cdf(d1 - sigma * math.sqrt(t))
spot, strike, rate, t = 100, 90, 0.05, 1
floor = max(spot - strike * math.exp(-rate * t), 0)
print(round(floor, 4), spot) # 14.3894 100
print(round(bs_call(spot, strike, rate, 0.0001, t), 4)) # 14.3894 tiny volatility: the floor
print(round(bs_call(spot, strike, rate, 5.0, t), 4)) # 98.8513 huge volatility: close to the spotCouper l'intervalle en deux
Une grille est grossière : la réponse n'est aussi fine que la grille. Mieux vaut utiliser le signe de l'écart. On part de sigma entre une valeur très basse et une très haute, on prend le milieu et on regarde l'écart : positif, le milieu est trop haut, on garde la moitié basse ; négatif, la haute. Chaque étape divise l'intervalle par deux, donc une trentaine d'étapes donnent huit chiffres. Le prochain sujet nomme et généralise cela ; ici, c'est l'outil qui lit le marché.
# Implied volatility by halving the interval, then reprice to check it
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(spot, strike, rate, sigma, t):
d1 = (math.log(spot / strike) + (rate + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return spot * norm_cdf(d1) - strike * math.exp(-rate * t) * norm_cdf(d1 - sigma * math.sqrt(t))
def implied_vol(price, spot, strike, rate, t, tol=1e-8):
low, high = 0.0001, 5.0
while high - low > tol:
mid = (low + high) / 2
if bs_call(spot, strike, rate, mid, t) > price:
high = mid
else:
low = mid
return (low + high) / 2
iv = implied_vol(8.0, 100, 100, 0.05, 0.5)
print(round(iv, 6)) # 0.240534
print(round(bs_call(100, 100, 0.05, iv, 0.5), 6)) # 8.0Le sourire
Si Black–Scholes était exact, toutes les options sur la même action et la même date impliqueraient la même volatilité. Ce n'est pas le cas : les prix d'exercice bas impliquent en général plus que les hauts (le *skew*), ou les deux ailes plus que le centre (le *sourire*). La forme montre ce que le modèle ignore (sauts, queues épaisses, demande de protection), ce qui explique que l'on cote les options en volatilité implicite : un nombre unique et comparable entre prix d'exercice et échéances, dont le prix découle.
# A smile: market prices at five strikes, converted to implied volatilities
import math
def norm_cdf(x):
return 0.5 * (1 + math.erf(x / math.sqrt(2)))
def bs_call(spot, strike, rate, sigma, t):
d1 = (math.log(spot / strike) + (rate + 0.5 * sigma ** 2) * t) / (sigma * math.sqrt(t))
return spot * norm_cdf(d1) - strike * math.exp(-rate * t) * norm_cdf(d1 - sigma * math.sqrt(t))
def implied_vol(price, spot, strike, rate, t, tol=1e-8):
low, high = 0.0001, 5.0
while high - low > tol:
mid = (low + high) / 2
low, high = (low, mid) if bs_call(spot, strike, rate, mid, t) > price else (mid, high)
return (low + high) / 2
strikes = [90, 95, 100, 105, 110]
quotes = [bs_call(100, k, 0.05, v, 0.5) for k, v in zip(strikes, [0.28, 0.24, 0.21, 0.22, 0.25])] # prices from a made-up smile
for strike, quote in zip(strikes, quotes):
print(strike, round(quote, 4), round(implied_vol(quote, 100, strike, 0.05, 0.5), 4))- La volatilité implicite est le
sigmaqui rendbs_callégal au prix du marché : on le trouve par recherche, faute de formule inverse. - Le prix croît avec
sigma: une seule réponse, et le signe de l'écart indique le sens ; hors des bornes, aucune. - Couper l'intervalle en deux converge vite (environ 30 étapes pour 8 chiffres) ; le sourire est la volatilité implicite en fonction du prix d'exercice.
À suivre : la recherche de racines par dichotomie et Newton, la méthode générale derrière cette recherche et une bien plus rapide.
Lab : ce qui est noté
- Comparer modèle et marché avec
call_price_error25 pts - Parcourir une grille de volatilités avec
implied_vol_scan25 pts - Inverser la formule avec
implied_vol25 pts - Lire tout un sourire avec
implied_vol_smile25 pts