Collateralized Cash Price — A consistent pricing framework (part III)

We apply a SABR model on the previously calibrated cash IRR, CCP and physical settled swaptions.

Posted by Oliver Kahl on Sat, May 26, 2018
Tags: swaptions, python, cash vs. physical
Series: Cash vs. Physical Swaptions

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 vols in
Figure 1. Vols going into SABR calibration.

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:

SABR phy smile
Figure 2. Fit SABR vs market for physical (cash CCP) settlement.
SABR cash rec smile
Figure 3. Fit SABR vs market for cash IRR receiver.
SABR cash pay smile
Figure 4. Fit SABR vs market for cash IRR payer.

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.

SABR 30y30y smile
Figure 5. SABR 30y30y smile for all settlement types.

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: