Usar la fórmula al revés
La fórmula de Black–Scholes convierte una volatilidad en un precio. En el mercado ocurre lo contrario: el precio está cotizado y la volatilidad se desconoce. La volatilidad implícita es la sigma para la que la fórmula reproduce el precio de mercado. No hay inversa algebraica, así que se busca: se calcula el precio para una sigma de prueba, se mira el error de precio (modelo menos mercado) y se mueve sigma hasta anularlo. La búsqueda más simple prueba una malla de valores y se queda con el mejor.
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))Por qué hay una única respuesta
El precio del call sube de forma constante con sigma: la vega es positiva, así que el error cruza cero una sola vez. La respuesta es única y se sabe hacia dónde moverse: si el modelo es demasiado caro, bajar sigma. Solo existe solución si el precio de mercado está estrictamente entre el *suelo intrínseco* max(S - K * exp(-rT), 0) (volatilidad cero) y el spot S (volatilidad infinita). Fuera de esos límites la cotización contradice la ausencia de arbitraje y la respuesta correcta es un error, no un número.
# 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 spotDividir el intervalo a la mitad
Una malla es tosca: la respuesta es tan fina como la malla. Mejor usar el signo del error. Se parte de sigma entre un valor muy bajo y uno muy alto, se toma el punto medio y se mira el error: positivo, el medio es demasiado alto, se queda la mitad inferior; negativo, la superior. Cada paso divide el intervalo por dos, así que unos treinta pasos dan ocho cifras. El siguiente tema lo nombra y lo generaliza; aquí es la herramienta que lee el mercado.
# 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.0La sonrisa
Si Black–Scholes fuera exacto, todas las opciones sobre la misma acción y fecha implicarían la misma volatilidad. No es así: los strikes bajos suelen implicar más que los altos (el *skew*), o ambas alas más que el centro (la *sonrisa*). El patrón muestra lo que el modelo no capta (saltos, colas gruesas, demanda de protección), por lo que las opciones se cotizan en volatilidad implícita: un número único y comparable entre strikes y vencimientos, del que se deduce el precio.
# 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 volatilidad implícita es la
sigmaque igualabs_callal precio de mercado: se halla por búsqueda, porque no hay fórmula inversa. - El precio sube con
sigma: hay una respuesta y el signo del error dice hacia dónde moverse; fuera de los límites no hay ninguna. - Dividir el intervalo converge rápido (unos 30 pasos para 8 cifras); la sonrisa es la volatilidad implícita frente al strike.
A continuación: la búsqueda de raíces con bisección y Newton, el método general detrás de esta búsqueda y otro mucho más rápido.
Lab: qué se califica
- Comparar modelo y mercado con
call_price_error25 pts - Recorrer una malla de volatilidades con
implied_vol_scan25 pts - Invertir la fórmula con
implied_vol25 pts - Leer una sonrisa entera con
implied_vol_smile25 pts