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>
