A EURUSD market for IRS, cross-currency and FX volatility#

In this notebook we demonstrate the code for rateslib to build:

  • local currency interest rate curves in EUR and USD from RFR swaps,

  • collateral curves accounting for the cross-currency basis and FX swap points,

  • volatility surface priced from FX volatility products.

Input market data#

First things first we need market data for the interest rates curves and forward FX curves. This data was observed on 28th May 2024.

[1]:
from rateslib import *
import numpy as np
from pandas import DataFrame
[2]:
fxr = FXRates({"eurusd": 1.0867}, settlement=dt(2024, 5, 30))
[3]:
mkt_data = DataFrame(
    data=[['1w', 3.9035,5.3267,3.33,],
          ['2w', 3.9046,5.3257,6.37,],
          ['3w',3.8271,5.3232,9.83,],
          ['1m',3.7817,5.3191,13.78,],
          ['2m',3.7204,5.3232,30.04,],
          ['3m',3.667,5.3185,45.85,-2.5],
          ['4m',3.6252,5.3307,61.95,],
          ['5m',3.587,5.3098,78.1,],
          ['6m',3.5803,5.3109,94.25,-3.125],
          ['7m',3.5626,5.301,110.82,],
          ['8m',3.531,5.2768,130.45,],
          ['9m',3.5089,5.2614,145.6,-7.25],
          ['10m',3.4842,5.2412,162.05,],
          ['11m',3.4563,5.2144,178,],
          ['1y',3.4336,5.1936,None,-6.75],
          ['15m',3.3412,5.0729,None,-6.75],
          ['18m',3.2606,4.9694,None,-6.75],
          ['21m',3.1897,4.8797,None,-7.75],
          ['2y',3.1283,4.8022,None,-7.875],
          ['3y',2.9254,4.535,None,-9],
          ['4y',2.81,4.364,None,-10.125],
          ['5y',2.7252,4.256,None,-11.125],
          ['6y',2.6773,4.192,None,-12.125],
          ['7y',2.6541,4.151,None,-13],
          ['8y',2.6431,4.122,None,-13.625],
          ['9y',2.6466,4.103,None,-14.25],
          ['10y',2.6562,4.091,None,-14.875],
          ['12y',2.6835,4.084,None,-16.125],
          ['15y',2.7197,4.08,None,-17],
          ['20y',2.6849,4.04,None,-16],
          ['25y',2.6032,3.946,None,-12.75],
          ['30y',2.5217,3.847,None,-9.5]],
    columns=["tenor", "estr", "sofr", "fx_swap", "xccy"],
)
mkt_data
[3]:
tenor estr sofr fx_swap xccy
0 1w 3.9035 5.3267 3.33 NaN
1 2w 3.9046 5.3257 6.37 NaN
2 3w 3.8271 5.3232 9.83 NaN
3 1m 3.7817 5.3191 13.78 NaN
4 2m 3.7204 5.3232 30.04 NaN
5 3m 3.6670 5.3185 45.85 -2.500
6 4m 3.6252 5.3307 61.95 NaN
7 5m 3.5870 5.3098 78.10 NaN
8 6m 3.5803 5.3109 94.25 -3.125
9 7m 3.5626 5.3010 110.82 NaN
10 8m 3.5310 5.2768 130.45 NaN
11 9m 3.5089 5.2614 145.60 -7.250
12 10m 3.4842 5.2412 162.05 NaN
13 11m 3.4563 5.2144 178.00 NaN
14 1y 3.4336 5.1936 NaN -6.750
15 15m 3.3412 5.0729 NaN -6.750
16 18m 3.2606 4.9694 NaN -6.750
17 21m 3.1897 4.8797 NaN -7.750
18 2y 3.1283 4.8022 NaN -7.875
19 3y 2.9254 4.5350 NaN -9.000
20 4y 2.8100 4.3640 NaN -10.125
21 5y 2.7252 4.2560 NaN -11.125
22 6y 2.6773 4.1920 NaN -12.125
23 7y 2.6541 4.1510 NaN -13.000
24 8y 2.6431 4.1220 NaN -13.625
25 9y 2.6466 4.1030 NaN -14.250
26 10y 2.6562 4.0910 NaN -14.875
27 12y 2.6835 4.0840 NaN -16.125
28 15y 2.7197 4.0800 NaN -17.000
29 20y 2.6849 4.0400 NaN -16.000
30 25y 2.6032 3.9460 NaN -12.750
31 30y 2.5217 3.8470 NaN -9.500

Solving rates curves and FX forwards curve#

We will create all Curves and solve them all using the Solver. It is possible to solve everything simultaneously in a single Solver but this is less efficient than decoupling the known separable components, and using multiple Solvers in a dependency chain.

[4]:
eur = Curve(
    nodes={
        dt(2024, 5, 28): 1.0,
        **{add_tenor(dt(2024, 5, 30), _, "F", "tgt"): 1.0 for _ in mkt_data["tenor"]}
    },
    calendar="tgt",
    interpolation="log_linear",
    convention="act360",
    id="estr",
)
usd = Curve(
    nodes={
        dt(2024, 5, 28): 1.0,
        **{add_tenor(dt(2024, 5, 30), _, "F", "nyc"): 1.0 for _ in mkt_data["tenor"]}
    },
    calendar="nyc",
    interpolation="log_linear",
    convention="act360",
    id="sofr",
)
eurusd = Curve(
    nodes={
        dt(2024, 5, 28): 1.0,
        **{add_tenor(dt(2024, 5, 30), _, "F", "tgt"): 1.0 for _ in mkt_data["tenor"]}
    },
    interpolation="log_linear",
    convention="act360",
    id="eurusd",
)

With Curves created but not necessarily calibrated we can design the FXForwards market mapping:

[5]:
fxf = FXForwards(
    fx_rates=fxr,
    fx_curves={"eureur": eur, "eurusd": eurusd, "usdusd": usd}
)

The Instruments used to solve the ESTR curve are ESTR swaps and the SOFR curve are SOFR swaps:

[6]:
estr_swaps = [IRS(dt(2024, 5, 30), _, spec="eur_irs", curves="estr") for _ in mkt_data["tenor"]]
estr_rates = mkt_data["estr"].tolist()
labels = mkt_data["tenor"].to_list()
sofr_swaps =  [IRS(dt(2024, 5, 30), _, spec="usd_irs", curves="sofr") for _ in mkt_data["tenor"]]
sofr_rates = mkt_data["sofr"].tolist()
[7]:
eur_solver = Solver(
    curves=[eur],
    instruments=estr_swaps,
    s=estr_rates,
    fx=fxf,
    instrument_labels=labels,
    id="eur",
)
usd_solver = Solver(
    curves=[usd],
    instruments=sofr_swaps,
    s=sofr_rates,
    fx=fxf,
    instrument_labels=labels,
    id="usd",
)
SUCCESS: `func_tol` reached after 7 iterations (levenberg_marquardt), `f_val`: 1.6125193535364693e-15, `time`: 0.2175s
SUCCESS: `func_tol` reached after 7 iterations (levenberg_marquardt), `f_val`: 3.993407065375496e-17, `time`: 0.2185s

The cross currency curve use a combination of FXSwaps and XCS:

[8]:
fxswaps = [FXSwap(dt(2024, 5, 30), _, pair="eurusd", curves=[None, "eurusd", None, "sofr"]) for _ in mkt_data["tenor"][0:14]]
fxswap_rates = mkt_data["fx_swap"][0:14].tolist()
xcs = [XCS(dt(2024, 5, 30), _, spec="eurusd_xcs", curves=["estr", "eurusd", "sofr", "sofr"]) for _ in mkt_data["tenor"][14:]]
xcs_rates = mkt_data["xccy"][14:].tolist()
[9]:
fx_solver = Solver(
    pre_solvers=[eur_solver, usd_solver],
    curves=[eurusd],
    instruments=fxswaps + xcs,
    s=fxswap_rates + xcs_rates,
    fx=fxf,
    instrument_labels=labels,
    id="eurusd_xccy",
)
SUCCESS: `func_tol` reached after 5 iterations (levenberg_marquardt), `f_val`: 2.660105897983944e-21, `time`: 2.0243s

Solving an FX Vol Surface#

Next we will use the market FX volatility quotes to build a surface. These prices are all expressed in log-normal vol terms under normal market conventions and the instruments 1Y or less use spot unadjusted delta and those longer than 1y use forward undajusted delta.

[10]:
vol_data = DataFrame(
    data=[
        ['1w',4.535,-0.047,0.07,-0.097,0.252],
        ['2w',5.168,-0.082,0.077,-0.165,0.24],
        ['3w',5.127,-0.175,0.07,-0.26,0.233],
        ['1m',5.195,-0.2,0.07,-0.295,0.235],
        ['2m',5.237,-0.28,0.087,-0.535,0.295],
        ['3m',5.257,-0.363,0.1,-0.705,0.35],
        ['4m',5.598,-0.47,0.123,-0.915,0.422],
        ['5m',5.776,-0.528,0.133,-1.032,0.463],
        ['6m',5.92,-0.565,0.14,-1.11,0.49],
        ['9m',6.01,-0.713,0.182,-1.405,0.645],
        ['1y',6.155,-0.808,0.23,-1.585,0.795],
        ['18m',6.408,-0.812,0.248,-1.588,0.868],
        ['2y',6.525,-0.808,0.257,-1.58,0.9],
        ['3y',6.718,-0.733,0.265,-1.45,0.89],
        ['4y',7.025,-0.665,0.265,-1.31,0.885],
        ['5y',7.26,-0.62,0.26,-1.225,0.89],
        ['6y',7.508,-0.516,0.27,-0.989,0.94],
        ['7y',7.68,-0.442,0.278,-0.815,0.975],
        ['10y',8.115,-0.267,0.288,-0.51,1.035],
        ['15y',8.652,-0.325,0.362,-0.4,1.195],
        ['20y',8.651,-0.078,0.343,-0.303,1.186],
        ['25y',8.65,-0.029,0.342,-0.218,1.178],
        ['30y',8.65,0.014,0.341,-0.142,1.171],
    ],
    columns=["tenor", "atm", "25drr", "25dbf", "10drr", "10dbf"]
)
vol_data["expiry"] = [add_tenor(dt(2024, 5, 28), _, "MF", "tgt") for _ in vol_data["tenor"]]
vol_data
[10]:
tenor atm 25drr 25dbf 10drr 10dbf expiry
0 1w 4.535 -0.047 0.070 -0.097 0.252 2024-06-04
1 2w 5.168 -0.082 0.077 -0.165 0.240 2024-06-11
2 3w 5.127 -0.175 0.070 -0.260 0.233 2024-06-18
3 1m 5.195 -0.200 0.070 -0.295 0.235 2024-06-28
4 2m 5.237 -0.280 0.087 -0.535 0.295 2024-07-29
5 3m 5.257 -0.363 0.100 -0.705 0.350 2024-08-28
6 4m 5.598 -0.470 0.123 -0.915 0.422 2024-09-30
7 5m 5.776 -0.528 0.133 -1.032 0.463 2024-10-28
8 6m 5.920 -0.565 0.140 -1.110 0.490 2024-11-28
9 9m 6.010 -0.713 0.182 -1.405 0.645 2025-02-28
10 1y 6.155 -0.808 0.230 -1.585 0.795 2025-05-28
11 18m 6.408 -0.812 0.248 -1.588 0.868 2025-11-28
12 2y 6.525 -0.808 0.257 -1.580 0.900 2026-05-28
13 3y 6.718 -0.733 0.265 -1.450 0.890 2027-05-28
14 4y 7.025 -0.665 0.265 -1.310 0.885 2028-05-29
15 5y 7.260 -0.620 0.260 -1.225 0.890 2029-05-28
16 6y 7.508 -0.516 0.270 -0.989 0.940 2030-05-28
17 7y 7.680 -0.442 0.278 -0.815 0.975 2031-05-28
18 10y 8.115 -0.267 0.288 -0.510 1.035 2034-05-29
19 15y 8.652 -0.325 0.362 -0.400 1.195 2039-05-30
20 20y 8.651 -0.078 0.343 -0.303 1.186 2044-05-30
21 25y 8.650 -0.029 0.342 -0.218 1.178 2049-05-28
22 30y 8.650 0.014 0.341 -0.142 1.171 2054-05-28

A Surface is defined by given expiries and delta grdipoints. All vol values are initially set to 5.0, and will be calibrated by the Instruments.

[11]:
surface = FXDeltaVolSurface(
    eval_date=dt(2024, 5, 28),
    expiries=vol_data["expiry"],
    delta_indexes=[0.1, 0.25, 0.5, 0.75, 0.9],
    node_values=np.ones((23, 5))*5.0,
    delta_type="forward",
    id="eurusd_vol"
)

Define the instruments and their rates for 1Y or less:

[12]:
fx_args = dict(
    pair="eurusd",
    curves=[None, "eurusd", None, "sofr"],
    calendar="tgt",
    delivery_lag=2,
    payment_lag=2,
    eval_date=dt(2024, 5, 28),
    modifier="MF",
    premium_ccy="usd",
    vol="eurusd_vol",
)

instruments_le_1y, rates_le_1y, labels_le_1y = [], [], []
for row in range(11):
    instruments_le_1y.extend([
        FXStraddle(strike="atm_delta", expiry=vol_data["expiry"][row], delta_type="spot", **fx_args),
        FXRiskReversal(strike=["-25d", "25d"], expiry=vol_data["expiry"][row], delta_type="spot", **fx_args),
        FXBrokerFly(strike=["-25d", "atm_delta", "25d"], expiry=vol_data["expiry"][row], delta_type="spot", **fx_args),
        FXRiskReversal(strike=["-10d", "10d"], expiry=vol_data["expiry"][row], delta_type="spot", **fx_args),
        FXBrokerFly(strike=["-10d", "atm_delta", "10d"], expiry=vol_data["expiry"][row], delta_type="spot", **fx_args),
    ])
    rates_le_1y.extend([vol_data["atm"][row], vol_data["25drr"][row], vol_data["25dbf"][row], vol_data["10drr"][row], vol_data["10dbf"][row]])
    labels_le_1y.extend([f"atm_{row}", f"25drr_{row}", f"25dbf_{row}", f"10drr_{row}", f"10dbf_{row}"])

Also define the instruments and rates for greater than 1Y:

[13]:
instruments_gt_1y, rates_gt_1y, labels_gt_1y = [], [], []
for row in range(11, 23):
    instruments_gt_1y.extend([
        FXStraddle(strike="atm_delta", expiry=vol_data["expiry"][row], delta_type="forward", **fx_args),
        FXRiskReversal(strike=["-25d", "25d"], expiry=vol_data["expiry"][row], delta_type="forward", **fx_args),
        FXBrokerFly(strike=["-25d", "atm_delta", "25d"], expiry=vol_data["expiry"][row], delta_type="forward", **fx_args),
        FXRiskReversal(strike=["-10d", "10d"], expiry=vol_data["expiry"][row], delta_type="forward", **fx_args),
        FXBrokerFly(strike=["-10d", "atm_delta", "10d"], expiry=vol_data["expiry"][row], delta_type="forward", **fx_args),
    ])
    rates_gt_1y.extend([vol_data["atm"][row], vol_data["25drr"][row], vol_data["25dbf"][row], vol_data["10drr"][row], vol_data["10dbf"][row]])
    labels_gt_1y.extend([f"atm_{row}", f"25drr_{row}", f"25dbf_{row}", f"10drr_{row}", f"10dbf_{row}"])

Now solve for all calibrating instruments and rates.

[14]:
surface_solver = Solver(
    surfaces=[surface],
    instruments=instruments_le_1y+instruments_gt_1y,
    s=rates_le_1y+rates_gt_1y,
    instrument_labels=labels_le_1y+labels_gt_1y,
    fx=fxf,
    pre_solvers=[fx_solver],
    id="eurusd_vol"
)
SUCCESS: `func_tol` reached after 14 iterations (levenberg_marquardt), `f_val`: 9.091299248723455e-13, `time`: 5.7012s

3D Surface Plot and Cross-sectional Smiles#

[15]:
surface.plot()
[15]:
(<Figure size 640x480 with 1 Axes>, <Axes3D: >, None)
_images/z_eurusd_surface_26_1.png
[16]:
surface.smiles[0].plot(comparators=surface.smiles[1:])
[16]:
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x113b5efd0>,
  <matplotlib.lines.Line2D at 0x113b5f110>,
  <matplotlib.lines.Line2D at 0x113b5f250>,
  <matplotlib.lines.Line2D at 0x113b5f390>,
  <matplotlib.lines.Line2D at 0x113b5f4d0>,
  <matplotlib.lines.Line2D at 0x113b5f610>,
  <matplotlib.lines.Line2D at 0x113b5f750>,
  <matplotlib.lines.Line2D at 0x113b5f890>,
  <matplotlib.lines.Line2D at 0x113b5f9d0>,
  <matplotlib.lines.Line2D at 0x113b5fb10>,
  <matplotlib.lines.Line2D at 0x113b5fc50>,
  <matplotlib.lines.Line2D at 0x113b5fd90>,
  <matplotlib.lines.Line2D at 0x113b5fed0>,
  <matplotlib.lines.Line2D at 0x113ba0050>,
  <matplotlib.lines.Line2D at 0x113ba0190>,
  <matplotlib.lines.Line2D at 0x113ba02d0>,
  <matplotlib.lines.Line2D at 0x113ba0410>,
  <matplotlib.lines.Line2D at 0x113ba0550>,
  <matplotlib.lines.Line2D at 0x113ba0690>,
  <matplotlib.lines.Line2D at 0x113ba07d0>,
  <matplotlib.lines.Line2D at 0x113ba0910>,
  <matplotlib.lines.Line2D at 0x113ba0a50>,
  <matplotlib.lines.Line2D at 0x113ba0b90>])
_images/z_eurusd_surface_27_1.png

Calculating a generic option price#

[ ]: