Financial Engineering and Risk Management tutorials: Model Calibration

This tutorial gives hands-on experience on descriptive analysis (visualization, implied volatility) for option pricing, and model calibration on virtual option data (finding optimal initial start point for optimization routine, calibrating stochastic models on a dataset). The term “virtual option” means the option that do not exist in real market, but instead simulated from stochastic models.

C’est un support des travaux dirigés pour le cours de M2 “Financial Engineering and Risk Management” que j’ai enseigné aux étudiants de l’Université Paris-Sud en 2017 et 2018 (devenue désormais Paris-Saclay).

Calculating the implied volatility

Given Apple’s call option bid and ask data, calculate the implied volatility for the following call option and put option. Note: The implied volatility is the volatility that makes the option price from BS model equal to its actual market price.

\(K = 190\)
\(T-t = 151/365\)
\(q = 0.005\)
\(r = 0.0245\)
\(S_0​ = 190.3\)
\(C = 10.875\)
\(P = 9.625\)

We import necessary modules:

import warnings
warnings.filterwarnings("ignore")

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib import cm
%matplotlib inline
# for interactive figures
#%matplotlib notebook
from mpl_toolkits.mplot3d import Axes3D
from scipy import interpolate
from scipy.stats import norm
from scipy import optimize
import cmath
import math

Let’s load data from Excel:

# file name
excel_file = 'data_apple.xlsx'
# read data into a data frame
df = pd.read_excel(excel_file)
# create the 'Mid' variable
df['Mid'] = df[['Bid','Ask']].mean(axis=1)
# see some of the data
df.head()
Option_type Maturity_days Strike Ticker Bid Ask Last IVM Volm Mid
0 Call 25 170.0 AAPL 8/17/18 C170 21.150000 21.400000 20.65 30.762677 7 21.275000
1 Call 25 172.5 AAPL 8/17/18 C172.5 18.649994 19.049988 0.00 29.173643 0 18.849991
2 Call 25 175.0 AAPL 8/17/18 C175 16.449997 16.649994 16.50 28.306871 19 16.549995
3 Call 25 177.5 AAPL 8/17/18 C177.5 14.100000 14.400000 0.00 26.967507 0 14.250000
4 Call 25 180.0 AAPL 8/17/18 C180 12.050000 12.150000 12.10 26.321682 1129 12.100000

Plotting put options surface

Let’s plot put options surface for the given AAPL data. We use linear interpolation for missing strikes.

# define strikes and maturities
#all_strikes = np.sort(df_calls.Strike.unique())
all_strikes = np.arange(170., 210. + 2.5, 2.5)
all_maturities = np.sort(df.Maturity_days.unique())
print(all_strikes)
print(all_maturities)
[170.  172.5 175.  177.5 180.  182.5 185.  187.5 190.  192.5 195.  197.5
 200.  202.5 205.  207.5 210. ]
[ 25  60  88 116 151 179 333 543 697]
df_puts = df[df['Option_type'] == 'Put'][['Maturity_days', 'Strike', 'Mid']]
df_puts.head()
Maturity_days Strike Mid
17 25 170.0 0.435
18 25 172.5 0.570
19 25 175.0 0.765
20 25 177.5 1.035
21 25 180.0 1.415
# define a grid for the surface
X, Y = np.meshgrid(all_strikes, all_maturities)
Z_p = np.empty([len(all_maturities), len(all_strikes)])
# Use linear interpolation to fill missing strikes
for i in range(len(all_maturities)):
    maturity_data = df_puts[df_puts['Maturity_days'] == all_maturities[i]]
    
    # Interpolate for the mid prices
    interp_func = interpolate.interp1d(maturity_data['Strike'], maturity_data['Mid'], kind='linear', fill_value="extrapolate")
    
    # Fill the grid with interpolated values
    Z_p[i, :] = interp_func(all_strikes)
# plot the surface
fig = plt.figure(figsize=(8,6))
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(X, Y, Z_p, cmap=cm.coolwarm)
ax.set_ylabel('Maturity (days)') 
ax.set_xlabel('Strike') 
ax.set_zlabel('P(K, T)')
ax.set_title('Apple Puts')
plt.savefig('fig3.png')
plt.show()

png

Calculate implied volatility

Calculate the implied volatility of the following call option and put option. The implied volatility is the volatility that makes the option price from BS model equal to its actual market price.

\(K\) = 190
\(T-t\) = 151/365
\(q\) = 0.005
\(r\) = 0.0245
\(S_0\) = 190.3
\(C\) = 10.875
\(P\) = 9.625

# select strike and maturity
K = 190.
T_days = 151
T_years = 1.* T_days / 365

# dividend rate
q = 0.005

# risk free rate
r = 0.0245

# spot price
S_0 = 190.3

# price
P_call = 10.875
P_put = 9.625
# utilize the following black scholes calculator to get the price from BS model
def BS_d1(S, K, r, q, sigma, tau):
    ''' Computes d1 for the Black Scholes formula '''
    d1 = 1.0*(np.log(1.0 * S/K) + (r - q + sigma**2/2) * tau) / (sigma * np.sqrt(tau))
    return d1

def BS_d2(S, K, r, q, sigma, tau):
    ''' Computes d2 for the Black Scholes formula '''
    d2 = 1.0*(np.log(1.0 * S/K) + (r - q - sigma**2/2) * tau) / (sigma * np.sqrt(tau))
    return d2

def BS_price(type_option, S, K, r, q, sigma, T, t=0):
    ''' Computes the Black Scholes price for a 'call' or 'put' option '''
    tau = T - t
    d1 = BS_d1(S, K, r, q, sigma, tau)
    d2 = BS_d2(S, K, r, q, sigma, tau)
    if type_option == 'call':
        price = S * np.exp(-q * tau) * norm.cdf(d1) - K * np.exp(-r * tau) * norm.cdf(d2)
    elif type_option == 'put':
        price = K * np.exp(-r * tau) * norm.cdf(-d2) - S * np.exp(-q * tau) * norm.cdf(-d1) 
    return price

### part need to be finished ###
# auxiliary function for computing implied vol
def aux_imp_vol(sigma, P, type_option, S, K, r, q, T, t=0):

    # Calculate the Black-Scholes price
    bs_price = BS_price(type_option, S, K, r, q, sigma, T, t)
    # Return the difference between the market price and the BS price
    return bs_price - P
# compute implied vol
imp_vol_call = optimize.brentq(aux_imp_vol, 0.01, 0.4, args=(P_call, 'call', S_0, K, r, q, T_years))
imp_vol_put = optimize.brentq(aux_imp_vol, 0.01, 0.4, args=(P_put, 'put', S_0, K, r, q, T_years))
imp_vol_call, imp_vol_put
(0.20505479848219343, 0.2169237544182971)

Answer: the implied volatilities of the options are:

call: 0.2051
put: 0.2169

Heston model: searching optimal initial point

Given two sets of initial parameters of Heston model, \(P_1 = (1.0, 0.02, 0.05, -0.4, 0.08), P_2 = (3.0, 0.06, 0.10, -0.6, 0.04)\), find the optimal initial parameters set lying on the hyperparameter line between the given two parameters sets.

# import module needed for option pricing
import readPlotOptionSurface 
import modulesForCalibration as mfc

png

# Parameters
alpha = 1.5
eta = 0.2
    
n = 12

# Model
model = 'Heston' 


# risk free rate
r = 0.0245
# dividend rate
q = 0.005
# spot price
S0 = 190.3
params1 = (1.0, 0.02, 0.05, -0.4, 0.08)
params2 = (3.0, 0.06, 0.10, -0.6, 0.04)
iArray = []
rmseArray = []
rmseMin = 1e10

maturities, strikes, callPrices = readPlotOptionSurface.readNPlot()

marketPrices = callPrices
maturities_years = maturities/365.0

png

from scipy.optimize import minimize_scalar
#  Note: You could use the eValue function in modulesForCalibration (mfc) to calculate the mean square error. 
#  For more detail about this function, please refer to modulesForCalibration.py

# Initialize optimal parameters and minimum RMSE
optimParams = params1
rmseMin = 10**6

# Initialize the search for optimal parameters between params1 and params2
kappa_range = np.linspace(params1[0], params2[0], num= int((params2[0]-params1[0])/0.01) + 1)

# Iterate through the parameter grid

def f(kappa):
    theta = params1[1] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[1] - params1[1])
    sigma = params1[2] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[2] - params1[2])
    rho   = params1[3] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[3] - params1[3])
    v0    = params1[4] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[4] - params1[4])
    print("kappa", kappa)
    params = (kappa, theta, sigma, rho, v0)
    return mfc.eValue(params, marketPrices, maturities_years, strikes, r, q, S0, alpha, eta, n, model)


result = minimize_scalar(f, bounds =(params1[0], params2[0]), method="bounded")
kappa 1.7639320225002102
kappa 2.23606797749979
kappa 2.5278640450004204
kappa 2.2914719396292194
kappa 2.306021558871544
kappa 2.306683816848725
kappa 2.3065629682560886
kappa 2.3065596007108975
kappa 2.30656633580128
result
     fun: 0.8658456401462155
 message: 'Solution found.'
    nfev: 9
  status: 0
 success: True
       x: 2.3065629682560886

The min-search algorithm converged after 9 iterations at \(K=2.30\). We can now calculate the other parameters.

kappa = result["x"]
theta = params1[1] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[1] - params1[1])
sigma = params1[2] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[2] - params1[2])
rho   = params1[3] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[3] - params1[3])
v0    = params1[4] + (kappa - params1[0])/(params2[0] - params1[0])*(params2[4] - params1[4])

theta, sigma, rho, v0
(0.046131259365121774,
 0.08266407420640222,
 -0.5306562968256089,
 0.05386874063487823)

Answer: the optimal initial parameters are:
\(K = 2.31\)
\(\theta = 0.046\)
\(\sigma = 0.083\)
\(\rho = -0.53\)

Heston Model Calibration

Calibrate the Heston model for the given dataset ‘Virtual Option Data.csv’ by brute force. The searching range of parameters should be the followings:

\(2.5 \leq \kappa \leq 3.0\) with step 2.5
\(0.06 \leq \theta \leq 0.065\) with step 0.0025
\(0.1 \leq \sigma \leq 0.3\) with step 0.05
\(-0.675 \leq \rho \leq -0.625\) with step 0.025
\(0.04 \leq v_0 \leq 0.06\) with step 0.01

What is the calibrated value of \(κ\)?
What is the calibrated value of \(θ\)?
What is the calibrated value of \(σ\)?
What is the calibrated value of \(ρ\)?

# use the following fixed Parameters to calibrate the model
S0 = 100
K = 80
k = np.log(K)
r = 0.05
q = 0.015

# Parameters setting in fourier transform
alpha = 1.5
eta = 0.2

n = 12
N = 2**n
# step-size in log strike space
lda = (2*np.pi/N)/eta

#Choice of beta
beta = np.log(S0)-N*lda/2

# Model
model = 'Heston'
# calculate characteristic function of different models
def generic_CF(u, params, T, model):
    
    if (model == 'GBM'):
        
        sig = params[0];
        mu = np.log(S0) + (r-q-sig**2/2)*T;
        a = sig*np.sqrt(T);
        phi = np.exp(1j*mu*u-(a*u)**2/2);
        
    elif(model == 'Heston'):
        
        kappa  = params[0];
        theta  = params[1];
        sigma  = params[2];
        rho    = params[3];
        v0     = params[4];

        tmp = (kappa-1j*rho*sigma*u);
        g = np.sqrt((sigma**2)*(u**2+1j*u)+tmp**2);
        
        pow1 = 2*kappa*theta/(sigma**2);

        numer1 = (kappa*theta*T*tmp)/(sigma**2) + 1j*u*T*r + 1j*u*math.log(S0);
        log_denum1 = pow1 * np.log(np.cosh(g*T/2)+(tmp/g)*np.sinh(g*T/2));
        tmp2 = ((u*u+1j*u)*v0)/(g/np.tanh(g*T/2)+tmp);
        log_phi = numer1 - log_denum1 - tmp2;
        phi = np.exp(log_phi);

    elif (model == 'VG'):
        
        sigma  = params[0];
        nu     = params[1];
        theta  = params[2];

        if (nu == 0):
            mu = math.log(S0) + (r-q - theta -0.5*sigma**2)*T;
            phi  = math.exp(1j*u*mu) * math.exp((1j*theta*u-0.5*sigma**2*u**2)*T);
        else:
            mu  = math.log(S0) + (r-q + math.log(1-theta*nu-0.5*sigma**2*nu)/nu)*T;
            phi = cmath.exp(1j*u*mu)*((1-1j*nu*theta*u+0.5*nu*sigma**2*u**2)**(-T/nu));

    return phi

# calculate option price by inverse fourier transform
def genericFFT(params, T):
    
    # forming vector x and strikes km for m=1,...,N
    km = []
    xX = []
    
    # discount factor
    df = math.exp(-r*T)
    
    for j in range(N):
        
        nuJ=j*eta
        km.append(beta+j*lda)
        
        psi_nuJ = df*generic_CF(nuJ-(alpha+1)*1j, params, T, model)/((alpha + 1j*nuJ)*(alpha+1+1j*nuJ))
        if j == 0:
            wJ = (eta/2)
        else:
            wJ = eta
        
        xX.append(cmath.exp(-1j*beta*nuJ)*psi_nuJ*wJ)
     
    yY = np.fft.fft(xX)
    
    cT_km = []    
    for i in range(N):
        multiplier = math.exp(-alpha*km[i])/math.pi
        cT_km.append(multiplier*np.real(yY[i]))
    
    return km, cT_km

# myRange(a, b) return a generator [a, a+1, ..., b], which is different from built-in generator Range that returns [a, a+1,..., b-1]. 
# You may use it to perform brute force
def myRange(start, finish, increment):
    while (start <= finish):
        yield start
        start += increment
# load virtual option data
data = pd.read_csv("Virtual Option Data.csv", index_col=0)
# generate strike and maturity array
strikes = np.array(data.index, dtype=float)
maturities = np.array(data.columns, dtype=float)
marketPrices = data.values
modelPrices = np.zeros_like(marketPrices)

maeMin = 1.0e6
### part need to be finished ###

kappa_range = list(myRange(2.5, 3.0, 0.25))
theta_range = list(myRange(0.06, 0.065, 0.0025))
sigma_range = list(myRange(0.1, 0.3, 0.05))
rho_range = list(myRange(-0.675, -0.625, 0.025))
v0_range = list(myRange(0.04, 0.06, 0.01))

for kappa in kappa_range:
    print("kappa", kappa)
    for theta in theta_range:
        print("theta", theta)
        for sigma in sigma_range:
            print("sigma", sigma)
            for rho in rho_range:
                print("rho", rho)
                for v0 in v0_range:
                    print("v0", v0)
                    params = (kappa, theta, sigma, rho, v0)
                    rmse =  mfc.eValue(params, marketPrices, maturities_years, strikes, r, q, S0, alpha, eta, n, model)
                    iArray.append(params)
                    rmseArray.append(rmse)
                    print(rmse)
                    if rmse < rmseMin:
                        rmseMin = rmse
                        optimParams = params
print(rmseMin)
print(optimParams)

Answer: the calibrated values are:

\(κ = 2.75\)
\(θ = 0.06\)
\(σ = 0.25\)
\(ρ = -0.65\)