Overview
In the previous article we calibrated cash IRR payer, cash IRR receiver and physical vols in the physical measure. These sets of vols allowed us to value all settlement types in an arbitrage-free fashion. Nevertheless, what we were still lacking, was a complete vol cube for every single settlement type. As it is well known that "regular" interpolation techniques fail with respect that they are not proven to be free of arbitrage, we select the SABR model as a popular choice for completing the vol cube.
As it is not our main focus here to implement the most "sophisticated" version of SABR, but rather an example what is the next logical step after calibrating vols from observed market premiums, we stick to the basic version of SABR: Hagan et al. "Managing Smile Risk". The only amendment we make here is, that we explicitly allow for negative rates by utilizing a "shifted" version of the original model.
Approaching calibration
As pointed out above, we have flagged vols with three category types: "cash receiver", "cash payer" and "physical". These categories go into calibration separately. On each category we apply a sorting algorithm which is based on the "group-by" operation in Pandas. The logic of the grouping is simply that we look how many quotes we have over the full range of strikes. We start with those groups having covered all strikes with quotes and work our way though to those groups having fewer and fewer strikes. Each calibration starts with it’s neighbours parameter outcome (the first calibration has a naive initial guess). As there are a lot of expiry/ tail combinations where only ATM quotes are available, we use interpolation of the calibrated SABR parameters. For this we utilize Scipy "Griddata".
One more comment beforehand: As there is a bit of redundancy with regards to the SABR parameters, we fix beta at 0.5 (a choice often seen in SABR implementations).
To get well prepared for calibration we enrich our "vols_sabr_in" DataFrame with the parameter columns. Furthermore, we build calibration groups according to the number of available quotes over the range of strikes. See below:
SABR parameter calibration
As outlined above we now give our "groups" into calibration. The calibration itself will be done by the SABR class. We drip feed it group by group and later interpolate parameters where only ATM quotes are available. The algorithm is outlined below:
calib_results = np.empty((0,4))
for calib_groups in [calib_groups_rec, calib_groups_pay, calib_groups_phy]:
for name, group in calib_groups:
if name == 0: #where full set of strike data is availiable
vol_cube = pd.DataFrame(columns = group.columns,
index = group.index, data = group.values)
strikes = np.array(vol_cube.columns[2:13])
for number, line in enumerate(group.itertuples()):
if number == 0: # naive initial parameter guess
sabr_par = SABR(line[1], line[2], line.Index[0] / 12, strikes,
np.array(line[3:14]), line[16])
vol_cube['alpha'][line.Index] = sabr_par.alpha
vol_cube['rho'][line.Index] = sabr_par.rho
vol_cube['nu'][line.Index] = sabr_par.nu
par_updated = np.array([vol_cube['alpha'][line.Index],
vol_cube['rho'][line.Index],
vol_cube['nu'][line.Index]])
else: # last parameter outcome as initial guess
sabr_par = SABR(line[1], line[2], line.Index[0] / 12, strikes,
np.array(line[3:14]), line[16], par_updated)
vol_cube['alpha'][line.Index] = sabr_par.alpha
vol_cube['rho'][line.Index] = sabr_par.rho
vol_cube['nu'][line.Index] = sabr_par.nu
par_updated = np.array([vol_cube['alpha'][line.Index],
vol_cube['rho'][line.Index],
vol_cube['nu'][line.Index]])
elif 0 < name < 10: #where we have at least one OTM strike with data
par_updated = np.array([vol_cube['alpha'][0],
vol_cube['rho'][0],
vol_cube['nu'][0]])
incr_calib_group = pd.DataFrame(columns = group.columns, index = group.index,
data = group.values)
incr_calib_group.sort_index(ascending=False, inplace = True)
for number, line in enumerate(incr_calib_group.itertuples()):
sabr_par = SABR(line[1], line[2], line.Index[0] / 12, strikes,
np.array(line[3:14]),line[16], par_updated)
incr_calib_group['alpha'][line.Index] = sabr_par.alpha
incr_calib_group['rho'][line.Index] = sabr_par.rho
incr_calib_group['nu'][line.Index] = sabr_par.nu
par_updated = np.array([incr_calib_group['alpha'][line.Index],
incr_calib_group['rho'][line.Index],
incr_calib_group['nu'][line.Index]])
vol_cube = vol_cube.append(incr_calib_group).sort_index()
else: #where we only have ATM strike data we use a combination 'fillna' and interpolation with Scipy Griddata
old_x = vol_cube['alpha'].unstack().columns.values
old_y = vol_cube['alpha'].unstack().index.values
X, Y = np.meshgrid(old_x, old_y)
new_x = vols_sabr_in['alpha'].loc['Phy'].unstack().columns.values
new_y = vols_sabr_in['alpha'].loc['Phy'].unstack().index.values
XI, YI = np.meshgrid(new_x, new_y)
temp_par = np.empty((0,294))
for par in ['alpha', 'beta', 'rho', 'nu']:
if par == 'beta':
temp_par = np.vstack((temp_par, np.tile(0.5, (294,))))
else:
vol_cube[par].loc[1, 24]\
= vol_cube[par].unstack().fillna(method='bfill')[24][1]
values = vol_cube[par].unstack().values
interpolated = griddata((X.flatten(), Y.flatten()), values.flatten(),
(XI, YI), method = 'linear')
interpolated = pd.DataFrame(columns = new_x,
index = new_y, data = interpolated)
interpolated.fillna(method='ffill', inplace = True)
interpolated.loc[:, :24] = interpolated.loc[:, :24].transpose().fillna(
method='bfill').transpose()
temp_par = np.vstack((temp_par, interpolated.stack().values))
calib_results = np.append(calib_results, temp_par.T, axis = 0)
vols_sabr_in.iloc[:,14:] = calib_results
After running through the calibration we now have all the SABR parameters in our "vols_sabr_in" DataFrame.
SABR vol calculation
Before calling the "SABR_out" method from our SABR class, we do one final parameter calibration and then feed these parameters into "SABR_out" as outlined below:
vols_sabr_out = pd.DataFrame(columns = strikes, index = vols_sabr_in.index)
calib_results = np.empty((0,4))
for line in vols_sabr_in.itertuples():
sabr_par = SABR(line[1], line[2], line.Index[1] / 12,
strikes, np.array(line[3:14]),line[16],
np.array([vols_sabr_in['alpha'][line.Index], vols_sabr_in['rho'][line.Index],
vols_sabr_in['nu'][line.Index]]))
calib_results = np.vstack((calib_results, [sabr_par.alpha, 0.5,
sabr_par.rho, sabr_par.nu]))
vols_sabr_out.loc[line.Index] = sabr_par.SABR_out()
vols_sabr_in.iloc[:,14:] = calib_results
Inspection of the calibration results
As a first step, it makes sense to pick those expiry/ tail combinations where we have a full range of market quotes. In the following we choose four expiry/ tail combinations — in the hope to capture the majority of the vol structure — for our three vol types. We inspect the fit visually by Matplotlib charts:
It can been seen that expiry/ tail combinations which lie in the mid of the vol structure calibrate best. Longer and shorter combinations don’t fit equally well and also calibration on the wings of the distribution exhibit some problems. Those problems are well known with SABR and there are extensions to the original model that promise better fits.
As a last piece, we present a 30y30y smile comparioson between cash IRR receiver, cash IRR payer and physical (CCP) vols similar to the one we already showed in the previous article because here cash/ physical basis should be the most pronounced.
Here as well we see some problems of SABR on the right wing, where payers first seem to undershoot and then overshoot a bit, but hopefully you can get the picture: A combination of shifted log-vol implication, explicit cash-physical model calibration and a SABR model is able to yield a pricing maschinery that is able to supply a complete vol cubes for all settlement types. Therefore, an implementation similar to the one presented here in this series should put you in a position to consistently price all standard vanilla options.
References
Hagan et al.: "Managing smile risk"
This is a post in the
Cash vs. Physical Swaptions series.
Other posts in this series:
- Nov 07, 2018 - Collateralized Cash Price — An introduction to the new settlement standard in Swaptions
- May 26, 2018 - Collateralized Cash Price — A consistent pricing framework (part I)
- May 26, 2018 - Collateralized Cash Price — A consistent pricing framework (part II)
- May 26, 2018 - Collateralized Cash Price — A consistent pricing framework (part III)