FX Vol Surfaces#

Warning

FX volatility products in rateslib are not in stable status. Their API and/or object interactions may incur breaking changes in upcoming releases as they mature and other classes or pricing models may be added.

The rateslib.fx_volatility module includes classes for Smiles and Surfaces which can be used to price FX Options and FX Option Strategies.

rateslib.fx_volatility.FXDeltaVolSmile(...)

Create an FX Volatility Smile at a given expiry indexed by delta percent.

rateslib.fx_volatility.FXDeltaVolSurface(...)

Create an FX Volatility Surface parametrised by cross-sectional Smiles at different expiries.

Introduction and FX Volatility Smiles#

The FXDeltaVolSmile is parametrised by a series of (delta, vol) node points interpolated by a cubic spline. This interpolation is automatically constructed with knot sequences that adjust to the number of given nodes. One node will create a constant vol level, and two nodes will create a straight line gradient. More nodes (appropriately calibrated) will create a traditional smile shape.

An FXDeltaVolSmile must also be initialised with an eval_date which serves the same purpose as the initial node point on a Curve, and indicates ‘today’. There must also be an expiry, and options priced with this Smile must have an equivalent expiry or errors will be raised. Finally, the delta_type of the Smile must be specified so that its delta index is well defined.

Constructing a Smile#

The following data describes Instruments to calibrate the EURUSD FX volatility surface on 7th May 2024. We will take a cross-section of this data, at the 3-week expiry (28th May 2024), and create an FXDeltaVolSmile.

Since EURUSD is not premium adjusted and the premium currency is USD we will match the Smile with this definition and set it to a delta_type of ‘spot’, matching the market convention of these quoted instruments. Since we have 5 calibrating instruments we require 5 degrees of freedom.

In [1]: smile = FXDeltaVolSmile(
   ...:     nodes={
   ...:         0.10: 10.0,
   ...:         0.25: 10.0,
   ...:         0.50: 10.0,
   ...:         0.75: 10.0,
   ...:         0.90: 10.0,
   ...:     },
   ...:     eval_date=dt(2024, 5, 7),
   ...:     expiry=dt(2024, 5, 28),
   ...:     delta_type="spot",
   ...:     id="eurusd_3w_smile"
   ...: )
   ...: 

The above Smile is initialised as a flat vol at 10%. In order to calibrate it we need to create the pricing instruments, given in the market prices data table.

Since these Instruments are multi-currency derivatives an FXForwards framework also needs to be setup for pricing. We will do this simultaneously using other prevailing market data, i.e. local currency interest rates at 3.90% and 5.32%, and an FX Swap rate at 8.85 points.

# Define the interest rate curves for EUR, USD and X-Ccy basis
In [2]: eureur = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="tgt", id="eureur")

In [3]: eurusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, id="eurusd")

In [4]: usdusd = Curve({dt(2024, 5, 7): 1.0, dt(2024, 5, 30): 1.0}, calendar="nyc", id="usdusd")

# Create an FX Forward market with spot FX rate data
In [5]: fxf = FXForwards(
   ...:     fx_rates=FXRates({"eurusd": 1.0760}, settlement=dt(2024, 5, 9)),
   ...:     fx_curves={"eureur": eureur, "usdusd": usdusd, "eurusd": eurusd},
   ...: )
   ...: 

# Setup the Solver instrument calibration for rates Curves and vol Smiles
In [6]: option_args=dict(
   ...:     pair="eurusd", expiry=dt(2024, 5, 28), calendar="tgt", delta_type="spot",
   ...:     curves=[None, "eurusd", None, "usdusd"], vol="eurusd_3w_smile"
   ...: )
   ...: 

In [7]: solver = Solver(
   ...:     curves=[eureur, eurusd, usdusd, smile],
   ...:     instruments=[
   ...:         IRS(dt(2024, 5, 9), "3W", spec="eur_irs", curves="eureur"),
   ...:         IRS(dt(2024, 5, 9), "3W", spec="usd_irs", curves="usdusd"),
   ...:         FXSwap(dt(2024, 5, 9), "3W", pair="eurusd", curves=[None, "eurusd", None, "usdusd"]),
   ...:         FXStraddle(strike="atm_delta", **option_args),
   ...:         FXRiskReversal(strike=["-25d", "25d"], **option_args),
   ...:         FXRiskReversal(strike=["-10d", "10d"], **option_args),
   ...:         FXBrokerFly(strike=["-25d", "atm_delta", "25d"], **option_args),
   ...:         FXBrokerFly(strike=["-10d", "atm_delta", "10d"], **option_args),
   ...:     ],
   ...:     s=[3.90, 5.32, 8.85, 5.493, -0.157, -0.289, 0.071, 0.238],
   ...:     fx=fxf,
   ...: )
   ...: 
SUCCESS: `func_tol` reached after 11 iterations (levenberg_marquardt), `f_val`: 7.709670586017593e-13, `time`: 0.1450s

In [8]: smile.plot()
Out[8]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x11b920550>])

(Source code, png, hires.png, pdf)

_images/c_fx_smile-1_00_00.png

Rateslib Vol Smile#

FX Volatility Surfaces#

FX Surfaces in rateslib are collections of cross-sectional FX Smiles where:

  • each cross-sectional Smile will represent a Smile at that explicit expiry,

  • the delta type and the delta indexes on each cross-sectional Smile are the same,

  • each Smile has its own calibrated node values,

  • Smiles for expiries that do not pre-exist are generated with an interpolation scheme that uses linear total variance, which is equivalent to flat-forward volatility

To demonstrate this, we will use an example adapted from Iain Clark’s Foreign Exchange Option Pricing: A Practitioner’s Guide.

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.

# Setup the FXForward market...
In [9]: eur = Curve({dt(2009, 5, 3): 1.0, dt(2011, 5, 10): 1.0})

In [10]: usd = Curve({dt(2009, 5, 3): 1.0, dt(2011, 5, 10): 1.0})

In [11]: fxf = FXForwards(
   ....:     fx_rates=FXRates({"eurusd": 1.34664}, settlement=dt(2009, 5, 5)),
   ....:     fx_curves={"eureur": eur, "usdusd": usd, "eurusd": eur},
   ....: )
   ....: 

In [12]: 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.0021s

His Table 4.2 is shown below, which outlines the delta type of the used instruments at their respective tenors, and the ATM-delta straddle, the 25-delta broker-fly and the 25-delta risk reversal market volatility prices.

In [13]: data = DataFrame(
   ....:     data = [["spot", 18.25, 0.95, -0.6], ["forward", 17.677, 0.85, -0.562]],
   ....:     index=["1y", "2y"],
   ....:     columns=["Delta Type", "ATM", "25dBF", "25dRR"],
   ....: )
   ....: 

In [14]: data
Out[14]: 
   Delta Type   ATM  25dBF  25dRR
1y       spot 18.25   0.95  -0.60
2y    forward 17.68   0.85  -0.56

Constructing a Surface#

We will now create a Surface that will be calibrated by those given rates. The Surface is initialised at a flat 18% volatility.

In [15]: surface = FXDeltaVolSurface(
   ....:     eval_date=dt(2009, 5, 3),
   ....:     delta_indexes=[0.25, 0.5, 0.75],
   ....:     expiries=[dt(2010, 5, 3), dt(2011, 5, 3)],
   ....:     node_values=np.ones((2, 3))* 18.0,
   ....:     delta_type="forward",
   ....:     id="surface",
   ....: )
   ....: 

The calibration of the Surface requires a Solver that will iterate and update the surface node values until convergence with the given instrument rates.

Note

The Surface is parametrised by a ‘forward’ delta type but that the 1Y Instruments use ‘spot’. Internally this is all handled appropriately with necessary conversions, but it is the users responsibility to label the Surface and Instrument with the correct types. As Clark and others highlight “failing to take [correct delta types] into account introduces a mismatch - large enough to be relevant for calibration and pricing, but small enough that it may not be noticed at first”. Parametrising the Surface with a ‘forward’ delta type is the recommended choice because it is more standardised and the configuration of which delta types to use for the Instruments can be a separate consideration.

For performance reasons it is recommended to match unadjusted delta type Surfaces with calibrating Instruments that also have unadjusted delta types. And vice versa with premium adjusted delta types. However, rateslib has internal root solvers which can handle these cross-delta type specifications, although it degrades the performance of the Solver because the calculations are more difficult. Mixing ‘spot’ and ‘forward’ is not a difficult distinction to refactor and that does not cause performance degradation.

In [16]: fx_args_0 = dict(
   ....:     pair="eurusd",
   ....:     curves=[None, eur, None, usd],
   ....:     expiry=dt(2010, 5, 3),
   ....:     delta_type="spot",
   ....:     vol="surface",
   ....: )
   ....: 

In [17]: fx_args_1 = dict(
   ....:     pair="eurusd",
   ....:     curves=[None, eur, None, usd],
   ....:     expiry=dt(2011, 5, 3),
   ....:     delta_type="forward",
   ....:     vol="surface",
   ....: )
   ....: 

In [18]: solver = Solver(
   ....:     surfaces=[surface],
   ....:     instruments=[
   ....:         FXStraddle(strike="atm_delta", **fx_args_0),
   ....:         FXBrokerFly(strike=["-25d", "atm_delta", "25d"], **fx_args_0),
   ....:         FXRiskReversal(strike=["-25d", "25d"], **fx_args_0),
   ....:         FXStraddle(strike="atm_delta", **fx_args_1),
   ....:         FXBrokerFly(strike=["-25d", "atm_delta", "25d"], **fx_args_1),
   ....:         FXRiskReversal(strike=["-25d", "25d"], **fx_args_1),
   ....:     ],
   ....:     s=[18.25, 0.95, -0.6, 17.677, 0.85, -0.562],
   ....:     fx=fxf,
   ....: )
   ....: 
SUCCESS: `func_tol` reached after 10 iterations (levenberg_marquardt), `f_val`: 3.638632286230408e-12, `time`: 0.1182s

Clark’s Table 4.5 is replicated here. Note that due to using a different parametric form for Smiles (i.e. a natural cubic spline), inferring his FX forwards market rates, and not necessarily knowing the exact dates and holiday calendars, this produces minor deviations from his calculated values.

In [19]: data2
Out[19]: 
            25d Put  ATM Put  25d Call
(1y, k)        1.20     1.36      1.55
(1y, vol)     19.52    18.25     18.91
(18m, k)       1.17     1.37      1.60
(18m, vol)    19.04    17.87     18.46
(2y, k)        1.15     1.37      1.64
(2y, vol)     18.80    17.68     18.24

Plotting#

A full 3D surface plot is not yet available but a few cross-sections can be plotted together. Here, the three relevant Smiles from above are plotted.

In [20]: sm12 = surface.smiles[0]

In [21]: sm18 = surface.get_smile(dt(2010, 11, 3))

In [22]: sm24 = surface.smiles[1]

In [23]: sm12.plot(comparators=[sm18, sm24], labels=["1y", "18m", "2y"])
Out[23]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x11b99b110>,
  <matplotlib.lines.Line2D at 0x11b99b250>,
  <matplotlib.lines.Line2D at 0x11b99b390>])

(Source code, png, hires.png, pdf)

_images/c_fx_smile-2_00_00.png