FX Volatility Surface Temporal Interpolation#
[1]:
from rateslib import *
from pandas import Series
import matplotlib.pyplot as plt
This article will demonstrate how rateslib performs temporal interpolation when FX volatility Surfaces are constructued with cross-sectional Smiles at given expiries.
FXDeltaVolSurfaces#
The default FXDeltaVolSurface is constructed with parametrised cross-sectional FXDeltaVolSmiles. The temporal interpolation method determines a delta-node between the two surrounding Smiles using linear total variance, which has been shown (see Clark: FX Option Pricing) to be equivalent to flat forward volatility within the interval.
Consider Table 4.7 of that same publication, Clark: FX Option Pricing. To replicate the data there we will create a Surface here which has flat line Smiles (i.e. there is just one volatility datapoint at each expiry) in the following way:
[2]:
fxvs = FXDeltaVolSurface(
expiries=[
dt(2024, 2, 12), # Spot
dt(2024, 2, 16), # 1W
dt(2024, 2, 23), # 2W
dt(2024, 3, 1), # 3W
dt(2024, 3, 8), # 4W
],
delta_indexes=[0.5],
node_values=[[8.15], [11.95], [11.97], [11.75], [11.80]],
eval_date=dt(2024, 2, 9),
delta_type="forward",
)
[3]:
fxvs.plot()
[3]:
(<Figure size 640x480 with 1 Axes>, <Axes3D: >, None)

In the time/expiry dimension we will plot the volatility as measured for every calendar day expiry for the four weeks, using the 50% delta midpoint on each Smile.
[4]:
cal = get_calendar("all")
x, y = [], []
for date in cal.cal_date_range(dt(2024, 2, 10), dt(2024, 3, 8)):
x.append(date)
y.append(fxvs.get_smile(date)[0.5])
fig, ax = plt.subplots(1,1)
plt.xticks(rotation=90)
ax.plot(x,y)
[4]:
[<matplotlib.lines.Line2D at 0x1114ece10>]

Using Weights#
The comment in the publication is that markets do not assign volatility to calendar days when the market is closed. In this section we will provide weights that manipulate the forward volatility and align with table 4.7.
Date |
Weight |
Volatility to Expiry |
---|---|---|
10 Feb ‘24 |
0.0 |
0.0 |
11 Feb ‘24 |
0.0 |
0.0 |
12 Feb ‘24 |
1.0 |
8.15 |
13 Feb ‘24 |
1.0 |
9.99 |
14 Feb ‘24 |
1.0 |
10.95 |
15 Feb ‘24 |
1.0 |
11.54 |
16 Feb ‘24 |
1.0 |
11.95 |
17 Feb ‘24 |
0.0 |
11.18 |
18 Feb ‘24 |
0.0 |
10.54 |
19 Feb ‘24 |
1.0 |
10.96 |
20 Feb ‘24 |
1.0 |
11.29 |
21 Feb ‘24 |
1.0 |
11.56 |
22 Feb ‘24 |
1.0 |
11.78 |
23 Feb ‘24 |
1.0 |
11.97 |
24 Feb ‘24 |
0.0 |
11.56 |
25 Feb ‘24 |
0.0 |
11.20 |
26 Feb ‘24 |
1.0 |
11.34 |
27 Feb ‘24 |
1.0 |
11.46 |
28 Feb ‘24 |
1.0 |
11.57 |
29 Feb ‘24 |
1.0 |
11.66 |
1 Mar ‘24 |
1.0 |
11.75 |
2 Mar ‘24 |
0.0 |
11.48 |
3 Mar ‘24 |
0.0 |
11.23 |
4 Mar ‘24 |
1.0 |
11.36 |
5 Mar ‘24 |
1.0 |
11.49 |
6 Mar ‘24 |
1.0 |
11.60 |
7 Mar ‘24 |
1.0 |
11.70 |
8 Mar ‘24 |
1.0 |
11.80 |
9 Mar ‘24 |
0.0 |
11.59 |
We can use the calendar methods in rateslib to create an indexed Series with zero weights where we want to have them.
[5]:
# Use a generic business day calendar to find the weekends
cal = get_calendar("bus")
weekends = [
_ for _ in cal.cal_date_range(dt(2024, 2, 9), dt(2024, 3, 11))
if _ not in cal.bus_date_range(dt(2024, 2, 9), dt(2024, 3, 11))
]
weights = Series(0.0, index=weekends)
weights
[5]:
2024-02-10 0.0
2024-02-11 0.0
2024-02-17 0.0
2024-02-18 0.0
2024-02-24 0.0
2024-02-25 0.0
2024-03-02 0.0
2024-03-03 0.0
2024-03-09 0.0
2024-03-10 0.0
dtype: float64
Now we will rebuild an FXDeltaVolSurface and plot the difference to before.
[6]:
fxvs_2 = FXDeltaVolSurface(
expiries=[
dt(2024, 2, 12), # Spot
dt(2024, 2, 16), # 1W
dt(2024, 2, 23), # 2W
dt(2024, 3, 1), # 3W
dt(2024, 3, 8), # 4W
],
delta_indexes=[0.5],
node_values=[[8.15], [11.95], [11.97], [11.75], [11.80]],
eval_date=dt(2024, 2, 9),
delta_type="forward",
weights=weights,
)
[7]:
cal = get_calendar("all")
x, y, y2 = [], [], []
for date in cal.cal_date_range(dt(2024, 2, 10), dt(2024, 3, 8)):
x.append(date)
y.append(fxvs.get_smile(date)[0.5])
y2.append(fxvs_2.get_smile(date)[0.5])
fig, ax = plt.subplots(1,1)
plt.xticks(rotation=90)
ax.plot(x,y, label="excl. weights")
ax.plot(x,y2, label="incl. weights")
ax.plot([dt(2024, 2, 12), dt(2024, 2, 16), dt(2024, 2, 23), dt(2024, 3, 1), dt(2024, 3, 8)],
[8.15, 11.95, 11.97, 11.75, 11.80],
"o", label="benchmarks"
)
ax.legend()
[7]:
<matplotlib.legend.Legend at 0x1114652b0>

We observe the familiar sawtooth pattern that is frequently observed in short dated FX market vol.
FXSabrSurface#
The FXSabrSurface is constructed with cross-sectional FXSabrSmiles. For a given strike a volatility is obtained on the neighbouring Smiles and temporal interpolation is applied exactly as above.
An FXForwards market is required by FXSabrSurfaces in order to determine ATM-forward FX rates used within the SABR formula.
[8]:
eur = Curve({dt(2024, 2, 9): 1.0, dt(2026, 2, 15): 1.0})
usd = Curve({dt(2024, 2, 9): 1.0, dt(2026, 2, 15): 1.0})
fxf = FXForwards(
fx_rates=FXRates({"eurusd": 1.34664}, settlement=dt(2024, 2, 13)),
fx_curves={"eureur": eur, "usdusd": usd, "eurusd": eur},
)
solver = Solver(
curves=[eur, usd],
instruments=[
Value(dt(2024, 2, 10), curves=eur, metric="cc_zero_rate"),
Value(dt(2024, 2, 10), 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.0039s
Use the same weights
as defined above for the temporal interpolation.
[9]:
fxss = FXSabrSurface(
expiries=[
dt(2024, 2, 12), # Spot
dt(2024, 2, 16), # 1W
dt(2024, 2, 23), # 2W
dt(2024, 3, 1), # 3W
dt(2024, 3, 8), # 4W
],
node_values=[
[0.0815, 1.0, 0.0, 0.0],
[0.1195, 1.0, 0.0, 0.0],
[0.1197, 1.0, 0.0, 0.0],
[0.1175, 1.0, 0.0, 0.0],
[0.1180, 1.0, 0.0, 0.0],
],
eval_date=dt(2024, 2, 9),
pair="eurusd",
delivery_lag=2,
calendar="tgt|fed",
)
fxss_2 = FXSabrSurface(
expiries=[
dt(2024, 2, 12), # Spot
dt(2024, 2, 16), # 1W
dt(2024, 2, 23), # 2W
dt(2024, 3, 1), # 3W
dt(2024, 3, 8), # 4W
],
node_values=[
[0.0815, 1.0, 0.0, 0.0],
[0.1195, 1.0, 0.0, 0.0],
[0.1197, 1.0, 0.0, 0.0],
[0.1175, 1.0, 0.0, 0.0],
[0.1180, 1.0, 0.0, 0.0],
],
eval_date=dt(2024, 2, 9),
pair="eurusd",
delivery_lag=2,
calendar="tgt|fed",
weights=weights,
)
[10]:
x, y, y2 = [], [], []
for date in cal.cal_date_range(dt(2024, 2, 10), dt(2024, 3, 8)):
x.append(date)
y.append(fxss.get_from_strike(1.36, fxf, date)[1])
y2.append(fxss_2.get_from_strike(1.36, fxf, date)[1])
fig, ax = plt.subplots(1,1)
plt.xticks(rotation=90)
ax.plot(x,y, label="excl. weights")
ax.plot(x,y2, label="incl. weights")
ax.plot([dt(2024, 2, 12), dt(2024, 2, 16), dt(2024, 2, 23), dt(2024, 3, 1), dt(2024, 3, 8)],
[8.15, 11.95, 11.97, 11.75, 11.80],
"o", label="benchmarks"
)
ax.legend()
[10]:
<matplotlib.legend.Legend at 0x111632850>

The same recognisable chart is obtained.