Comparing Surface Interpolation for FX Options#

This notebook will give a demonstration of rateslib interpolating its two different FX vol surface parametrisations: the FXDeltaVolSurface and the FXSabrSurface.

To reference a publication we will use Iain Clark’s Foreign Exchange Option Pricing: A Practitioner’s Guide, and establish an FXForwards market similar to the values he uses in his Table 4.4 and and Table 4.5.

The eval_date is fictionally assumed to be 3rd May 2009 and the FX spot rate is 1.34664, and the continuously compounded EUR and USD rates are 1.0% and 0.4759..% respectively. With these we will be able to closely match his values for option strikes.

[1]:
from rateslib import *
from pandas import DataFrame

eur = Curve({dt(2009, 5, 3): 1.0, dt(2011, 5, 10): 1.0})
usd = Curve({dt(2009, 5, 3): 1.0, dt(2011, 5, 10): 1.0})
fxf = FXForwards(
    fx_rates=FXRates({"eurusd": 1.34664}, settlement=dt(2009, 5, 5)),
    fx_curves={"eureur": eur, "usdusd": usd, "eurusd": eur},
)
fx_solver = Solver(
    curves=[eur, usd],
    instruments=[
        Value(dt(2009, 5, 4), curves=eur, metric="cc_zero_rate"),
        Value(dt(2009, 5, 4), curves=usd, metric="cc_zero_rate")
    ],
    s=[1.00, 0.4759550366220911],
    fx=fxf,
)
SUCCESS: `func_tol` reached after 4 iterations (levenberg_marquardt), `f_val`: 5.204923799977582e-16, `time`: 0.0028s

The Data Used#

Usually 1Y Options use spot delta definitions, whilst 2Y Options use a forward delta. Clark, in his publication, noted this and also pre-computed the forward delta values, for a consistent representation. This will be used to calibrate the Surface.

[2]:
DataFrame(
   data=[
       [1.1964, 1.3620, 1.5501], [19.590, 18.250, 18.967],
       [1.1733, 1.3689, 1.5974], [19.068, 17.870, 18.485],
       [1.1538, 1.3748, 1.6393], [18.801, 17.677, 18.239]
   ],
   index=[("1y", "k"), ("1y", "vol"), ("18m", "k"), ("18m", "vol"), ("2y", "k"), ("2y", "vol")],
   columns=["25d Put", "ATM Put", "25d Call"]
)
[2]:
25d Put ATM Put 25d Call
(1y, k) 1.1964 1.3620 1.5501
(1y, vol) 19.5900 18.2500 18.9670
(18m, k) 1.1733 1.3689 1.5974
(18m, vol) 19.0680 17.8700 18.4850
(2y, k) 1.1538 1.3748 1.6393
(2y, vol) 18.8010 17.6770 18.2390

Create a DeltaVolSurface#

This surface matches conventions and delta values at the relevant expiries.

[3]:
fxs = FXDeltaVolSurface(
    eval_date=dt(2009, 5, 3),
    expiries=[dt(2010, 5, 3), dt(2011, 5, 3)],  # 1Y and 2Y
    delta_indexes=[0.25, 0.5, 0.75],
    node_values=[[5, 5, 5], [5, 5, 5]],
    delta_type="forward",
    id="dv"
)

Calibrate to the stated volatilities.

[4]:
op_args = dict(pair="eurusd", delta_type="forward", curves=[None, eur, None, usd], eval_date=dt(2009, 5, 3), vol=fxs, metric="vol")

vol_solver = Solver(
    surfaces=[fxs],
    instruments=[
        FXPut(expiry="1y", strike="-25d", **op_args),
        FXCall(expiry="1y", strike="atm_delta", **op_args),
        FXCall(expiry="1y", strike="25d", **op_args),
        FXPut(expiry="2y", strike="-25d", **op_args),
        FXCall(expiry="2y", strike="atm_delta", **op_args),
        FXCall(expiry="2y", strike="25d", **op_args),
    ],
    s=[19.59, 18.25, 18.967, 18.801, 17.677, 18.239],
    fx=fxf,
)
SUCCESS: `func_tol` reached after 10 iterations (levenberg_marquardt), `f_val`: 5.501481514056634e-17, `time`: 0.0121s

For the DeltaVolSurface, the method rateslib employs is to interpolate, temporally, between delta indexes, and then construct a DeltaVolSmile with those parameters. Finally deriving the volatility for a given strike or delta using the usual methods for a Smile.

[5]:
fxs.get_smile(dt(2010, 11, 3))
[5]:
<rateslib.fx_volatility.delta_vol.FXDeltaVolSmile at 0x107db3110>

Now we will derive the the values for the 18 month Options.

[6]:
result = FXPut(expiry="18m", strike="-25d", **op_args).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[6]:
{'strike': <Dual: 1.172631, (dv_1_0, dv_1_1, dv_1_2, ...), [-0.0, -0.0, 0.0, ...]>,
 'vol': <Dual: 19.064734, (dv_1_0, dv_1_1, dv_1_2, ...), [0.7, -0.0, 0.0, ...]>}
[7]:
result = FXCall(expiry="18m", strike="atm_delta", **op_args).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[7]:
{'strike': <Dual: 1.368385, (dv_1_0, dv_1_1, dv_1_2, ...), [0.0, 0.0, -0.0, ...]>,
 'vol': <Dual: 17.867943, (dv_1_0, dv_1_1, dv_1_2, ...), [-0.0, 0.7, 0.0, ...]>}
[8]:
result = FXCall(expiry="18m", strike="25d", **op_args).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[8]:
{'strike': <Dual: 1.597111, (dv_1_0, dv_1_1, dv_1_2, ...), [0.0, -0.0, 0.0, ...]>,
 'vol': <Dual: 18.482183, (dv_1_0, dv_1_1, dv_1_2, ...), [-0.0, 0.0, 0.7, ...]>}

Formatted for easy display this gives the following for the DeltaVolSmile at 18M:

[9]:
DataFrame(
   data=[[1.1726, 1.3684, 1.5971], [19.065, 17.868, 18.482]],
   index=[("18m", "k"), ("18m", "vol")],
   columns=["25d Put", "ATM Put", "25d Call"]
)
[9]:
25d Put ATM Put 25d Call
(18m, k) 1.1726 1.3684 1.5971
(18m, vol) 19.0650 17.8680 18.4820

Create a SabrSurface#

The SABRSurface behaves differently in the way it interpolates. For a given strike it will interpolate, temporally, between the volatility values obtained for that strike on neighboring SabrSmiles. It does not generate an intermediate SabrSmile for a given expiry.

[10]:
fxs2 = FXSabrSurface(
    eval_date=dt(2009, 5, 3),
    expiries=[dt(2010, 5, 3), dt(2011, 5, 3)],
    node_values=[[0.05, 1.0, 0.01, 0.01]]*2,
    pair="eurusd",
    id="sabr",
)
[11]:
op_args2 = dict(pair="eurusd", delta_type="forward", curves=[None, eur, None, usd], eval_date=dt(2009, 5, 3), vol=fxs2, metric="vol")
vol_solver2 = Solver(
    surfaces=[fxs2],
    instruments=[
        FXPut(expiry="1y", strike="-25d", **op_args2),
        FXCall(expiry="1y", strike="atm_delta", **op_args2),
        FXCall(expiry="1y", strike="25d", **op_args2),
        FXPut(expiry="2y", strike="-25d", **op_args2),
        FXCall(expiry="2y", strike="atm_delta", **op_args2),
        FXCall(expiry="2y", strike="25d", **op_args2),
    ],
    s=[19.59, 18.25, 18.967, 18.801, 17.677, 18.239],
    fx=fxf,
)
SUCCESS: `func_tol` reached after 10 iterations (levenberg_marquardt), `f_val`: 3.324154162802061e-16, `time`: 0.0601s
[12]:
result = FXPut(expiry="18m", strike="-25d", **op_args2).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[12]:
{'strike': <Dual: 1.172554, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [-0.5, 0.0, -0.0, ...]>,
 'vol': <Dual: 19.076934, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [71.2, -1.5, 3.7, ...]>}
[13]:
result = FXCall(expiry="18m", strike="atm_delta", **op_args2).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[13]:
{'strike': <Dual: 1.368393, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [0.3, 0.0, 0.0, ...]>,
 'vol': <Dual: 17.870025, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [68.2, 1.2, 1.8, ...]>}
[14]:
result = FXCall(expiry="18m", strike="25d", **op_args2).analytic_greeks(fx=fxf)
{"strike": result["__strike"], "vol": result["__vol"]*100}
[14]:
{'strike': <Dual: 1.597527, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [1.3, 0.1, 0.1, ...]>,
 'vol': <Dual: 18.505722, (sabr_1_0, sabr_1_1, sabr_1_2, ...), [71.8, 4.3, 3.7, ...]>}

Again for ease of display the values for the SabrSmile are as follows:

[15]:
DataFrame(
   data=[[1.1722, 1.3685, 1.5985], [19.081, 17.870, 18.511]],
   index=[("18m", "k"), ("18m", "vol")],
   columns=["25d Put", "ATM Put", "25d Call"]
)
[15]:
25d Put ATM Put 25d Call
(18m, k) 1.1722 1.3685 1.5985
(18m, vol) 19.0810 17.8700 18.5110

Comparing the interpolated values of the Surface#

We can make a plot of the comparison between the volatility values on of the interpolated DeltaVolSurface and the SabrSurface.

[16]:
strikes = [1.15 + _ * 0.0025 for _ in range(200)]

import matplotlib.pyplot as plt

fix, ax = plt.subplots(1,1)
ax.plot(strikes, [fxs.get_from_strike(_, fxf.rate("eurusd", dt(2010, 11, 5)), dt(2010, 11, 3))[1] for _ in strikes], label="DeltaVol")
ax.plot(strikes, [fxs2.get_from_strike(_, fxf, dt(2010, 11, 3))[1] for _ in strikes], label="Sabr")
ax.legend()
[16]:
<matplotlib.legend.Legend at 0x107ee17f0>
_images/z_fxvol_surface_construction_25_1.png