Podemos demostrarlo mediante un experimento de fijación de precios utilizando QuantLib-Python.
He definido varias funciones de utilidad en el bloque de código al final de la respuesta que necesitarás para replicar el trabajo.
En primer lugar, vamos a crear un proceso Heston, y a calibrar un modelo local de vol para que coincida con él. Hasta cuestiones numéricas, ambos deberían tener el mismo precio de las vainillas.
v0, kappa, theta, rho, sigma = 0.015, 1.5, 0.08, -0.4, 0.4
dates, strikes, vols, feller = create_vol_surface_mesh_from_heston_params(today, calendar, spot, v0, kappa, theta, rho, sigma, flat_ts, dividend_ts)
local_vol_surface = ql.BlackVarianceSurface(today, calendar, dates, strikes, vols, day_count)
# Plot the vol surface ...
plot_vol_surface(local_vol_surface, plot_years=np.arange(0.1, 2, 0.1))
![Our vol surface]()
En este caso, he elegido los parámetros de heston para dar un vol bastante rápido, un sesgo moderado hacia abajo, y para mantenernos a salvo de la condición de feller.
Ahora, la forma más elegante de proceder sería utilizar los precios incorporados en ql
y los instrumentos de precio del tipo ql.ForwardVanillaOption
pero, lamentablemente, el único motor de fijación de precios de opciones a plazo expuesto actualmente en python es ql.ForwardEuropeanEngine
whihc pondrá el precio bajo el vol local pero no bajo el modelo heston, así que en su lugar procedo usando monte carlo y poniendo el precio de las opciones explícitamente (es un poco tosco pero demuestra el punto).
El siguiente paso es generar muchas rutas de MC a partir de los procesos que acabo de definir
local_vol = ql.BlackVolTermStructureHandle(local_vol_surface)
bs_process = ql.BlackScholesMertonProcess(ql.QuoteHandle(ql.SimpleQuote(spot)), dividend_ts, flat_ts, local_vol)
heston_process = ql.HestonProcess(flat_ts, dividend_ts, ql.QuoteHandle(ql.SimpleQuote(spot)), v0, kappa, theta, sigma, rho)
bs_paths = generate_multi_paths_df(bs_process, num_paths=100000, timestep=72, length=3)[0]
heston_paths, heston_vols = generate_multi_paths_df(heston_process, num_paths=100000, timestep=72, length=3)
bs_paths.head().transpose().plot()
plt.pause(0.05)
heston_paths.head().transpose().plot()
![Sample local vol and Heston paths]()
Ahora que tenemos trayectorias, queremos cotizar las opciones que comienzan a lo largo de cada una de ellas. A continuación, cotizo las opciones que comienzan en 1Y y expiran en 2Y, y las opciones que comienzan en 2Y y expiran en 3Y, con distintos tipos de dinero (el strike sólo se determina al inicio, por el spot*dinero). Como mis tipos son 0 en todas partes, el precio de estas opciones es simplemente (S(2) - moneyness * S(1)).clip(0).mean()
o similar.
También tenemos que retirar los "vols implícitos" de estos precios. Dado que el precio de ejercicio no se determina de antemano, no está del todo claro que utilizar la fórmula normal de BS sea correcto, pero lo he hecho de todos modos (utilizando moneyness * spot como precio de ejercicio), a continuación.
moneynesses = np.linspace(0.6, 1.4, 17)
prices = []
for moneyness in moneynesses:
lv_price_1y = (bs_paths[2.0] - moneyness * bs_paths[1.0]).clip(0).mean()
lv_price_2y = (bs_paths[3.0] - moneyness * bs_paths[2.0]).clip(0).mean()
heston_price_1y = (heston_paths[2.0] - moneyness * heston_paths[1.0]).clip(0).mean()
heston_price_2y = (heston_paths[3.0] - moneyness * heston_paths[2.0]).clip(0).mean()
prices.append({'moneyness': moneyness, 'lv_price_1y': lv_price_1y, 'lv_price_2y': lv_price_2y, 'heston_price_1y': heston_price_1y, 'heston_price_2y': heston_price_2y})
price_df = pd.DataFrame(prices)
price_df['lv_iv_1y'] = price_df.apply(lambda x: bs_implied_vol(x['lv_price_1y'], 1.0, 100, 100 * x['moneyness'], 1.0), axis=1)
price_df['lv_iv_2y'] = price_df.apply(lambda x: bs_implied_vol(x['lv_price_2y'], 1.0, 100, 100 * x['moneyness'], 1.0), axis=1)
price_df['heston_iv_1y'] = price_df.apply(lambda x: bs_implied_vol(x['heston_price_1y'], 1.0, 100, 100 * x['moneyness'], 1.0), axis=1)
price_df['heston_iv_2y'] = price_df.apply(lambda x: bs_implied_vol(x['heston_price_2y'], 1.0, 100, 100 * x['moneyness'], 1.0), axis=1)
plt.plot(moneynesses, price_df['lv_iv_1y'], label='lv 1y fwd iv at 1y')
plt.plot(moneynesses, price_df['lv_iv_2y'], label='lv 1y fwd iv at 2y')
plt.plot(moneynesses, price_df['heston_iv_1y'], label='heston 1y fwd iv at 1y')
plt.plot(moneynesses, price_df['heston_iv_2y'], label='heston 1y fwd iv at 2y')
plt.title("Forward IVs in Local Vol and Heston")
plt.legend()
![Heston and lv forward-start vols]()
Como puede ver, los vols a plazo procedentes de lv son mucho más planos y menos sonrientes que los precios del proceso de heston, que es exactamente el efecto que buscábamos.
Funciones de utilidad y código boilerplate de QuantLib:
import warnings
warnings.filterwarnings('ignore')
import QuantLib as ql
import numpy as np
import pandas as pd
from scipy import optimize, stats
from matplotlib import pyplot as plt
import matplotlib.cm as cm
from mpl_toolkits.mplot3d import Axes3D
def plot_vol_surface(vol_surface, plot_years=np.arange(0.1, 3, 0.1), plot_strikes=np.arange(70, 130, 1), funct='blackVol'):
if type(vol_surface) != list:
surfaces = [vol_surface]
else:
surfaces = vol_surface
fig = plt.figure(figsize=(8,6))
ax = fig.gca(projection='3d')
X, Y = np.meshgrid(plot_strikes, plot_years)
for surface in surfaces:
method_to_call = getattr(surface, funct)
Z = np.array([method_to_call(float(y), float(x))
for xr, yr in zip(X, Y)
for x, y in zip(xr,yr) ]
).reshape(len(X), len(X[0]))
surf = ax.plot_surface(X,Y,Z, rstride=1, cstride=1, linewidth=0.1)
N = Z / Z.max() # normalize 0 -> 1 for the colormap
surf = ax.plot_surface(X, Y, Z, rstride=1, cstride=1, linewidth=0.1, facecolors=cm.twilight(N))
m = cm.ScalarMappable(cmap=cm.twilight)
m.set_array(Z)
plt.colorbar(m, shrink=0.8, aspect=20)
ax.view_init(30, 300)
def generate_multi_paths_df(process, num_paths=1000, timestep=24, length=2):
"""Generates multiple paths from an n-factor process, each factor is returned in a seperate df"""
times = ql.TimeGrid(length, timestep)
dimension = process.factors()
rng = ql.GaussianRandomSequenceGenerator(ql.UniformRandomSequenceGenerator(dimension * timestep, ql.UniformRandomGenerator()))
seq = ql.GaussianMultiPathGenerator(process, list(times), rng, False)
paths = [[] for i in range(dimension)]
for i in range(num_paths):
sample_path = seq.next()
values = sample_path.value()
spot = values[0]
for j in range(dimension):
paths[j].append([x for x in values[j]])
df_paths = [pd.DataFrame(path, columns=[spot.time(x) for x in range(len(spot))]) for path in paths]
return df_paths
def create_vol_surface_mesh_from_heston_params(today, calendar, spot, v0, kappa, theta, rho, sigma,
rates_curve_handle, dividend_curve_handle,
strikes = np.linspace(40, 200, 161), tenors = np.linspace(0.1, 3, 60)):
quote = ql.QuoteHandle(ql.SimpleQuote(spot))
heston_process = ql.HestonProcess(rates_curve_handle, dividend_curve_handle, quote, v0, kappa, theta, sigma, rho)
heston_model = ql.HestonModel(heston_process)
heston_handle = ql.HestonModelHandle(heston_model)
heston_vol_surface = ql.HestonBlackVolSurface(heston_handle)
data = []
for strike in strikes:
data.append([heston_vol_surface.blackVol(tenor, strike) for tenor in tenors])
expiration_dates = [calendar.advance(today, ql.Period(int(365*t), ql.Days)) for t in tenors]
implied_vols = ql.Matrix(data)
feller = 2 * kappa * theta - sigma ** 2
return expiration_dates, strikes, implied_vols, feller
def d_plus_minus(forward, strike, tte, vol):
denominator = vol * np.sqrt(tte)
inner_term = np.log(forward / strike) + 0.5 * vol * vol * tte
d_plus = inner_term / denominator
d_minus = d_plus - denominator
return d_plus, d_minus
def call_option_price(vol, dcf, forward, strike, tte):
d_plus, d_minus = d_plus_minus(forward, strike, tte, vol)
return dcf * (forward * stats.norm.cdf(d_plus) - strike * stats.norm.cdf(d_minus))
def vol_solver_helper(x, price, dcf, forward, strike, tte):
return call_option_price(x, dcf, forward, strike, tte) - price
def bs_implied_vol(price, dcf, forward, strike, tte):
return optimize.brentq(vol_solver_helper, 0.0001, 2.0, args=(price, dcf, forward, strike, tte))
# World State for Vanilla Pricing
spot = 100
vol = 0.1
rate = 0.0
dividend = 0.0
today = ql.Date(1, 9, 2020)
day_count = ql.Actual365Fixed()
calendar = ql.NullCalendar()
# Set up the vol and risk-free curves
volatility = ql.BlackConstantVol(today, calendar, vol, day_count)
riskFreeCurve = ql.FlatForward(today, rate, day_count)
dividendCurve = ql.FlatForward(today, rate, day_count)
flat_ts = ql.YieldTermStructureHandle(riskFreeCurve)
dividend_ts = ql.YieldTermStructureHandle(dividendCurve)
flat_vol = ql.BlackVolTermStructureHandle(volatility)