FX Volatility#

Interbank standard conventions for quoting FX volatility products are quite varied. None-the-less, rateslib provides the most common definitions and products, all priced using the Black-76 model.

Currently, in v1.2.x, there is no ability to build a volatility Surface. However, there is a FXDeltaVolSmile for options with consistent expiries, and the ability to input vol as an explicit value, to pricing methods.

The following Instruments are currently available.

Inheritance diagram of rateslib.instruments.FXCall, rateslib.instruments.FXPut, rateslib.instruments.FXRiskReversal, rateslib.instruments.FXStraddle, rateslib.instruments.FXStrangle

rateslib.instruments.FXCall(*args, **kwargs)

Create an FX Call option.

rateslib.instruments.FXPut(*args, **kwargs)

Create an FX Put option.

rateslib.instruments.FXRiskReversal(*args[, ...])

Create an FX Risk Reversal option strategy.

rateslib.instruments.FXStraddle(*args[, ...])

Create an FX Straddle option strategy.

rateslib.instruments.FXStrangle(*args[, ...])

Create an FX Strangle option strategy.

FXForwards Market#

As multi-currency derivatives, FX Options rely on the existence of an FXForwards object, which is usually determined from non-volatility markets. See FX forwards. This will be used to forecast forward FX rates relevant to the pricing of an arbitrary FX Option.

For the purpose of this user guide page, we create such a market below.

# FXForwards for FXOptions
In [1]: eureur = Curve(
   ...:     {dt(2023, 3, 16): 1.0, dt(2023, 9, 16): 0.9851909811629752}, calendar="tgt", id="eureur"
   ...: )
   ...: 

In [2]: usdusd = Curve(
   ...:     {dt(2023, 3, 16): 1.0, dt(2023, 9, 16): 0.976009366603271}, calendar="nyc", id="usdusd"
   ...: )
   ...: 

In [3]: eurusd = Curve(
   ...:     {dt(2023, 3, 16): 1.0, dt(2023, 9, 16): 0.987092591908283}, id="eurusd"
   ...: )
   ...: 

In [4]: fxr = FXRates({"eurusd": 1.0615}, settlement=dt(2023, 3, 20))

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

In [6]: fxf._set_ad_order(1)

In [7]: fxf.swap("eurusd", [dt(2023, 3, 20), dt(2023, 6, 20)])  # should be 60.1 points
Out[7]: <Dual: 60.100000, (fx_eurusd, eurusd1, eurusd0, ...), [56.6, 5642.4, 5105.5, ...]>

Building and Pricing an Option#

Typing EURUSD Curncy OVML into Bloomberg will bring up the Bloomberg currency options pricer for Calls and Puts. This can be replicated with rateslib native functionality via FXCall and FXPut.

In [8]: fxc = FXCall(
   ...:     pair="eurusd",
   ...:     expiry=dt(2023, 6, 16),
   ...:     notional=20e6,
   ...:     strike=1.101,
   ...:     payment_lag=dt(2023, 3, 20),
   ...:     delivery_lag=2,
   ...:     calendar="tgt",
   ...:     modifier="mf",
   ...:     premium_ccy="usd",
   ...:     eval_date=NoInput(0),
   ...:     option_fixing=NoInput(0),
   ...:     premium=NoInput(0),
   ...:     delta_type="forward",
   ...:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd","usd")],
   ...:     spec=NoInput(0),
   ...: )
   ...: 

In [9]: fxc.rate(fx=fxf, vol=8.9)
Out[9]: <Dual: 69.378270, (fx_eurusd, eurusd1, eurusd0, ...), [2501.2, 1403.4, 1269.8, ...]>

In [10]: fxc.analytic_greeks(vol=8.9, fx=fxf)
Out[10]: 
{'delta': <Dual: 0.251754, (fx_eurusd, eurusd1, eurusd0, ...), [6.7, 3.8, 3.4, ...]>,
 'delta_eur': <Dual: 5035073.728707, (fx_eurusd, eurusd1, eurusd0, ...), [134493518.8, 75460012.4, 68278851.0, ...]>,
 'gamma': <Dual: 6.686817, (fx_eurusd, eurusd1, eurusd0, ...), [88.0, 49.4, 44.7, ...]>,
 'gamma_eur_1%': <Dual: 1427648.701688, (fx_eurusd, eurusd1, eurusd0, ...), [20136216.9, 11297787.4, 10222632.0, ...]>,
 'vega': <Dual: 0.168790, (fx_eurusd, eurusd1, eurusd0, ...), [2.5, 1.4, 1.3, ...]>,
 'vega_usd': <Dual: 33757.945511, (fx_eurusd, eurusd1, eurusd0, ...), [507939.8, 284988.8, 257867.8, ...]>,
 'vomma': <Dual: 0.905448, (fx_eurusd, eurusd1, eurusd0, ...), [-41.7, -23.4, -21.2, ...]>,
 'vanna': <Dual: 2.557600, (fx_eurusd, eurusd1, eurusd0, ...), [-39.5, -22.2, -20.0, ...]>,
 '_kega': <Dual: -0.369785, (fx_eurusd, eurusd1, eurusd0, ...), [11.7, 6.5, 5.9, ...]>,
 '_kappa': <Dual: -0.234725, (fx_eurusd, eurusd1, eurusd0, ...), [-6.4, -3.6, -3.3, ...]>,
 '__delta_type': 'forward',
 '__vol': 0.08900000000000001,
 '__strike': 1.101,
 '__bs76': <Dual: 0.006934, (fx_eurusd, eurusd1, eurusd0, ...), [0.2, 0.1, 0.1, ...]>,
 '__class': 'FXCallPeriod'}

The Call option priced above is partly unpriced becuase the premium is not directly specified. This means that rateslib will always assert the premium to be mid-market, based on the prevailing Curves, FXForwards and vol parameters supplied.

Changing some of the pricing parameters provides different prices. Rateslib is compared to Bloomberg’s OVML.

Premium currency:

usd

usd

usd

usd

eur

eur

eur

eur

Premium date:

20/3/23

20/3/23

20/6/23

20/6/23

20/3/23

20/3/23

20/6/23

20/6/23

Delta type:

Spot

Forward

Spot

Forward

Spot (pa)

Forward (pa)

Spot (pa)

Forward (pa)

Option rate (rateslib):

69.3783

69.3783

70.2258

70.2258

0.65359

0.65359

0.65785

0.65785

Option rate (BBG):

69.378

69.378

70.226

70.226

0.6536

0.6536

0.6578

0.6578

Delta % (rateslib):

0.25012

0.25175

0.25012

0.25175

0.24359

0.24518

0.24359

0.24518

Delta % (BBG):

0.25012

0.25175

0.25013

0.25176

0.24359

0.24518

0.24355

0.24518

Restrictions#

Rateslib currently allows the currency of the premium to only be either the domestic (LHS) or the foreign (RHS) currency of the FX pair of the option (which is also the default if none is specified).

If the currency is specified as foreign, then the pricing metric will be stated in pips and the percent delta calculations are unadjusted.

If the currency is stated as domestic, then the pricing metric is stated as percentage of notional and the percent delta calculations are premium adjusted.

Strikes given in Delta terms#

Commonly interbank Instruments are quoted in terms of delta values and the strikes are not explicitly stated. Suppose building a FXCall with a specified 25% delta.

In [11]: fxc = FXCall(
   ....:     pair="eurusd",
   ....:     expiry=dt(2023, 6, 16),
   ....:     notional=20e6,
   ....:     strike="25d",
   ....:     payment_lag=2,
   ....:     delivery_lag=2,
   ....:     calendar="tgt",
   ....:     premium_ccy="usd",
   ....:     delta_type="spot",
   ....: )
   ....: 

In [12]: fxc.rate(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd","usd")],
   ....:     fx=fxf,
   ....:     vol=8.9
   ....: )
   ....: 
Out[12]: <Dual: 70.180131, (fx_eurusd, eurusd1, eurusd0, ...), [2530.5, 1419.8, 1284.7, ...]>

When pricing functions are called, the strike on the option is implied from the vol and the delta value. This may require a root finding algorithm particularly if the vol is given as a Smile or a Surface. Relevant pricing parameters can be seen by viewing analytic_greeks(). The strike is also automatically assigned, temporarily, to the attached FXCallPeriod

In [13]: fxc.analytic_greeks(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=8.9
   ....: )
   ....: 
Out[13]: 
{'delta': <Dual: 0.250000, (fx_eurusd, eurusd1, eurusd0, ...), [6.7, 3.9, 3.3, ...]>,
 'delta_eur': <Dual: 5000000.000000, (fx_eurusd, eurusd1, eurusd0, ...), [133587823.4, 77484546.1, 65319053.0, ...]>,
 'gamma': <Dual: 6.679391, (fx_eurusd, eurusd1, eurusd0, ...), [88.0, 56.1, 38.0, ...]>,
 'gamma_eur_1%': <Dual: 1418034.745178, (fx_eurusd, eurusd1, eurusd0, ...), [20012286.1, 11915312.4, 8063490.3, ...]>,
 'vega': <Dual: 0.168746, (fx_eurusd, eurusd1, eurusd0, ...), [2.5, 1.4, 1.3, ...]>,
 'vega_usd': <Dual: 33749.129781, (fx_eurusd, eurusd1, eurusd0, ...), [508084.8, 285070.2, 257941.4, ...]>,
 'vomma': <Dual: 0.906235, (fx_eurusd, eurusd1, eurusd0, ...), [-41.7, -23.4, -21.1, ...]>,
 'vanna': <Dual: 2.541766, (fx_eurusd, eurusd1, eurusd0, ...), [-39.2, -20.7, -21.2, ...]>,
 '_kega': <Dual: -0.370007, (fx_eurusd, eurusd1, eurusd0, ...), [11.7, 6.5, 5.9, ...]>,
 '_kappa': <Dual: -0.234606, (fx_eurusd, eurusd1, eurusd0, ...), [-6.4, -3.6, -3.3, ...]>,
 '__delta_type': 'spot',
 '__vol': 0.08900000000000001,
 '__strike': 1.1010192011340847,
 '__bs76': <Dual: 0.006930, (fx_eurusd, eurusd1, eurusd0, ...), [0.2, 0.1, 0.1, ...]>,
 '__class': 'FXCallPeriod'}

In [14]: fxc.periods[0].strike
Out[14]: 1.1010192011340847

With altered pricing parameters, the Option strike will adapt accordingly to maintain the 25% spot delta calculation.

In [15]: fxc.rate(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd","usd")],
   ....:     fx=fxf,
   ....:     vol=10.0,   #  <- A different vol will imply a different strike to maintain the same delta
   ....: )
   ....: 
Out[15]: <Dual: 78.639814, (fx_eurusd, eurusd1, eurusd0, ...), [2530.5, 1419.8, 1284.7, ...]>

In [16]: fxc.analytic_greeks(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=10.0
   ....: )
   ....: 
Out[16]: 
{'delta': <Dual: 0.250000, (fx_eurusd, eurusd1, eurusd0, ...), [5.9, 3.5, 2.9, ...]>,
 'delta_eur': <Dual: 5000000.000000, (fx_eurusd, eurusd1, eurusd0, ...), [118893162.8, 69239842.0, 57858957.2, ...]>,
 'gamma': <Dual: 5.944658, (fx_eurusd, eurusd1, eurusd0, ...), [69.1, 44.8, 29.1, ...]>,
 'gamma_eur_1%': <Dual: 1262050.923208, (fx_eurusd, eurusd1, eurusd0, ...), [15851731.8, 9505382.0, 6181870.2, ...]>,
 'vega': <Dual: 0.168746, (fx_eurusd, eurusd1, eurusd0, ...), [2.3, 1.3, 1.2, ...]>,
 'vega_usd': <Dual: 33749.129781, (fx_eurusd, eurusd1, eurusd0, ...), [455692.8, 255674.7, 231343.4, ...]>,
 'vomma': <Dual: 0.812787, (fx_eurusd, eurusd1, eurusd0, ...), [-33.0, -18.5, -16.8, ...]>,
 'vanna': <Dual: 2.279667, (fx_eurusd, eurusd1, eurusd0, ...), [-30.8, -16.1, -16.8, ...]>,
 '_kega': <Dual: -0.371474, (fx_eurusd, eurusd1, eurusd0, ...), [10.4, 5.8, 5.3, ...]>,
 '_kappa': <Dual: -0.232923, (fx_eurusd, eurusd1, eurusd0, ...), [-5.7, -3.2, -2.9, ...]>,
 '__delta_type': 'spot',
 '__vol': 0.1,
 '__strike': 1.1053863932903523,
 '__bs76': <Dual: 0.007765, (fx_eurusd, eurusd1, eurusd0, ...), [0.2, 0.1, 0.1, ...]>,
 '__class': 'FXCallPeriod'}

In [17]: fxc.periods[0].strike
Out[17]: 1.1053863932903523

Straddles#

An FXStraddle is the most frequently traded instrument for outright exposure to volatility. Straddles are defined by a single strike, which can be a defined numeric value (for a ‘struck’ deal), or an or associated value, e.g. “atm_delta”, “atm_forward” or “atm_spot”.

The default pricing metric for an FX Straddle is vol points.

In [18]: fxstr = FXStraddle(
   ....:     pair="eurusd",
   ....:     expiry=dt(2023, 6, 16),
   ....:     notional=20e6,
   ....:     strike="atm_delta",
   ....:     payment_lag=2,
   ....:     delivery_lag=2,
   ....:     calendar="tgt",
   ....:     premium_ccy="usd",
   ....:     delta_type="spot",
   ....: )
   ....: 

In [19]: fxstr.rate(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=8.9,
   ....: )
   ....: 
Out[19]: 8.9

In [20]: fxstr.analytic_greeks(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=8.9,
   ....: )
   ....: 
Out[20]: 
{'gamma': <Dual: 16.713274, (fx_eurusd, eurusd1, eurusd0, ...), [-15.7, 8.1, -24.7, ...]>,
 '_kappa': <Dual: 0.035191, (fx_eurusd, eurusd1, eurusd0, ...), [-16.6, -9.3, -8.4, ...]>,
 'vega_usd': <Dual: 84447.582646, (fx_eurusd, eurusd1, eurusd0, ...), [79555.0, 44635.7, 40388.0, ...]>,
 'vomma': <Dual: -0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [-4.5, -2.5, -2.3, ...]>,
 'vega': <Dual: 0.422238, (fx_eurusd, eurusd1, eurusd0, ...), [0.4, 0.2, 0.2, ...]>,
 'delta': <Dual: 0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [16.7, 9.4, 8.5, ...]>,
 'gamma_eur_1%': <Dual: 3548227.972514, (fx_eurusd, eurusd1, eurusd0, ...), [-0.0, 1719168.6, -5245206.6, ...]>,
 'vanna': <Dual: 0.397985, (fx_eurusd, eurusd1, eurusd0, ...), [-187.8, -105.2, -95.5, ...]>,
 '_kega': <Dual: 0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [22.6, 12.7, 11.5, ...]>,
 'delta_eur': <Dual: 0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [334265470.8, 187545666.1, 169697859.6, ...]>,
 '__class': 'FXOptionStrat',
 '__options': [{'delta': <Dual: -0.496763, (fx_eurusd, eurusd1, eurusd0, ...), [8.4, 4.4, 4.5, ...]>,
   'delta_eur': <Dual: -9935253.353127, (fx_eurusd, eurusd1, eurusd0, ...), [167132735.4, 88740248.8, 89816556.5, ...]>,
   'gamma': <Dual: 8.356637, (fx_eurusd, eurusd1, eurusd0, ...), [-7.9, 4.0, -12.4, ...]>,
   'gamma_eur_1%': <Dual: 1774113.986257, (fx_eurusd, eurusd1, eurusd0, ...), [-0.0, 859584.3, -2622603.3, ...]>,
   'vega': <Dual: 0.211119, (fx_eurusd, eurusd1, eurusd0, ...), [0.2, 0.1, 0.1, ...]>,
   'vega_usd': <Dual: 42223.791323, (fx_eurusd, eurusd1, eurusd0, ...), [39777.5, 22317.9, 20194.0, ...]>,
   'vomma': <Dual: -0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [-2.2, -1.3, -1.1, ...]>,
   'vanna': <Dual: 0.198992, (fx_eurusd, eurusd1, eurusd0, ...), [-93.9, -52.6, -47.8, ...]>,
   '_kega': <Dual: 0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [11.3, 6.3, 5.7, ...]>,
   '_kappa': <Dual: 0.511301, (fx_eurusd, eurusd1, eurusd0, ...), [-8.3, -4.7, -4.2, ...]>,
   '__delta_type': 'spot',
   '__vol': 0.08900000000000001,
   '__strike': 1.0685761878290885,
   '__bs76': <Dual: 0.019328, (fx_eurusd, eurusd1, eurusd0, ...), [-0.5, -0.3, -0.3, ...]>,
   '__class': 'FXPutPeriod'},
  {'delta': <Dual: 0.496763, (fx_eurusd, eurusd1, eurusd0, ...), [8.4, 4.9, 4.0, ...]>,
   'delta_eur': <Dual: 9935253.353127, (fx_eurusd, eurusd1, eurusd0, ...), [167132735.4, 98805417.4, 79881303.1, ...]>,
   'gamma': <Dual: 8.356637, (fx_eurusd, eurusd1, eurusd0, ...), [-7.9, 4.0, -12.4, ...]>,
   'gamma_eur_1%': <Dual: 1774113.986257, (fx_eurusd, eurusd1, eurusd0, ...), [-0.0, 859584.3, -2622603.3, ...]>,
   'vega': <Dual: 0.211119, (fx_eurusd, eurusd1, eurusd0, ...), [0.2, 0.1, 0.1, ...]>,
   'vega_usd': <Dual: 42223.791323, (fx_eurusd, eurusd1, eurusd0, ...), [39777.5, 22317.9, 20194.0, ...]>,
   'vomma': <Dual: -0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [-2.2, -1.3, -1.1, ...]>,
   'vanna': <Dual: 0.198992, (fx_eurusd, eurusd1, eurusd0, ...), [-93.9, -52.6, -47.8, ...]>,
   '_kega': <Dual: 0.000000, (fx_eurusd, eurusd1, eurusd0, ...), [11.3, 6.3, 5.7, ...]>,
   '_kappa': <Dual: -0.476110, (fx_eurusd, eurusd1, eurusd0, ...), [-8.3, -4.7, -4.2, ...]>,
   '__delta_type': 'spot',
   '__vol': 0.08900000000000001,
   '__strike': 1.0685761878290885,
   '__bs76': <Dual: 0.018276, (fx_eurusd, eurusd1, eurusd0, ...), [0.5, 0.3, 0.3, ...]>,
   '__class': 'FXCallPeriod'}],
 '__delta_type': 'spot'}

In [21]: fxstr.plot_payoff(
   ....:     range=[1.025, 1.11],
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=8.9,
   ....: )
   ....: 
Out[21]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f8670a2d310>])

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

_images/e_fx_volatility-1.png

Risk Reversals#

FXRiskReversal are frequently traded products and often used in calibrating a volatility Surface or Smile.

RiskReversals need to be specified by two different strike values; a lower and a higher strike. These can be entered in delta terms. Pricing also allows two different vol inputs if a volatility Surface or Smile is not given.

The default pricing metric for a RiskReversal is ‘vol’ which calculates the difference in volatility quotations for each option.

In [22]: fxrr = FXRiskReversal(
   ....:     pair="eurusd",
   ....:     expiry=dt(2023, 6, 16),
   ....:     notional=20e6,
   ....:     strike=("-25d", "25d"),
   ....:     payment_lag=2,
   ....:     delivery_lag=2,
   ....:     calendar="tgt",
   ....:     premium_ccy="usd",
   ....:     delta_type="spot",
   ....: )
   ....: 

In [23]: fxrr.rate(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=[10.15, 8.9]
   ....: )
   ....: 
Out[23]: -1.25

In [24]: fxrr.plot_payoff(
   ....:     range=[1.025, 1.11],
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=[10.15, 8.9]
   ....: )
   ....: 
Out[24]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f8670ab0e90>])

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

_images/e_fx_volatility-2.png

Strangles#

The other common Instrument combination for calibrating Surfaces and Smiles is an FXStrangle. Again, the strangle requires two strike inputs, which can be input in delta terms.

The default pricing metric for a strangle is ‘single_vol’, which quotes a single volatility value used to price the strike and premium for each option. Rateslib uses an iteration to calculate this (see rate()) from a Surface or Smile.

In [25]: fxstg = FXStrangle(
   ....:     pair="eurusd",
   ....:     expiry=dt(2023, 6, 16),
   ....:     notional=20e6,
   ....:     strike=("-25d", "25d"),
   ....:     payment_lag=2,
   ....:     delivery_lag=2,
   ....:     calendar="tgt",
   ....:     premium_ccy="usd",
   ....:     delta_type="spot",
   ....: )
   ....: 

In [26]: fxstg.rate(
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=[10.15, 8.9]
   ....: )
   ....: 
Out[26]: <Dual: 9.533895, (fx_eurusd, eurusd1, eurusd0, ...), [-8.1, -4.6, -4.1, ...]>

In [27]: fxstg.plot_payoff(
   ....:     range=[1.025, 1.11],
   ....:     curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")],
   ....:     fx=fxf,
   ....:     vol=9.533895,
   ....: )
   ....: 
Out[27]: 
(<Figure size 640x480 with 1 Axes>,
 <Axes: >,
 [<matplotlib.lines.Line2D at 0x7f867098d5d0>])

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

_images/e_fx_volatility-3.png