diff --git a/docs/source/c_fx_smile.rst b/docs/source/c_fx_smile.rst index d01d19d1..b45ef372 100644 --- a/docs/source/c_fx_smile.rst +++ b/docs/source/c_fx_smile.rst @@ -167,10 +167,10 @@ i.e. local currency interest rates at 3.90% and 5.32%, and an FX Swap rate at 8. 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), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), ], s=[3.90, 5.32, 8.85, 5.493, -0.157, -0.289, 0.071, 0.238], fx=fxf, @@ -225,10 +225,10 @@ i.e. local currency interest rates at 3.90% and 5.32%, and an FX Swap rate at 8. IRS(dt(2024, 5, 9), "3W", spec="usd_irs", curves="usdusd"), FXSwap(dt(2024, 5, 9), "3W", currency="eur", leg2_currency="usd", 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), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), ], s=[3.90, 5.32, 8.85, 5.493, -0.157, -0.289, 0.071, 0.238], fx=fxf, @@ -361,11 +361,11 @@ node values until convergence with the given instrument rates. 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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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, @@ -480,11 +480,11 @@ Three relevant cross-sectional *Smiles* from above are plotted. 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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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, @@ -553,11 +553,11 @@ Alternative a 3D surface plot can also be shown. 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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **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, diff --git a/docs/source/e_fx_volatility.rst b/docs/source/e_fx_volatility.rst index 9f0e2a48..9707dca0 100644 --- a/docs/source/e_fx_volatility.rst +++ b/docs/source/e_fx_volatility.rst @@ -505,7 +505,7 @@ The default pricing ``metric`` is *'single_vol'* which calculates the single vol pair="eurusd", expiry=dt(2023, 6, 16), notional=[20e6, -13.5e6], - strike=("-25d", "atm_delta", "25d"), + strike=(("-25d", "25d"), "atm_delta"), payment_lag=2, delivery_lag=2, calendar="tgt|fed", @@ -515,7 +515,7 @@ The default pricing ``metric`` is *'single_vol'* which calculates the single vol fxbf.rate( curves=[None, fxf.curve("eur", "usd"), None, fxf.curve("usd", "usd")], fx=fxf, - vol=[10.15, 7.5, 8.9] + vol=[[10.15, 8.9], 7.5] ) fxbf.plot_payoff( range=[1.000, 1.150], @@ -549,7 +549,7 @@ The default pricing ``metric`` is *'single_vol'* which calculates the single vol pair="eurusd", expiry=dt(2023, 6, 16), notional=[20e6, -13.5e6], - strike=("-25d", "atm_delta", "25d"), + strike=(("-25d", "25d"), "atm_delta"), payment_lag=2, delivery_lag=2, calendar="tgt|fed", diff --git a/docs/source/i_whatsnew.rst b/docs/source/i_whatsnew.rst index fc0a594f..a4b71bd2 100644 --- a/docs/source/i_whatsnew.rst +++ b/docs/source/i_whatsnew.rst @@ -15,6 +15,18 @@ email contact, see `rateslib `_. 1.7.0 (No Release Date) **************************** +The key theme for 1.7.0 was to add Python type hinting to the entire codebase, and adding +``mypy`` CI checks to the development process. This resulted in +a number of refactorisations which may have changed the way some argument inputs should be +structured. + +*FXOptions* which were added and listed in beta status since v1.2.0, have seen the largest +changes and have now been moved out beta status. + +Internally, caching and state management were improved to provide more safety, preventing users +inadvertently mutating objects without the *Solver's* *Gradients* being updated. All mutable +objects now have specific methods to allow *updates*. + .. list-table:: :widths: 25 75 :header-rows: 1 @@ -105,6 +117,14 @@ email contact, see `rateslib `_. - The internal data objects for *FXOption* pricing are restructured to conform to more strict data typing. (`642 `_) + * - Refactor + - :red:`Minor Breaking Change!` The argument inputs for *FXOptionStrat* types, such + as :class:`~rateslib.instruments.FXRiskReversal`, :class:`~rateslib.instruments.FXStraddle`, + :class:`~rateslib.instruments.FXStrangle` and :class:`~rateslib.instruments.FXBrokerFly`, + may have changed to conform to a more generalised structure. This may include the + specification of their ``premium``, ``strike``, ``notional`` and ``vol`` inputs. Review + their updated documentation for details. + (Mostly `643 `_) 1.6.0 (30th November 2024) **************************** diff --git a/docs/source/z_eurusd_surface.ipynb b/docs/source/z_eurusd_surface.ipynb index 9124014b..98390513 100644 --- a/docs/source/z_eurusd_surface.ipynb +++ b/docs/source/z_eurusd_surface.ipynb @@ -355,10 +355,10 @@ "for row in range(11):\n", " instruments_le_1y.extend([\n", " FXStraddle(strike=\"atm_delta\", expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", - " FXRiskReversal(strike=[\"-25d\", \"25d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", - " FXBrokerFly(strike=[\"-25d\", \"atm_delta\", \"25d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", - " FXRiskReversal(strike=[\"-10d\", \"10d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", - " FXBrokerFly(strike=[\"-10d\", \"atm_delta\", \"10d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", + " FXRiskReversal(strike=(\"-25d\", \"25d\"), expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", + " FXBrokerFly(strike=((\"-25d\", \"25d\"), \"atm_delta\"), expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", + " FXRiskReversal(strike=(\"-10d\", \"10d\"), expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", + " FXBrokerFly(strike=((\"-10d\", \"10d\"), \"atm_delta\"), expiry=vol_data[\"expiry\"][row], delta_type=\"spot\", **fx_args),\n", " ])\n", " rates_le_1y.extend([vol_data[\"atm\"][row], vol_data[\"25drr\"][row], vol_data[\"25dbf\"][row], vol_data[\"10drr\"][row], vol_data[\"10dbf\"][row]])\n", " labels_le_1y.extend([f\"atm_{row}\", f\"25drr_{row}\", f\"25dbf_{row}\", f\"10drr_{row}\", f\"10dbf_{row}\"])" @@ -383,10 +383,10 @@ "for row in range(11, 23):\n", " instruments_gt_1y.extend([\n", " FXStraddle(strike=\"atm_delta\", expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", - " FXRiskReversal(strike=[\"-25d\", \"25d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", - " FXBrokerFly(strike=[\"-25d\", \"atm_delta\", \"25d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", - " FXRiskReversal(strike=[\"-10d\", \"10d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", - " FXBrokerFly(strike=[\"-10d\", \"atm_delta\", \"10d\"], expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", + " FXRiskReversal(strike=(\"-25d\", \"25d\"), expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", + " FXBrokerFly(strike=((\"-25d\", \"25d\"), \"atm_delta\"), expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", + " FXRiskReversal(strike=(\"-10d\", \"10d\"), expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", + " FXBrokerFly(strike=((\"-10d\", \"10d\"), \"atm_delta\"), expiry=vol_data[\"expiry\"][row], delta_type=\"forward\", **fx_args),\n", " ])\n", " rates_gt_1y.extend([vol_data[\"atm\"][row], vol_data[\"25drr\"][row], vol_data[\"25dbf\"][row], vol_data[\"10drr\"][row], vol_data[\"10dbf\"][row]])\n", " labels_gt_1y.extend([f\"atm_{row}\", f\"25drr_{row}\", f\"25dbf_{row}\", f\"10drr_{row}\", f\"10dbf_{row}\"])" @@ -445,22 +445,6 @@ "source": [ "surface.smiles[0].plot(comparators=surface.smiles[1:])" ] - }, - { - "cell_type": "markdown", - "id": "7d447f20-17cf-48e0-88cf-2cb10421dda8", - "metadata": {}, - "source": [ - "## Calculating a generic option price" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "cd193993-a065-402a-9654-0faf0eb9e676", - "metadata": {}, - "outputs": [], - "source": [] } ], "metadata": { diff --git a/pyproject.toml b/pyproject.toml index 3992fe75..10a8c893 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,7 +151,7 @@ packages = [ ] exclude = [ # "/instruments/bonds/securities.py", - "/instruments/fx_volatility/strategies.py", + # "/instruments/fx_volatility/strategies.py", # "/instruments/generics.py", # "/instruments/rates/inflation.py", "solver.py", diff --git a/python/rateslib/instruments/fx_volatility/strategies.py b/python/rateslib/instruments/fx_volatility/strategies.py index 992a167f..35e8f9b5 100644 --- a/python/rateslib/instruments/fx_volatility/strategies.py +++ b/python/rateslib/instruments/fx_volatility/strategies.py @@ -1,17 +1,22 @@ from __future__ import annotations +from collections.abc import Sequence from typing import TYPE_CHECKING from pandas import DataFrame +from rateslib.curves._parsers import _validate_obj_not_no_input from rateslib.default import NoInput, _drb -from rateslib.dual import dual_log -from rateslib.fx_volatility import FXDeltaVolSurface, FXVolObj +from rateslib.dual import Dual, Dual2, Variable, dual_log +from rateslib.dual.utils import _dual_float +from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface from rateslib.instruments.fx_volatility.vanilla import FXCall, FXOption, FXPut from rateslib.instruments.utils import ( _get_curves_fx_and_base_maybe_from_solver, _get_fxvol_maybe_from_solver, + _validate_fx_as_forwards, ) +from rateslib.periods import FXOptionPeriod from rateslib.splines import evaluate if TYPE_CHECKING: @@ -19,30 +24,46 @@ FX_, NPV, Any, + Curve, Curves_, DualTypes, + DualTypes_, + FXForwards, + FXOptionPeriod, + FXVol, + FXVolOption, + FXVolStrat_, + ListFXVol_, Solver_, + datetime, str_, ) class FXOptionStrat: """ - Create a custom option strategy composed of a list of :class:`~rateslib.instruments.FXOption`. + Create a custom option strategy composed of a list of :class:`~rateslib.instruments.FXOption`, + or other :class:`~rateslib.instruments.FXOptionStrat` objects. Parameters ---------- options: list - The *FXOptions* which make up the strategy. + The *FXOptions* or *FXOptionStrats* which make up the strategy. rate_weight: list The multiplier for the *'pips_or_%'* metric that sums the options to a final *rate*. + E.g. A *RiskReversal* uses [-1.0, 1.0] for a sale and a purchase. + E.g. A *Straddle* uses [1.0, 1.0] for summing two premium purchases. rate_weight_vol: list The multiplier for the *'vol'* metric that sums the options to a final *rate*. + E.g. A *RiskReversal* uses [-1.0, 1.0] to obtain the vol difference between two options. + E.g. A *Straddle* uses [0.5, 0.5] to obtain the volatility at the strike of each option. """ _greeks: dict[str, Any] = {} _strat_elements: tuple[FXOption | FXOptionStrat, ...] - periods: list[FXOption] + vol: FXVolStrat_ + curves: Curves_ + kwargs: dict[str, Any] def __init__( self, @@ -60,6 +81,73 @@ def __init__( "`rate_weight` and `rate_weight_vol` must have same length as `options`.", ) + @property + def _vol_agg(self) -> FXVolStrat_: + """Aggregate the `vol` metric on contained options into a container""" + + def vol_attr(obj: FXOption | FXOptionStrat) -> FXVolStrat_: + if isinstance(obj, FXOption): + return obj.vol + else: + return obj._vol_agg + + return [vol_attr(obj) for obj in self._strat_elements] + + def _parse_vol_sequence(self, vol: FXVolStrat_) -> ListFXVol_: + """ + This function exists to determine a recursive list + + This function must exist to parse an input sequence of given vol values for each + *Instrument* in the strategy to a list that will be applied sequentially to value + each of those *Instruments*. + + If a sub-sequence, e.g BrokerFly is a strategy of strategies then this function will + be repeatedly called within each strategy. + """ + if isinstance( + vol, + str | float | Dual | Dual2 | Variable | FXDeltaVolSurface | FXDeltaVolSmile | NoInput, + ): + ret: ListFXVol_ = [] + for obj in self.periods: + if isinstance(obj, FXOptionStrat): + ret.append(obj._parse_vol_sequence(vol)) + else: + ret.append(vol) + return ret + elif isinstance(vol, Sequence): + if len(vol) != len(self.periods): + raise ValueError( + "`vol` as sequence must have same length as its contained " + f"strategy elements: {len(self.periods)}" + ) + else: + ret = [] + for obj, vol_ in zip(self.periods, vol, strict=True): + if isinstance(obj, FXOptionStrat): + ret.append(obj._parse_vol_sequence(vol_)) + else: + assert isinstance(vol_, str) or not isinstance(vol_, Sequence) # noqa: S101 + ret.append(vol_) + return ret + + def _get_fxvol_maybe_from_solver_recursive( + self, vol: FXVolStrat_, solver: Solver_ + ) -> ListFXVol_: + """ + Function must parse a ``vol`` input in combination with ``vol_agg`` attribute to yield + a Sequence of vols applied to the various levels of associated *Options* or *OptionStrats* + """ + vol_ = self._parse_vol_sequence(vol) # vol_ is properly nested for one vol per option + ret: ListFXVol_ = [] + for obj, vol__ in zip(self.periods, vol_, strict=False): + if isinstance(obj, FXOptionStrat): + ret.append(obj._get_fxvol_maybe_from_solver_recursive(vol__, solver)) + else: + assert isinstance(vol__, str) or not isinstance(vol__, Sequence) # noqa: S101 + ret.append(_get_fxvol_maybe_from_solver(vol_attr=obj.vol, vol=vol__, solver=solver)) + return ret + @property def periods(self) -> list[FXOption | FXOptionStrat]: return list(self._strat_elements) @@ -67,48 +155,67 @@ def periods(self) -> list[FXOption | FXOptionStrat]: def __repr__(self) -> str: return f"" - def _vol_as_list(self, vol, solver): - """Standardise a vol input over the list of periods""" - if not isinstance(vol, list | tuple): - vol = [vol] * len(self.periods) - return [_get_fxvol_maybe_from_solver(self.vol, _, solver) for _ in vol] - def rate( self, curves: Curves_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - vol: list[float] | float = NoInput(0), + vol: FXVolStrat_ = NoInput(0), metric: str_ = NoInput(0), # "pips_or_%", ) -> DualTypes: """ - Return the mid-market rate of an option strategy. + Return various pricing metrics of the *FXOptionStrat*. - See :meth:`~rateslib.instruments.FXOption.rate`. - """ - curves, fx, base = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.kwargs["pair"][3:], - ) - vol = self._vol_as_list(vol, solver) + Parameters + ---------- + curves : list of Curve + Curves for discounting cashflows. List follows the structure used by IRDs and + should be given as: + `[None, Curve for ccy1, None, Curve for ccy2]` + solver : Solver, optional + The numerical :class:`Solver` that constructs *Curves*, *Smiles* or *Surfaces* from + calibrating instruments. + fx: FXForwards + The object to project the relevant forward and spot FX rates. + base: str, optional + Not used by the `rate` method. + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface, or Sequence of such, optional + The volatility used in calculation. See notes. + metric: str in {"pips_or_%", "vol", "premium"}, optional + The pricing metric type to return. See notes for + :meth:`FXOption.rate ` + + Returns + ------- + float, Dual, Dual2 + + Notes + ----- + If the ``vol`` option is given as a Sequence of volatility values, these should be + ordered according to each *FXOption* or *FXOptionStrat* contained on the *Instrument*. + For nested *FXOptionStrat* use nested sequences. + + For example, for an *FXBrokerFly*, which contains an *FXStrangle* and an *FXStraddle*, + ``vol`` may be entered as `[[12, 11], 10]` which are values of 12% and 11% on the + *Strangle* options and 10% for the two *Straddle* options, or just `"fx_surface1"` which + will determine all volatilities from an FXDeltaVolSurface associated with a *Solver*, + with id: "fx_surface1". - metric = metric if metric is not NoInput.blank else self.kwargs["metric"] + """ + vol_: ListFXVol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) + metric_: str = _drb(self.kwargs["metric"], metric) map_ = { "pips_or_%": self.rate_weight, "vol": self.rate_weight_vol, "premium": [1.0] * len(self.periods), "single_vol": self.rate_weight_vol, } - weights = map_[metric] + weights = map_[metric_] - _ = 0.0 - for option, vol_, weight in zip(self.periods, vol, weights, strict=False): - _ += option.rate(curves, solver, fx, base, vol_, metric) * weight + _: DualTypes = 0.0 + for option, vol__, weight in zip(self.periods, vol_, weights, strict=True): + _ += option.rate(curves, solver, fx, base, vol__, metric_) * weight # type: ignore[arg-type] return _ def npv( @@ -118,22 +225,52 @@ def npv( fx: FX_ = NoInput(0), base: str_ = NoInput(0), local: bool = False, - vol: list[float] | float = NoInput(0), + vol: FXVolStrat_ = NoInput(0), ) -> NPV: - if not isinstance(vol, list): - vol = [vol] * len(self.periods) + """ + Return the NPV of the *FXOptionStrat*. + + Parameters + ---------- + curves : list of Curve + Curves for discounting cashflows. List follows the structure used by IRDs and + should be given as: `[None, Curve for ccy1, None, Curve for ccy2]` + solver : Solver, optional + The numerical :class:`Solver` that constructs *Curves*, *Smiles* or *Surfaces* from + calibrating instruments. + fx: FXForwards + The object to project the relevant forward and spot FX rates. + base: str, optional + 3-digit currency in which to express values. + local: bool, optional + If `True` will return a dict identifying NPV by local currencies on each + period. + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface, or Sequence of such, optional + The volatility used in calculation. + + Returns + ------- + float, Dual, Dual2 + + Notes + ----- + If the ``vol`` option is given as a Sequence of volatility values, these should be + ordered according to each *FXOption* or *FXOptionStrat* contained on the *Instrument*. + For nested *FXOptionStrat* use nested sequences. + """ + vol_: ListFXVol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) results = [ - option.npv(curves, solver, fx, base, local, vol_) - for (option, vol_) in zip(self.periods, vol, strict=False) + option.npv(curves, solver, fx, base, local, vol__) # type: ignore[arg-type] + for (option, vol__) in zip(self.periods, vol_, strict=True) ] if local: - _ = DataFrame(results).fillna(0.0) - _ = _.sum() - _ = _.to_dict() + df = DataFrame(results).fillna(0.0) + df_sum = df.sum() + _: NPV = df_sum.to_dict() else: - _ = sum(results) + _ = sum(results) # type: ignore[arg-type] return _ def _plot_payoff( @@ -144,14 +281,13 @@ def _plot_payoff( fx: FX_ = NoInput(0), base: str_ = NoInput(0), local: bool = False, - vol: list[float] | float = NoInput(0), - ): - if not isinstance(vol, list): - vol = [vol] * len(self.periods) + vol: FXVolStrat_ = NoInput(0), + ) -> tuple[Any, Any]: + vol_: ListFXVol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) y = None - for option, vol_ in zip(self.periods, vol, strict=False): - x, y_ = option._plot_payoff(window, curves, solver, fx, base, local, vol_) + for option, vol__ in zip(self.periods, vol_, strict=True): + x, y_ = option._plot_payoff(window, curves, solver, fx, base, local, vol__) # type: ignore[arg-type] if y is None: y = y_ else: @@ -159,52 +295,42 @@ def _plot_payoff( return x, y - def _set_notionals(self, notional): - """ - Set the notionals on each option period. Mainly used by Brokerfly for vega neutral - strangle and straddle. - """ - for option in self.periods: - option.periods[0].notional = notional - def analytic_greeks( self, curves: Curves_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - local: bool = False, - vol: float = NoInput(0), - ): + vol: FXVolStrat_ = NoInput(0), + ) -> dict[str, Any]: """ - Return various pricing metrics of the *FX Option*. + Return aggregated greeks of the *FXOptionStrat*. Parameters ---------- curves : list of Curve - Curves for discounting cashflows. List follows the structure used by IRDs and should - be given as: + Curves for discounting cashflows. List follows the structure used by IRDs and + should be given as: `[None, Curve for domestic ccy, None, Curve for foreign ccy]` solver : Solver, optional - The numerical :class:`Solver` that constructs ``Curves`` from calibrating - instruments. - fx : float, FXRates, FXForwards, optional - The immediate settlement FX rate that will be used to convert values - into another currency. A given `float` is used directly. If giving a - ``FXRates`` or ``FXForwards`` object, converts from local currency - into ``base``. - base : str, optional - The base currency to convert cashflows into (3-digit code), set by default. - Only used if ``fx`` is an ``FXRates`` or ``FXForwards`` object. - + The numerical :class:`Solver` that constructs *Curves*, *Smiles* or *Surfaces* from + calibrating instruments. + fx: FXForwards + The object to project the relevant forward and spot FX rates. + base: str, optional + Not used by `analytic_greeks`. + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface, or Sequence of such, optional + The volatility used in calculation. Returns ------- - float, Dual, Dual2 + dict Notes - ------ - + ----- + If the ``vol`` option is given as a Sequence of volatility values, these should be + ordered according to each *FXOption* or *FXOptionStrat* contained on the *Instrument*. + For nested *FXOptionStrat* use nested sequences. """ # implicitly call set_pricing_mid for unpriced parameters @@ -213,7 +339,7 @@ def analytic_greeks( # interdependent options) self.rate(curves, solver, fx, base, vol) - curves, fx, base = _get_curves_fx_and_base_maybe_from_solver( + curves_, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -221,25 +347,34 @@ def analytic_greeks( base, self.kwargs["pair"][3:], ) - vol = self._vol_as_list(vol, solver) + vol_: ListFXVol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) gks = [] - for option, _vol in zip(self.periods, vol, strict=False): - # by calling on the OptionPeriod directly the strike is maintained from rate call. - gks.append( - option.periods[0].analytic_greeks( - curves[1], - curves[3], - fx, - base, - local, - _vol, - option.kwargs["premium"], - ), - ) + for option, vol_i in zip(self.periods, vol_, strict=True): + if isinstance(option, FXOptionStrat): + gks.append( + option.analytic_greeks( + curves=curves, + solver=solver, + fx=fx, + base=base, + vol=vol_i, + ) + ) + else: # option is FXOption + # by calling on the OptionPeriod directly the strike is maintained from rate call. + gks.append( + option._option_periods[0].analytic_greeks( + disc_curve=_validate_obj_not_no_input(curves_[1], "curves_[1]"), + disc_curve_ccy2=_validate_obj_not_no_input(curves_[3], "curves_[3]"), + fx=_validate_fx_as_forwards(fx_), + base=base_, + vol=vol_i, # type: ignore[arg-type] + ), + ) _unit_attrs = ["delta", "gamma", "vega", "vomma", "vanna", "_kega", "_kappa", "__bs76"] - _ = {} + _: dict[str, Any] = {} for attr in _unit_attrs: _[attr] = sum(gk[attr] * self.rate_weight[i] for i, gk in enumerate(gks)) @@ -266,6 +401,9 @@ class FXRiskReversal(FXOptionStrat, FXOption): """ Create an *FX Risk Reversal* option strategy. + A *RiskReversal* is composed of a lower strike :class:`~rateslib.instruments.FXPut` and a + higher strike :class:`~rateslib.instruments.FXCall`. + For additional arguments see :class:`~rateslib.instruments.FXOption`. Parameters @@ -284,37 +422,42 @@ class FXRiskReversal(FXOptionStrat, FXOption): Notes ----- + Buying a *Risk Reversal* equates to selling a lower strike :class:`~rateslib.instruments.FXPut` + and buying a higher strike :class:`~rateslib.instruments.FXCall`. The ``notional`` of each are + the same, and should be entered as a single value. + A positive *notional* will indicate a sale of the *Put* and a purchase of the *Call*. + When supplying ``strike`` as a string delta the strike will be determined at price time from the provided volatility. - Buying a *Risk Reversal* equates to selling a lower strike :class:`~rateslib.instruments.FXPut` - and buying a higher strike :class:`~rateslib.instruments.FXCall`. - - This class is essentially an alias constructor for an + This class is an alias constructor for an :class:`~rateslib.instruments.FXOptionStrat` where the number - of options and their definitions and nominals have been specifically set. + of options and their definitions and nominals have been specifically overloaded for + convenience. """ rate_weight = [-1.0, 1.0] rate_weight_vol = [-1.0, 1.0] _rate_scalar = 100.0 + periods: list[FXOption] # type: ignore[assignment] + vol: FXVolStrat_ def __init__( self, - *args, - strike=(NoInput(0), NoInput(0)), - premium=(NoInput(0), NoInput(0)), + *args: Any, + strike: tuple[str | DualTypes_, str | DualTypes_] = (NoInput(0), NoInput(0)), + premium: tuple[DualTypes_, DualTypes_] = (NoInput(0), NoInput(0)), metric: str = "vol", - **kwargs, - ): - super(FXOptionStrat, self).__init__( + **kwargs: Any, + ) -> None: + super(FXOptionStrat, self).__init__( # type: ignore[misc] *args, - strike=list(strike), - premium=list(premium), + strike=list(strike), # type: ignore[arg-type] + premium=list(premium), # type: ignore[arg-type] **kwargs, ) self.kwargs["metric"] = metric - self._strat_elements = [ + self._strat_elements = ( FXPut( pair=self.kwargs["pair"], expiry=self.kwargs["expiry"], @@ -347,9 +490,9 @@ def __init__( curves=self.curves, vol=self.vol, ), - ] + ) - def _validate_strike_and_premiums(self): + def _validate_strike_and_premiums(self) -> None: """called as part of init, specific validation rules for straddle""" if any(_ is NoInput.blank for _ in self.kwargs["strike"]): raise ValueError( @@ -368,6 +511,9 @@ class FXStraddle(FXOptionStrat, FXOption): """ Create an *FX Straddle* option strategy. + An *FXStraddle* is composed of an :class:`~rateslib.instruments.FXCall` + and an :class:`~rateslib.instruments.FXPut` at the same strike. + For additional arguments see :class:`~rateslib.instruments.FXOption`. Parameters @@ -383,25 +529,35 @@ class FXStraddle(FXOptionStrat, FXOption): Notes ----- - When supplying ``strike`` as a string delta the strike will be determined at price time from - the provided volatility and FX forward market. - Buying a *Straddle* equates to buying an :class:`~rateslib.instruments.FXCall` - and an :class:`~rateslib.instruments.FXPut` at the same strike. + and an :class:`~rateslib.instruments.FXPut` at the same strike. The ``notional`` of each are + the same, and is input as a single value. + + ``strike`` should be supplied as a single value. + When providing a string delta the strike will be determined at price time from + the provided volatility and FX forward market. This class is essentially an alias constructor for an :class:`~rateslib.instruments.FXOptionStrat` where the number - of options and their definitions and nominals have been specifically set. + of options and their definitions have been specifically overloaded for convenience. """ rate_weight = [1.0, 1.0] rate_weight_vol = [0.5, 0.5] _rate_scalar = 100.0 + periods: list[FXOption] # type: ignore[assignment] + vol: FXVolStrat_ - def __init__(self, *args, premium=(NoInput(0), NoInput(0)), metric="vol", **kwargs): - super(FXOptionStrat, self).__init__(*args, premium=list(premium), **kwargs) + def __init__( + self, + *args: Any, + premium: tuple[DualTypes_, DualTypes_] = (NoInput(0), NoInput(0)), + metric: str = "vol", + **kwargs: Any, + ) -> None: + super(FXOptionStrat, self).__init__(*args, premium=list(premium), **kwargs) # type: ignore[misc, arg-type] self.kwargs["metric"] = metric - self._strat_elements = [ + self._strat_elements = ( FXPut( pair=self.kwargs["pair"], expiry=self.kwargs["expiry"], @@ -434,11 +590,19 @@ def __init__(self, *args, premium=(NoInput(0), NoInput(0)), metric="vol", **kwar curves=self.curves, vol=self.vol, ), - ] + ) + + def _set_notionals(self, notional: DualTypes) -> None: + """ + Set the notionals on each option period. Mainly used by Brokerfly for vega neutral + strangle and straddle. + """ + for option in self.periods: + option.periods[0].notional = notional - def _validate_strike_and_premiums(self): + def _validate_strike_and_premiums(self) -> None: """called as part of init, specific validation rules for straddle""" - if self.kwargs["strike"] is NoInput.blank: + if isinstance(self.kwargs["strike"], NoInput): raise ValueError("`strike` for FXStraddle must be set to numeric or string value.") if isinstance(self.kwargs["strike"], str) and self.kwargs["premium"] != [ NoInput.blank, @@ -455,6 +619,9 @@ class FXStrangle(FXOptionStrat, FXOption): """ Create an *FX Strangle* option strategy. + An *FXStrangle* is composed of a lower strike :class:`~rateslib.instruments.FXPut` and + a higher strike :class:`~rateslib.instruments.FXCall`. + For additional arguments see :class:`~rateslib.instruments.FXOption`. Parameters @@ -462,10 +629,10 @@ class FXStrangle(FXOptionStrat, FXOption): args: tuple Positional arguments to :class:`~rateslib.instruments.FXOption`. strike: 2-element sequence - The first element is applied to the lower strike put and the - second element applied to the higher strike call, e.g. `["-25d", "25d"]`. + The first element is applied to the lower strike *Put* and the + second element applied to the higher strike *Call*, e.g. `["-25d", "25d"]`. premium: 2-element sequence, optional - The premiums associated with each option of the strangle. + The premiums associated with each *FXOption* of the *Strangle*. metric: str, optional The default metric to apply in the method :meth:`~rateslib.instruments.FXOptionStrat.rate` kwargs: tuple @@ -473,40 +640,47 @@ class FXStrangle(FXOptionStrat, FXOption): Notes ----- + Buying a *Strangle* equates to buying a lower strike :class:`~rateslib.instruments.FXPut` + and buying a higher strike :class:`~rateslib.instruments.FXCall`. The ``notional`` is provided + as a single input and is applied to both *FXOptions*. + When supplying ``strike`` as a string delta the strike will be determined at price time from the provided volatility. - Buying a *Strangle* equates to buying a lower strike :class:`~rateslib.instruments.FXPut` - and buying a higher strike :class:`~rateslib.instruments.FXCall`. - This class is essentially an alias constructor for an :class:`~rateslib.instruments.FXOptionStrat` where the number - of options and their definitions and nominals have been specifically set. + of options and their definitions and nominals have been specifically overloaded for + convenience. .. warning:: - The default ``metric`` for an *FXStraddle* is *'single_vol'*, which requires an iterative - algorithm to solve. - For defined strikes it is usually very accurate but for strikes defined by delta it - will return a solution within 0.1 pips. This means it is both slower than other instruments - and inexact. + The default ``metric`` for an *FXStrangle* is *'single_vol'*, which requires + an iterative algorithm to solve. + For defined strikes it is accurate but for strikes defined by delta it + will return an iterated solution within 0.1 pips. This means it is both slower + than other instruments and inexact. """ rate_weight = [1.0, 1.0] rate_weight_vol = [0.5, 0.5] _rate_scalar = 100.0 + periods: list[FXOption] # type: ignore[assignment] + vol: FXVolStrat_ def __init__( self, - *args, - strike=(NoInput(0), NoInput(0)), - premium=(NoInput(0), NoInput(0)), - metric="single_vol", - **kwargs, - ): - super(FXOptionStrat, self).__init__( - *args, strike=list(strike), premium=list(premium), **kwargs + *args: Any, + strike: tuple[str | DualTypes_, str | DualTypes_] = (NoInput(0), NoInput(0)), + premium: tuple[DualTypes_, DualTypes_] = (NoInput(0), NoInput(0)), + metric: str = "single_vol", + **kwargs: Any, + ) -> None: + super(FXOptionStrat, self).__init__( # type: ignore[misc] + *args, + strike=list(strike), # type: ignore[arg-type] + premium=list(premium), # type: ignore[arg-type] + **kwargs, ) self.kwargs["metric"] = metric self._is_fixed_delta = [ @@ -517,7 +691,7 @@ def __init__( and self.kwargs["strike"][1][-1].lower() == "d" and self.kwargs["strike"][1] != "atm_forward", ] - self._strat_elements = [ + self._strat_elements = ( FXPut( pair=self.kwargs["pair"], expiry=self.kwargs["expiry"], @@ -550,16 +724,16 @@ def __init__( curves=self.curves, vol=self.vol, ), - ] + ) - def _validate_strike_and_premiums(self): + def _validate_strike_and_premiums(self) -> None: """called as part of init, specific validation rules for strangle""" - if any(_ is NoInput.blank for _ in self.kwargs["strike"]): + if any(isinstance(_, NoInput) for _ in self.kwargs["strike"]): raise ValueError( "`strike` for FXStrangle must be set to list of 2 numeric or string values.", ) for k, p in zip(self.kwargs["strike"], self.kwargs["premium"], strict=False): - if isinstance(k, str) and p != NoInput.blank: + if isinstance(k, str) and not isinstance(p, NoInput): raise ValueError( "FXStrangle with string delta as `strike` cannot be initialised with a " "known `premium`.\n" @@ -572,36 +746,45 @@ def rate( solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - vol: list[float] | float = NoInput(0), + vol: FXVolStrat_ = NoInput(0), metric: str_ = NoInput(0), # "pips_or_%", - ): + ) -> DualTypes: """ - Returns the rate of the *FXStraddle* according to a pricing metric. + Returns the rate of the *FXStrangle* according to a pricing metric. + + For parameters see :meth:`FXOptionStrat.rate `. Notes ------ .. warning:: - The default ``metric`` for an *FXStraddle* is *'single_vol'*, which requires an + The default ``metric`` for an *FXStrangle* is *'single_vol'*, which requires an iterative algorithm to solve. For defined strikes it is usually very accurate but for strikes defined by delta it will return a solution within 0.01 pips. This means it is both slower than other instruments and inexact. - For parameters see :meth:`~rateslib.instruments.FXOption.rate`. - - The ``metric`` *'vol'* is not sensible to use with an *FXStraddle*, although it will - return the arithmetic - average volatility across both options, *'single_vol'* is the more standardised choice. + The ``metric`` *'vol'* is not sensible to use with an *FXStrangle*, although it will + return the arithmetic average volatility across both options, *'single_vol'* is the + more standardised choice. """ return self._rate(curves, solver, fx, base, vol, metric) - def _rate(self, curves, solver, fx, base, vol, metric, record_greeks=False): + def _rate( + self, + curves: Curves_, + solver: Solver_, + fx: FX_, + base: str_, + vol: FXVolStrat_, + metric: str_, + record_greeks: bool = False, + ) -> DualTypes: metric = _drb(self.kwargs["metric"], metric).lower() if metric != "single_vol" and not any(self._is_fixed_delta): # the strikes are explicitly defined and independent across options. - # can evaluate separately + # can evaluate separately, therefore the default method will suffice. return super().rate(curves, solver, fx, base, vol, metric) else: # must perform single vol evaluation to determine mkt convention strikes @@ -612,12 +795,20 @@ def _rate(self, curves, solver, fx, base, vol, metric, record_greeks=False): # return the premiums using the single_vol as the volatility return super().rate(curves, solver, fx, base, vol=single_vol, metric=metric) - def _rate_single_vol(self, curves, solver, fx, base, vol, record_greeks): + def _rate_single_vol( + self, + curves: Curves_, + solver: Solver_, + fx: FX_, + base: str_, + vol: FXVolStrat_, + record_greeks: bool, + ) -> DualTypes: """ Solve the single vol rate metric for a strangle using iterative market convergence routine. """ # Get curves and vol - curves, fx, base = _get_curves_fx_and_base_maybe_from_solver( + curves_, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -625,34 +816,45 @@ def _rate_single_vol(self, curves, solver, fx, base, vol, record_greeks): base, self.kwargs["pair"][3:], ) - vol = self._vol_as_list(vol, solver) - vol = [ - _ if not isinstance(_, FXDeltaVolSurface) else _.get_smile(self.kwargs["expiry"]) - for _ in vol - ] - - spot = fx.pairs_settlement[self.kwargs["pair"]] - w_spot, w_deli = curves[1][spot], curves[1][self.kwargs["delivery"]] - f_d, f_t = ( - fx.rate(self.kwargs["pair"], self.kwargs["delivery"]), - fx.rate(self.kwargs["pair"], spot), - ) + vol_: ListFXVol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) + # type assignment, instead of using assert + vol_0: FXVolOption = vol_[0] # type: ignore[assignment] + vol_1: FXVolOption = vol_[1] # type: ignore[assignment] + + # Get data from objects + curves_1: Curve = _validate_obj_not_no_input(curves_[1], "curves_[1]") + curves_3: Curve = _validate_obj_not_no_input(curves_[3], "curves_[3]") + fxf: FXForwards = _validate_fx_as_forwards(fx_) + spot: datetime = fxf.pairs_settlement[self.kwargs["pair"]] + w_spot: DualTypes = curves_1[spot] + w_deli: DualTypes = curves_1[self.kwargs["delivery"]] + f_d: DualTypes = fxf.rate(self.kwargs["pair"], self.kwargs["delivery"]) + f_t: DualTypes = fxf.rate(self.kwargs["pair"], spot) z_w_0 = 1.0 if "forward" in self.kwargs["delta_type"] else w_deli / w_spot f_0 = f_d if "forward" in self.kwargs["delta_type"] else f_t + eta1 = None - if isinstance(vol[0], FXVolObj): - eta1 = -0.5 if "_pa" in vol[0].delta_type else 0.5 - z_w_1 = 1.0 if "forward" in vol[0].delta_type else w_deli / w_spot + if isinstance( + vol_[0], FXDeltaVolSurface | FXDeltaVolSmile + ): # multiple Vol objects cannot be used, will derive conventions from the first one found. + eta1 = -0.5 if "_pa" in vol_[0].delta_type else 0.5 + z_w_1 = 1.0 if "forward" in vol_[0].delta_type else w_deli / w_spot fzw1zw0 = f_0 * z_w_1 / z_w_0 # first start by evaluating the individual swaptions given their # strikes with the smile - delta or fixed - gks = [ - self.periods[0].analytic_greeks(curves, solver, fx, base, vol=vol[0]), - self.periods[1].analytic_greeks(curves, solver, fx, base, vol=vol[1]), + gks: list[dict[str, Any]] = [ + self.periods[0].analytic_greeks(curves, solver, fxf, base, vol_0), + self.periods[1].analytic_greeks(curves, solver, fxf, base, vol_1), ] - def d_wrt_sigma1(period_index, greeks, smile_greeks, vol, eta1): + def d_wrt_sigma1( + period_index: int, + g: dict[str, Any], # greeks + sg: dict[str, Any], # smile_greeks + vol: FXVol, + eta1: float | None, + ) -> tuple[DualTypes, DualTypes]: """ Obtain derivatives with respect to tgt vol. @@ -668,53 +870,55 @@ def d_wrt_sigma1(period_index, greeks, smile_greeks, vol, eta1): whether the strike above is set to float or is left in AD format which has other implications for the calculation of risk sensitivities. """ - i, sg, g = period_index, smile_greeks, greeks - fixed_delta, vol = self._is_fixed_delta[i], vol[i] + fixed_delta = self._is_fixed_delta[period_index] if not fixed_delta: - return g[i]["vega"], 0.0 - elif not isinstance(vol, FXVolObj): + return g["vega"], 0.0 + elif not isinstance(vol, FXDeltaVolSmile | FXDeltaVolSurface): return ( - g[i]["_kappa"] * g[i]["_kega"] + g[i]["vega"], - sg[i]["_kappa"] * g[i]["_kega"], + g["_kappa"] * g["_kega"] + g["vega"], + sg["_kappa"] * g["_kega"], ) else: - dvol_ddeltaidx = evaluate(vol.spline, sg[i]["_delta_index"], 1) * 0.01 - ddeltaidx_dvol1 = sg[i]["gamma"] * fzw1zw0 + assert isinstance(eta1, float) # noqa: S101 / becuase vol is Smile/Surface + if isinstance(vol, FXDeltaVolSurface): + vol = vol.get_smile(self.kwargs["expiry"]) + dvol_ddeltaidx = evaluate(vol.spline, sg["_delta_index"], 1) * 0.01 + ddeltaidx_dvol1 = sg["gamma"] * fzw1zw0 if eta1 < 0: # premium adjusted vol smile - ddeltaidx_dvol1 += sg[i]["_delta_index"] - ddeltaidx_dvol1 *= g[i]["_kega"] / sg[i]["__strike"] + ddeltaidx_dvol1 += sg["_delta_index"] + ddeltaidx_dvol1 *= g["_kega"] / sg["__strike"] - _ = dual_log(sg[i]["__strike"] / f_d) / sg[i]["__vol"] - _ += eta1 * sg[i]["__vol"] * sg[i]["__sqrt_t"] ** 2 - _ *= dvol_ddeltaidx * sg[i]["gamma"] * fzw1zw0 + _ = dual_log(sg["__strike"] / f_d) / sg["__vol"] + _ += eta1 * sg["__vol"] * sg["__sqrt_t"] ** 2 + _ *= dvol_ddeltaidx * sg["gamma"] * fzw1zw0 ddeltaidx_dvol1 /= 1 + _ return ( - g[i]["_kappa"] * g[i]["_kega"] + g[i]["vega"], - sg[i]["_kappa"] * g[i]["_kega"] - + sg[i]["vega"] * dvol_ddeltaidx * ddeltaidx_dvol1, + g["_kappa"] * g["_kega"] + g["vega"], + sg["_kappa"] * g["_kega"] + sg["vega"] * dvol_ddeltaidx * ddeltaidx_dvol1, ) - tgt_vol = (gks[0]["__vol"] * gks[0]["vega"] + gks[1]["__vol"] * gks[1]["vega"]) * 100.0 + tgt_vol: DualTypes = ( + gks[0]["__vol"] * gks[0]["vega"] + gks[1]["__vol"] * gks[1]["vega"] + ) * 100.0 tgt_vol /= gks[0]["vega"] + gks[1]["vega"] f0, iters = 100e6, 1 + put_op_period: FXOptionPeriod = self.periods[0]._option_periods[0] + call_op_period: FXOptionPeriod = self.periods[1]._option_periods[0] + while abs(f0) > 1e-6 and iters < 10: # Determine the strikes at the current tgt_vol # Also determine the greeks of these options measure with tgt_vol gks = [ - self.periods[0].analytic_greeks(curves, solver, fx, base, vol=tgt_vol), - self.periods[1].analytic_greeks(curves, solver, fx, base, vol=tgt_vol), + self.periods[0].analytic_greeks(curves, solver, fxf, base, tgt_vol), + self.periods[1].analytic_greeks(curves, solver, fxf, base, tgt_vol), ] # Also determine the greeks of these options measured with the market smile vol. # (note the strikes have been set by previous call, call OptionPeriods direct # to avoid re-determination) smile_gks = [ - self.periods[0] - .periods[0] - .analytic_greeks(curves[1], curves[3], fx, base, vol=vol[0]), - self.periods[1] - .periods[0] - .analytic_greeks(curves[1], curves[3], fx, base, vol=vol[1]), + put_op_period.analytic_greeks(curves_1, curves_3, fxf, base_, vol_0), + call_op_period.analytic_greeks(curves_1, curves_3, fxf, base_, vol_1), ] # The value of the root function is derived from the 4 previous calculated prices @@ -725,8 +929,8 @@ def d_wrt_sigma1(period_index, greeks, smile_greeks, vol, eta1): - gks[1]["__bs76"] ) - dc1_dvol1_0, dcmkt_dvol1_0 = d_wrt_sigma1(0, gks, smile_gks, vol, eta1) - dc1_dvol1_1, dcmkt_dvol1_1 = d_wrt_sigma1(1, gks, smile_gks, vol, eta1) + dc1_dvol1_0, dcmkt_dvol1_0 = d_wrt_sigma1(0, gks[0], smile_gks[0], vol_0, eta1) + dc1_dvol1_1, dcmkt_dvol1_1 = d_wrt_sigma1(1, gks[1], smile_gks[1], vol_1, eta1) f1 = dcmkt_dvol1_0 + dcmkt_dvol1_1 - dc1_dvol1_0 - dc1_dvol1_1 tgt_vol = tgt_vol - (f0 / f1) * 100.0 # Newton-Raphson step @@ -735,22 +939,12 @@ def d_wrt_sigma1(period_index, greeks, smile_greeks, vol, eta1): if record_greeks: # this needs to be explicitly called since it degrades performance self._greeks["strangle"] = { "single_vol": { - "FXPut": self.periods[0].analytic_greeks(curves, solver, fx, base, vol=tgt_vol), - "FXCall": self.periods[1].analytic_greeks( - curves, - solver, - fx, - base, - vol=tgt_vol, - ), + "FXPut": self.periods[0].analytic_greeks(curves, solver, fxf, base, tgt_vol), + "FXCall": self.periods[1].analytic_greeks(curves, solver, fxf, base, tgt_vol), }, "market_vol": { - "FXPut": self.periods[0] - .periods[0] - .analytic_greeks(curves[1], curves[3], fx, base, vol=vol[0]), - "FXCall": self.periods[1] - .periods[0] - .analytic_greeks(curves[1], curves[3], fx, base, vol=vol[1]), + "FXPut": put_op_period.analytic_greeks(curves_1, curves_3, fxf, base, vol_0), + "FXCall": call_op_period.analytic_greeks(curves_1, curves_3, fxf, base, vol_1), }, } @@ -788,22 +982,26 @@ class FXBrokerFly(FXOptionStrat, FXOption): """ Create an *FX BrokerFly* option strategy. + An *FXBrokerFly* is composed of an :class:`~rateslib.instruments.FXStrangle` and an + :class:`~rateslib.instruments.FXStraddle`, in that order. + For additional arguments see :class:`~rateslib.instruments.FXOption`. Parameters ---------- args: tuple Positional arguments to :class:`~rateslib.instruments.FXOption`. - strike: 3-element sequence - The first element is applied to the lower strike put, the - second element to the straddle strike and the third element to the higher strike - call, e.g. `["-25d", "atm_delta", "25d"]`. - premium: 4-element sequence, optional - The premiums associated with each option of the strategy; lower strike put, straddle put, - straddle call, higher strike call. + strike: 2-element sequence + The first element should be a 2-element sequence of strikes of the *FXStrangle*. + The second element should be a single element for the strike of the *FXStraddle*. + call, e.g. `[["-25d", "25d"], "atm_delta"]`. + premium: 2-element sequence, optional + The premiums associated with each option of the strategy; + The first element contains 2 values for the premiums of each *FXOption* in the *Strangle*. + The second element contains 2 values for the premiums of each *FXOption* in the *Straddle*. notional: 2-element sequence, optional The first element is the notional associated with the *Strangle*. If the second element - is *None*, it will be implied in a vega neutral sense. + is *None*, it will be implied in a vega neutral sense at price time. metric: str, optional The default metric to apply in the method :meth:`~rateslib.instruments.FXOptionStrat.rate` kwargs: tuple @@ -811,18 +1009,18 @@ class FXBrokerFly(FXOptionStrat, FXOption): Notes ----- - When supplying ``strike`` as a string delta the strike will be determined at price time from - the provided volatility. - Buying a *BrokerFly* equates to buying an :class:`~rateslib.instruments.FXStrangle` and selling a :class:`~rateslib.instruments.FXStraddle`, where the convention is to set the notional on the *Straddle* such that the entire strategy is *vega* neutral at inception. + When supplying ``strike`` as a string delta the strike will be determined at price time from + the provided volatility. + .. warning:: The default ``metric`` for an *FXBrokerFly* is *'single_vol'*, which requires an iterative algorithm to solve. - For defined strikes it is usually very accurate but for strikes defined by delta it + For defined strikes it is accurate but for strikes defined by delta it will return a solution within 0.1 pips. This means it is both slower than other instruments and inexact. @@ -832,20 +1030,29 @@ class FXBrokerFly(FXOptionStrat, FXOption): rate_weight_vol = [1.0, -1.0] _rate_scalar = 100.0 + periods: list[FXOptionStrat] # type: ignore[assignment] + vol: FXVolStrat_ + def __init__( self, - *args, - strike=(NoInput(0), NoInput(0), NoInput(0)), - premium=(NoInput(0), NoInput(0), NoInput(0), NoInput(0)), - notional=(NoInput(0), NoInput(0)), - metric="single_vol", - **kwargs, - ): - super(FXOptionStrat, self).__init__( + *args: Any, + strike: tuple[tuple[DualTypes | str_, DualTypes | str_], DualTypes | str_] = ( + (NoInput(0), NoInput(0)), + NoInput(0), + ), + premium: tuple[tuple[DualTypes_, DualTypes_], tuple[DualTypes_, DualTypes_]] = ( + (NoInput(0), NoInput(0)), + (NoInput(0), NoInput(0)), + ), + notional: tuple[DualTypes_, DualTypes_] = (NoInput(0), NoInput(0)), + metric: str = "single_vol", + **kwargs: Any, + ) -> None: + super(FXOptionStrat, self).__init__( # type: ignore[misc] *args, - premium=list(premium), - strike=list(strike), - notional=list(notional), + premium=list(premium), # type: ignore[arg-type] + strike=list(strike), # type: ignore[arg-type] + notional=list(notional), # type: ignore[arg-type] **kwargs, ) self.kwargs["notional"][1] = ( @@ -860,11 +1067,11 @@ def __init__( payment_lag=self.kwargs["payment"], calendar=self.kwargs["calendar"], modifier=self.kwargs["modifier"], - strike=[self.kwargs["strike"][0], self.kwargs["strike"][2]], + strike=self.kwargs["strike"][0], notional=self.kwargs["notional"][0], option_fixing=self.kwargs["option_fixing"], delta_type=self.kwargs["delta_type"], - premium=[self.kwargs["premium"][0], self.kwargs["premium"][3]], + premium=self.kwargs["premium"][0], premium_ccy=self.kwargs["premium_ccy"], metric=self.kwargs["metric"], curves=self.curves, @@ -881,7 +1088,7 @@ def __init__( notional=self.kwargs["notional"][1], option_fixing=self.kwargs["option_fixing"], delta_type=self.kwargs["delta_type"], - premium=self.kwargs["premium"][1:3], + premium=self.kwargs["premium"][1], premium_ccy=self.kwargs["premium_ccy"], metric="vol" if self.kwargs["metric"] == "single_vol" else self.kwargs["metric"], curves=self.curves, @@ -889,9 +1096,18 @@ def __init__( ), ) - def _maybe_set_vega_neutral_notional(self, curves, solver, fx, base, vol, metric): - if self.kwargs["notional"][1] is NoInput.blank and metric in ["pips_or_%", "premium"]: - self.periods[0]._rate( + def _maybe_set_vega_neutral_notional( + self, curves: Curves_, solver: Solver_, fx: FX_, base: str_, vol: ListFXVol_, metric: str_ + ) -> None: + """ + Calculate the vega of the strangle and then set the notional on the straddle + to yield a vega neutral strategy. + + Notional is set as a fixed quantity, collapsing any AD sensitivities in accordance + with the general principle for determining risk sensitivities of unpriced instruments. + """ + if isinstance(self.kwargs["notional"][1], NoInput) and metric in ["pips_or_%", "premium"]: + self.periods[0]._rate( # type: ignore[attr-defined] curves, solver, fx, @@ -911,10 +1127,10 @@ def _maybe_set_vega_neutral_notional(self, curves, solver, fx, base, vol, metric strangle_vega += self._greeks["strangle"]["market_vol"]["FXCall"]["vega"] straddle_vega = self._greeks["straddle"]["vega"] scalar = strangle_vega / straddle_vega - self.periods[1].kwargs["notional"] = float( - self.periods[0].periods[0].periods[0].notional * -scalar, + self.periods[1].kwargs["notional"] = _dual_float( + self.periods[0].periods[0].periods[0].notional * -scalar, # type: ignore[union-attr] ) - self.periods[1]._set_notionals(self.periods[1].kwargs["notional"]) + self.periods[1]._set_notionals(self.periods[1].kwargs["notional"]) # type: ignore[attr-defined] # BrokerFly -> Strangle -> FXPut -> FXPutPeriod def rate( @@ -923,69 +1139,55 @@ def rate( solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - vol: list[float] | float = NoInput(0), + vol: FXVolStrat_ = NoInput(0), metric: str_ = NoInput(0), - ): + ) -> DualTypes: """ - Return the mid-market rate of an option strategy. + Returns the rate of the *FXBrokerFly* according to a pricing metric. - Parameters - ---------- - curves - solver - fx - base - vol - metric - - Returns - ------- - float, Dual, Dual2 + For parameters see :meth:`FXOptionStrat.rate `. Notes - ----- - - The different types of ``metric`` return different quotation conventions. + ------ - - *'single_vol'*: the default type for a :class:`~rateslib.instruments.FXStrangle` + .. warning:: - - *'vol'*: sums the mid-market volatilities of each option multiplied by their - respective ``rate_weight_vol`` - parameter. For example this is the default pricing convention for - a :class:`~rateslib.instruments.FXRiskReversal` where the price is the vol of the call - minus the vol of the - put and the ``rate_weight_vol`` parameters are [-1.0, 1.0]. + The default ``metric`` for an *FXBrokerFly* is *'single_vol'*, which requires an + iterative algorithm to solve. + For defined strikes it is usually very accurate but for strikes defined by delta it + will return a solution within 0.01 pips. This means it is both slower than other + instruments and inexact. - - *'pips_or_%'*: sums the mid-market pips or percent price of each option multiplied by - their respective - ``rate_weight`` parameter. For example for a :class:`~rateslib.instruments.FXStraddle` - the total premium - is the sum of two premiums and the ``rate_weight`` parameters are [1.0, 1.0]. + The ``metric`` *'vol'* is not sensible to use with an *FXBrokerFly*, although it will + return the arithmetic average volatility across both strategies, *'single_vol'* is the + more standardised choice. """ - if not isinstance(vol, list): - vol = [[vol, vol], vol] - else: - vol = [ - [vol[0], vol[2]], - vol[1], - ] # restructure to pass to Strangle and Straddle separately + vol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) + # if not isinstance(vol, list): + # vol = [[vol, vol], vol] + # else: + # vol = [ + # [vol[0], vol[2]], + # vol[1], + # ] # restructure to pass to Strangle and Straddle separately temp_metric = _drb(self.kwargs["metric"], metric) - self._maybe_set_vega_neutral_notional(curves, solver, fx, base, vol, temp_metric.lower()) + self._maybe_set_vega_neutral_notional(curves, solver, fx, base, vol_, temp_metric.lower()) if temp_metric == "pips_or_%": straddle_scalar = ( - self.periods[1].periods[0].periods[0].notional - / self.periods[0].periods[0].periods[0].notional + self.periods[1].periods[0].periods[0].notional # type: ignore[union-attr] + / self.periods[0].periods[0].periods[0].notional # type: ignore[union-attr] ) - weights = [1.0, straddle_scalar] + weights: Sequence[DualTypes] = [1.0, straddle_scalar] elif temp_metric == "premium": weights = self.rate_weight else: weights = self.rate_weight_vol - _ = 0.0 - for option_strat, vol_, weight in zip(self.periods, vol, weights, strict=False): - _ += option_strat.rate(curves, solver, fx, base, vol_, metric) * weight + _: DualTypes = 0.0 + + for option_strat, vol__, weight in zip(self.periods, vol_, weights, strict=False): + _ += option_strat.rate(curves, solver, fx, base, vol__, metric) * weight return _ def analytic_greeks( @@ -994,30 +1196,29 @@ def analytic_greeks( solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - local: bool = False, - vol: float = NoInput(0), - ): - """ """ + vol: FXVolStrat_ = NoInput(0), + ) -> dict[str, Any]: # implicitly call set_pricing_mid for unpriced parameters self.rate(curves, solver, fx, base, vol, metric="pips_or_%") # curves, fx, base = _get_curves_fx_and_base_maybe_from_solver( # self.curves, solver, curves, fx, base, self.kwargs["pair"][3:] # ) - if not isinstance(vol, list): - vol = [[vol, vol], vol] - else: - vol = [[vol[0], vol[2]], vol[1]] # restructure for strangle / straddle + vol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) + # if not isinstance(vol, list): + # vol = [[vol, vol], vol] + # else: + # vol = [[vol[0], vol[2]], vol[1]] # restructure for strangle / straddle # TODO: this meth can be optimised because it calculates greeks at multiple times in frames - g_grks = self.periods[0].analytic_greeks(curves, solver, fx, base, local, vol[0]) - d_grks = self.periods[1].analytic_greeks(curves, solver, fx, base, local, vol[1]) + g_grks = self.periods[0].analytic_greeks(curves, solver, fx, base, vol_[0]) + d_grks = self.periods[1].analytic_greeks(curves, solver, fx, base, vol_[1]) sclr = abs( - self.periods[1].periods[0].periods[0].notional - / self.periods[0].periods[0].periods[0].notional, + self.periods[1].periods[0].periods[0].notional # type: ignore[union-attr] + / self.periods[0].periods[0].periods[0].notional, # type: ignore[union-attr] ) _unit_attrs = ["delta", "gamma", "vega", "vomma", "vanna", "_kega", "_kappa", "__bs76"] - _ = {} + _: dict[str, Any] = {} for attr in _unit_attrs: _[attr] = g_grks[attr] - sclr * d_grks[attr] @@ -1047,8 +1248,8 @@ def _plot_payoff( fx: FX_ = NoInput(0), base: str_ = NoInput(0), local: bool = False, - vol: list[float] | float = NoInput(0), - ): - vol = self._vol_as_list(vol, solver) - self._maybe_set_vega_neutral_notional(curves, solver, fx, base, vol, metric="pips_or_%") - return super()._plot_payoff(range, curves, solver, fx, base, local, vol) + vol: FXVolStrat_ = NoInput(0), + ) -> tuple[Any, Any]: + vol_ = self._get_fxvol_maybe_from_solver_recursive(vol, solver) + self._maybe_set_vega_neutral_notional(curves, solver, fx, base, vol_, metric="pips_or_%") + return super()._plot_payoff(range, curves, solver, fx, base, local, vol_) diff --git a/python/rateslib/instruments/fx_volatility/vanilla.py b/python/rateslib/instruments/fx_volatility/vanilla.py index edcf4c9f..56ce17fa 100644 --- a/python/rateslib/instruments/fx_volatility/vanilla.py +++ b/python/rateslib/instruments/fx_volatility/vanilla.py @@ -110,6 +110,9 @@ class FXOption(Sensitivities, metaclass=ABCMeta): entered either as *Curve* or str for discounting cashflows in the appropriate currency with a consistent collateral on each side. E.g. *[None, "eurusd", None, "usdusd"]*. Forecasting curves are not relevant. + vol: str, Smile, Surface, float, Dual, Dual2, Variable + An attached pricing metric used to value the *FXOption* in the absence of volatility + values supplied at price-time. spec : str, optional An identifier to pre-populate many field with conventional values. See :ref:`here` for more info and available values. @@ -150,6 +153,7 @@ class FXOption(Sensitivities, metaclass=ABCMeta): _option_periods: tuple[FXPutPeriod | FXCallPeriod] _cashflow_periods: tuple[Cashflow] + curves: Curves_ def __init__( self, @@ -465,7 +469,6 @@ def rate( disc_curve_ccy2=_validate_obj_not_no_input(curves_[3], "curve"), fx=fx_, base=NoInput(0), - local=False, vol=self._pricing.vol, ) if metric == "premium": @@ -504,6 +507,8 @@ def npv( local : bool, optional If `True` will return a dict identifying NPV by local currencies on each period. + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface + The volatility used in calculation. Returns ------- @@ -564,7 +569,7 @@ def cashflows( The object to project the relevant forward and spot FX rates. base: str, optional Not used by `rate`. - vol: float, Dual, Dual2 or FXDeltaVolSmile + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface The volatility used in calculation. Returns @@ -603,7 +608,6 @@ def analytic_greeks( solver: Solver_ = NoInput(0), fx: FX_ = NoInput(0), base: str_ = NoInput(0), - local: bool = False, vol: FXVol_ = NoInput(0), ) -> dict[str, Any]: """ @@ -622,9 +626,7 @@ def analytic_greeks( The object to project the relevant forward and spot FX rates. base: str, optional Not used by `analytic_greeks`. - local: bool, - Not used by `analytic_greeks`. - vol: float, or FXDeltaVolSmile + vol: float, Dual, Dual2, FXDeltaVolSmile or FXDeltaVolSurface The volatility used in calculation. Returns @@ -649,7 +651,6 @@ def analytic_greeks( disc_curve_ccy2=_validate_obj_not_no_input(curves_[3], "curves_[3]"), fx=_validate_fx_as_forwards(fx_), base=base_, - local=local, vol=vol_, premium=NoInput(0), ) diff --git a/python/rateslib/instruments/utils.py b/python/rateslib/instruments/utils.py index 770c64c8..5f34dde6 100644 --- a/python/rateslib/instruments/utils.py +++ b/python/rateslib/instruments/utils.py @@ -131,6 +131,9 @@ def _get_fxvol_maybe_from_solver(vol_attr: FXVol_, vol: FXVol_, solver: Solver_) Try to retrieve a general vol input from a solver or the default vol object associated with instrument. + If the resolved input is a Sequence then return that directly. This aids with recursive + calculation in FXOptionStrats. + Parameters ---------- vol_attr: DualTypes, str or FXDeltaVolSmile diff --git a/python/rateslib/periods.py b/python/rateslib/periods.py index bc5b6c46..609fbeba 100644 --- a/python/rateslib/periods.py +++ b/python/rateslib/periods.py @@ -3310,21 +3310,21 @@ def __init__( expiry: datetime, delivery: datetime, payment: datetime, - strike: DualTypes | NoInput = NoInput(0), - notional: float | NoInput = NoInput(0), - option_fixing: float | NoInput = NoInput(0), - delta_type: str | NoInput = NoInput(0), - metric: str | NoInput = NoInput(0), + strike: DualTypes_ = NoInput(0), + notional: DualTypes_ = NoInput(0), + option_fixing: DualTypes_ = NoInput(0), + delta_type: str_ = NoInput(0), + metric: str_ = NoInput(0), ) -> None: self.pair: str = pair.lower() self.currency: str = self.pair[3:] self.domestic: str = self.pair[:3] - self.notional: float = defaults.notional if isinstance(notional, NoInput) else notional + self.notional: DualTypes = defaults.notional if isinstance(notional, NoInput) else notional self.strike: DualTypes | NoInput = strike self.payment: datetime = payment self.delivery: datetime = delivery self.expiry: datetime = expiry - self.option_fixing: float | NoInput = option_fixing + self.option_fixing: DualTypes_ = option_fixing self.delta_type: str = _drb(defaults.fx_delta_type, delta_type).lower() self.metric: str | NoInput = metric @@ -3476,7 +3476,6 @@ def rate( disc_curve_ccy2: Curve, fx: FX_ = NoInput(0), base: str_ = NoInput(0), - local: bool = False, vol: FXVolOption_ = NoInput(0), metric: str_ = NoInput(0), ) -> DualTypes: @@ -3493,8 +3492,6 @@ def rate( The object to project the currency pair FX rate at delivery. base: str, optional Not used by `rate`. - local: bool, - Not used by `rate`. vol: float, Dual, Dual2 The percentage log-normal volatility to price the option. metric: str in {"pips", "percent"} @@ -3599,7 +3596,6 @@ def analytic_greeks( disc_curve_ccy2: Curve, fx: FXForwards, base: str_ = NoInput(0), - local: bool = False, vol: FXVolOption_ = NoInput(0), premium: DualTypes_ = NoInput(0), # expressed in the payment currency ) -> dict[str, Any]: @@ -3616,8 +3612,6 @@ def analytic_greeks( The object to project the relevant forward and spot FX rates. base: str, optional Not used by `analytic_greeks`. - local: bool, - Not used by `analytic_greeks`. vol: float, Dual, Dual2, FXDeltaVolSmile, FXDeltaVolSurface The volatility used in calculation. premium: float, Dual, Dual2, optional diff --git a/python/rateslib/typing.py b/python/rateslib/typing.py index dec53fc1..87df9214 100644 --- a/python/rateslib/typing.py +++ b/python/rateslib/typing.py @@ -53,6 +53,9 @@ from rateslib.periods import CreditProtectionPeriod as CreditProtectionPeriod from rateslib.periods import FixedPeriod as FixedPeriod from rateslib.periods import FloatPeriod as FloatPeriod +from rateslib.periods import FXCallPeriod as FXCallPeriod +from rateslib.periods import FXOptionPeriod as FXOptionPeriod +from rateslib.periods import FXPutPeriod as FXPutPeriod from rateslib.periods import IndexCashflow as IndexCashflow from rateslib.periods import IndexFixedPeriod as IndexFixedPeriod from rateslib.rs import ( @@ -131,6 +134,9 @@ VolInput_: TypeAlias = "str | FXDeltaVolSmile | FXDeltaVolSurface" VolInput: TypeAlias = "VolInput_ | NoInput" +FXVolStrat_: TypeAlias = "Sequence[FXVolStrat_] | FXVol_" +ListFXVol_: TypeAlias = "list[ListFXVol_ | FXVol_]" + FX: TypeAlias = "DualTypes | FXRates | FXForwards" FX_: TypeAlias = "FX | NoInput" diff --git a/python/tests/test_instruments.py b/python/tests/test_instruments.py index e4d79083..e5472c99 100644 --- a/python/tests/test_instruments.py +++ b/python/tests/test_instruments.py @@ -5453,10 +5453,10 @@ class TestFXBrokerFly: @pytest.mark.parametrize( ("strike", "ccy"), [ - ([1.024, 1.0683, 1.116], "usd"), - (["-20d", "atm_delta", "20d"], "usd"), - ([1.024, 1.0683, 1.116], "eur"), - (["-20d", "atm_delta", "20d"], "eur"), + ([[1.024, 1.116], 1.0683], "usd"), + ([["-20d", "20d"], "atm_delta"], "usd"), + ([[1.024, 1.116], 1.0683], "eur"), + ([["-20d", "20d"], "atm_delta"], "eur"), ], ) @pytest.mark.parametrize("smile", [True, False]) @@ -5494,10 +5494,10 @@ def test_fxbf_rate(self, fxfo, delta_type, strike, ccy, smile) -> None: @pytest.mark.parametrize( ("strike", "ccy"), [ - ([1.024, 1.0683, 1.116], "usd"), - (["-20d", "atm_delta", "20d"], "usd"), - ([1.0228, 1.0683, 1.1147], "eur"), - (["-20d", "atm_delta", "20d"], "eur"), + ([[1.024, 1.116], 1.0683], "usd"), + ([["-20d", "20d"], "atm_delta"], "usd"), + ([[1.0228, 1.1147], 1.0683], "eur"), + ([["-20d", "20d"], "atm_delta"], "eur"), ], ) @pytest.mark.parametrize("smile", [True]) @@ -5533,10 +5533,10 @@ def test_fxbf_rate_pips(self, fxfo, strike, ccy, smile) -> None: @pytest.mark.parametrize( ("strike", "ccy"), [ - ([1.024, 1.0683, 1.116], "usd"), - (["-20d", "atm_delta", "20d"], "usd"), - ([1.024, 1.06668, 1.116], "eur"), - (["-20d", "atm_delta", "20d"], "eur"), + ([[1.024, 1.116], 1.0683], "usd"), + ([["-20d", "20d"], "atm_delta"], "usd"), + ([[1.024, 1.116], 1.06668], "eur"), + ([["-20d", "20d"], "atm_delta"], "eur"), ], ) def test_fxbf_rate_premium(self, fxfo, strike, ccy) -> None: @@ -5572,7 +5572,7 @@ def test_bf_rate_vols_list(self, fxfo) -> None: pair="eurusd", expiry=dt(2023, 6, 16), notional=[20e6, -13.5e6], - strike=("-20d", "atm_delta", "20d"), + strike=(("-20d", "20d"), "atm_delta"), payment_lag=2, delivery_lag=2, calendar="tgt", @@ -5582,7 +5582,7 @@ def test_bf_rate_vols_list(self, fxfo) -> None: result = fxbf.rate( curves=[None, fxfo.curve("eur", "usd"), None, fxfo.curve("usd", "usd")], fx=fxfo, - vol=[10.15, 1.0, 8.9], + vol=[[10.15, 8.9], 1.0], ) expected = 8.539499 assert abs(result - expected) < 1e-6 @@ -5590,7 +5590,7 @@ def test_bf_rate_vols_list(self, fxfo) -> None: result = fxbf.rate( curves=[None, fxfo.curve("eur", "usd"), None, fxfo.curve("usd", "usd")], fx=fxfo, - vol=[10.15, 7.8, 8.9], + vol=[[10.15, 8.9], 7.8], metric="pips_or_%", ) expected = -110.098920 @@ -5608,8 +5608,8 @@ def test_bf_rate_vols_list(self, fxfo) -> None: @pytest.mark.parametrize( "strikes", [ - ("-20d", "atm_delta", "20d"), - (1.0238746345527665, 1.0683288279019205, 1.1159199351325004), + (("-20d", "20d"), "atm_delta"), + ((1.0238746345527665, 1.1159199351325004), 1.0683288279019205), ], ) def test_greeks_delta_direction(self, fxfo, notn, expected_grks, expected_ccy, strikes) -> None: @@ -5665,7 +5665,7 @@ def test_single_vol_definition(self, fxfo) -> None: curves=[None, fxfo.curve("eur", "usd"), None, fxfo.curve("usd", "usd")], delta_type="forward", premium_ccy="usd", - strike=["-20d", "atm_delta", "20d"], + strike=[["-20d", "20d"], "atm_delta"], vol=fxvs, ) result = fxo.rate(metric="single_vol", fx=fxfo) @@ -5680,7 +5680,7 @@ def test_repr(self): payment_lag=dt(2023, 6, 20), delta_type="forward", premium_ccy="usd", - strike=["-20d", "atm_delta", "20d"], + strike=[["-20d", "20d"], "atm_delta"], ) expected = f"" assert expected == fxo.__repr__() diff --git a/python/tests/test_solver.py b/python/tests/test_solver.py index 7e5c5a4a..9633ec16 100644 --- a/python/tests/test_solver.py +++ b/python/tests/test_solver.py @@ -2015,10 +2015,10 @@ def test_solver_with_surface() -> None: instruments.extend( [ FXStraddle(strike="atm_delta", expiry=row[0], **fx_args), - FXRiskReversal(strike=["-25d", "25d"], expiry=row[0], **fx_args), - FXBrokerFly(strike=["-25d", "atm_delta", "25d"], expiry=row[0], **fx_args), - FXRiskReversal(strike=["-10d", "10d"], expiry=row[0], **fx_args), - FXBrokerFly(strike=["-10d", "atm_delta", "10d"], expiry=row[0], **fx_args), + FXRiskReversal(strike=("-25d", "25d"), expiry=row[0], **fx_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), expiry=row[0], **fx_args), + FXRiskReversal(strike=("-10d", "10d"), expiry=row[0], **fx_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), expiry=row[0], **fx_args), ], ) s.extend([row[1], row[2], row[3], row[4], row[5]]) @@ -2309,10 +2309,10 @@ def test_solver_auto_updates_fx_before_state_setting(self): 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), + FXRiskReversal(strike=("-25d", "25d"), **option_args), + FXRiskReversal(strike=("-10d", "10d"), **option_args), + FXBrokerFly(strike=(("-25d", "25d"), "atm_delta"), **option_args), + FXBrokerFly(strike=(("-10d", "10d"), "atm_delta"), **option_args), ], s=[3.90, 5.32, 8.85, 5.493, -0.157, -0.289, 0.071, 0.238], fx=fxf,