From b92b4af625ff2e4576749284013d3672d4a32f29 Mon Sep 17 00:00:00 2001 From: JHM Darbyshire <24256554+attack68@users.noreply.github.com> Date: Sat, 7 Mar 2026 13:24:12 +0100 Subject: [PATCH] REF: Restructure `_BaseIRSmile` (#330) Co-authored-by: Mike Lync Co-authored-by: JHM Darbyshire (M1) (cherry picked from commit 58d1efc065e856c088cf0a7f4260ab995c782f3f) --- python/rateslib/__init__.py | 2 + python/rateslib/enums/parameters.py | 40 +- .../instruments/ir_options/call_put.py | 45 +-- .../instruments/ir_options/straddle.py | 8 +- .../instruments/ir_options/vol_value.py | 150 +++++-- python/rateslib/local_types.py | 14 + python/rateslib/periods/ir_volatility.py | 9 +- .../periods/protocols/analytic_greeks.py | 1 + python/rateslib/periods/utils.py | 7 +- python/rateslib/solver.py | 18 +- python/rateslib/volatility/__init__.py | 4 + python/rateslib/volatility/ir/__init__.py | 5 +- python/rateslib/volatility/ir/base.py | 369 +++++++++++++++++- python/rateslib/volatility/ir/sabr.py | 232 +++++------ python/rateslib/volatility/ir/spline.py | 302 ++++++++++++++ python/rateslib/volatility/ir/utils.py | 242 +++++++++++- python/rateslib/volatility/utils.py | 99 ++++- .../instruments/test_instruments_legacy.py | 4 +- python/tests/periods/test_periods_legacy.py | 1 - python/tests/serialization/test_json.py | 1 - python/tests/test_ir_volatility.py | 60 ++- rust/enums/parameters.rs | 6 +- rust/enums/py/ir_option_metric.rs | 33 +- 23 files changed, 1353 insertions(+), 299 deletions(-) create mode 100644 python/rateslib/volatility/ir/spline.py diff --git a/python/rateslib/__init__.py b/python/rateslib/__init__.py index 8b3ec3eb..c70781bf 100644 --- a/python/rateslib/__init__.py +++ b/python/rateslib/__init__.py @@ -191,6 +191,7 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] FXSabrSurface, IRSabrCube, IRSabrSmile, + IRSplineSmile, ) # module level doc-string @@ -275,6 +276,7 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] # volatility/ir "IRSabrSmile", "IRSabrCube", + "IRSplineSmile", # solver.py "Solver", # fx.py diff --git a/python/rateslib/enums/parameters.py b/python/rateslib/enums/parameters.py index 991c0b4c..689d830e 100644 --- a/python/rateslib/enums/parameters.py +++ b/python/rateslib/enums/parameters.py @@ -39,6 +39,15 @@ class FXOptionMetric(Enum): Percent = 1 +class OptionPricingModel(Enum): + """ + Enumerable type for option pricing models + """ + + Black76 = 0 + Bachelier = 1 + + class SwaptionSettlementMethod(Enum): """ Enumerable type for swaption settlement methods. @@ -107,6 +116,34 @@ def __str__(self) -> str: return self.name +_OPTION_PRICING_MAP = { + "black76": OptionPricingModel.Black76, + "bachelier": OptionPricingModel.Bachelier, + # aliases + "black": OptionPricingModel.Black76, + "log_normal": OptionPricingModel.Black76, + "normal": OptionPricingModel.Bachelier, + "normal_vol": OptionPricingModel.Bachelier, + "log_normal_vol": OptionPricingModel.Black76, + "black_vol": OptionPricingModel.Black76, + "black_vol_shift": OptionPricingModel.Black76, +} + + +def _get_option_pricing_model( + method: str | OptionPricingModel, +) -> OptionPricingModel: + if isinstance(method, OptionPricingModel): + return method + else: + try: + return _OPTION_PRICING_MAP[method.lower()] + except KeyError: + raise ValueError( + f"`pricing_model` as string: '{method}' is not a valid option. Please consult docs." + ) + + _SWAPTION_SETTLEMENT_MAP = { "physical": SwaptionSettlementMethod.Physical, "cash_par_tenor": SwaptionSettlementMethod.CashParTenor, @@ -286,13 +323,11 @@ def _get_fx_option_metric(method: str | FXOptionMetric) -> FXOptionMetric: _IR_METRIC_MAP: dict[str, type[IROptionMetric]] = { "normal_vol": IROptionMetric.NormalVol, - "log_normal_vol": IROptionMetric.LogNormalVol, "cash": IROptionMetric.Cash, "percent_notional": IROptionMetric.PercentNotional, "black_vol_shift": IROptionMetric.BlackVolShift, # aliases "normalvol": IROptionMetric.NormalVol, - "lognormalvol": IROptionMetric.LogNormalVol, "percentnotional": IROptionMetric.PercentNotional, "blackvolshift": IROptionMetric.BlackVolShift, } @@ -334,5 +369,6 @@ def _get_ir_option_metric(method: str | IROptionMetric) -> IROptionMetric: "LegMtm", "LegIndexBase", "OptionType", + "OptionPricingModel", "IndexMethod", ] diff --git a/python/rateslib/instruments/ir_options/call_put.py b/python/rateslib/instruments/ir_options/call_put.py index 76d6829f..afe86294 100644 --- a/python/rateslib/instruments/ir_options/call_put.py +++ b/python/rateslib/instruments/ir_options/call_put.py @@ -12,7 +12,6 @@ from __future__ import annotations from abc import ABCMeta -from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING @@ -39,7 +38,7 @@ _get_ir_vol_value_and_forward_maybe_from_obj, ) from rateslib.volatility.fx import FXVolObj -from rateslib.volatility.ir import IRSabrSmile +from rateslib.volatility.ir import _BaseIRSmile from rateslib.volatility.ir.utils import _get_ir_expiry_and_payment if TYPE_CHECKING: @@ -51,7 +50,6 @@ DualTypes, DualTypes_, FXForwards_, - IROptionMetric, IRSSeries, Sequence, Solver_, @@ -61,22 +59,12 @@ _BaseIRSOptionPeriod, _BaseLeg, _IRVolOption_, + _IRVolPricingParams, datetime_, str_, ) -@dataclass -class _IRVolPricingMetrics: - """None elements are used as flags to indicate an element is not yet set.""" - - vol: DualTypes - k: DualTypes - t_e: DualTypes - f: DualTypes - shift: DualTypes - - class _BaseIROption(_BaseInstrument, metaclass=ABCMeta): """ Abstract base class for implementing *IR Swaptions*. @@ -85,14 +73,13 @@ class _BaseIROption(_BaseInstrument, metaclass=ABCMeta): :class:`~rateslib.instruments.IRPut`. """ - _pricing: _IRVolPricingMetrics + _pricing: _IRVolPricingParams @property def rate_scalar(self) -> float: if type(self.kwargs.meta["metric"]) in [ IROptionMetric.BlackVolShift, IROptionMetric.NormalVol, - IROptionMetric.LogNormalVol, ]: return 100.0 else: @@ -310,7 +297,13 @@ def _set_strike_and_vol( Pricing elements are captured and cached so they can be used later by subsequent methods. """ - _ir_price_params = _get_ir_vol_value_and_forward_maybe_from_obj( + if isinstance(vol, _BaseIRSmile): # TODO _BaseIRCube + eval_date = vol.meta.eval_date + else: + _ = _validate_obj_not_no_input(disc_curve, "disc_curve") + eval_date = _.nodes.initial + + _pricing = _get_ir_vol_value_and_forward_maybe_from_obj( rate_curve=rate_curve, index_curve=index_curve, strike=self.kwargs.leg1["strike"], @@ -318,21 +311,7 @@ def _set_strike_and_vol( irs=self._irs, tenor=self._option.ir_option_params.option_fixing.termination, expiry=self._option.ir_option_params.expiry, - ) - - if isinstance(vol, IRSabrSmile): - eval_date = vol.meta.eval_date - else: - _ = _validate_obj_not_no_input(disc_curve, "disc_curve") - eval_date = _.nodes.initial - t_e_ = self._option.ir_option_params.time_to_expiry(eval_date) - - _pricing = _IRVolPricingMetrics( - vol=_ir_price_params.vol, - k=_ir_price_params.k, - t_e=t_e_, - f=_ir_price_params.f, - shift=_ir_price_params.shift, + t_e=self._option.ir_option_params.time_to_expiry(eval_date), ) # Review section in book regarding Hyper-parameters and Solver interaction @@ -345,7 +324,7 @@ def _set_premium( rate_curve: CurveOption_, disc_curve: _BaseCurve_, index_curve: _BaseCurve_, - pricing: _IRVolPricingMetrics, + pricing: _IRVolPricingParams, ) -> None: """ Set an unspecified premium on the Option to be equal to the mid-market premium. diff --git a/python/rateslib/instruments/ir_options/straddle.py b/python/rateslib/instruments/ir_options/straddle.py index fa1ff40f..189019d1 100644 --- a/python/rateslib/instruments/ir_options/straddle.py +++ b/python/rateslib/instruments/ir_options/straddle.py @@ -130,11 +130,7 @@ def rate( vol_: FXVolStrat_ = self._parse_vol(vol) metric_: IROptionMetric = _get_ir_option_metric(_drb(self.kwargs.meta["metric"], metric)) match type(metric_): - case ( - IROptionMetric.NormalVol - | IROptionMetric.BlackVolShift - | IROptionMetric.LogNormalVol - ): + case IROptionMetric.NormalVol | IROptionMetric.BlackVolShift: weights = self.kwargs.meta["rate_weight_vol"] case IROptionMetric.Cash | IROptionMetric.PercentNotional: weights = self.kwargs.meta["rate_weight"] @@ -230,7 +226,7 @@ def _plot_payoff( curves=curves, solver=solver, fx=fx, - vol=vol__, # type: ignore[arg-type] + vol=vol__, ) if y is None: y = y_ diff --git a/python/rateslib/instruments/ir_options/vol_value.py b/python/rateslib/instruments/ir_options/vol_value.py index 5343b1c3..075d1751 100644 --- a/python/rateslib/instruments/ir_options/vol_value.py +++ b/python/rateslib/instruments/ir_options/vol_value.py @@ -15,20 +15,27 @@ from typing import TYPE_CHECKING, NoReturn from rateslib import defaults +from rateslib.curves._parsers import _validate_obj_not_no_input from rateslib.data.fixings import _get_irs_series from rateslib.enums.generics import NoInput, _drb -from rateslib.enums.parameters import OptionType -from rateslib.instruments.irs import IRS +from rateslib.enums.parameters import OptionPricingModel, OptionType, _get_ir_option_metric +from rateslib.instruments.ir_options.call_put import _BaseIROption from rateslib.instruments.protocols import _BaseInstrument from rateslib.instruments.protocols.kwargs import _KWArgs from rateslib.instruments.protocols.pricing import ( + _Curves, + _maybe_get_curve_maybe_from_solver, _maybe_get_ir_vol_maybe_from_solver, _Vol, ) from rateslib.periods.parameters import _IROptionParams +from rateslib.periods.utils import ( + _get_ir_vol_value_and_forward_maybe_from_obj, +) +from rateslib.rs import IROptionMetric from rateslib.scheduling import add_tenor -from rateslib.volatility.fx import FXVolObj from rateslib.volatility.ir import IRSabrCube, IRSabrSmile +from rateslib.volatility.utils import _OptionModelBachelier, _OptionModelBlack76 if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover @@ -39,6 +46,7 @@ IRSSeries, Solver_, VolT_, + _BaseCurve, datetime, datetime_, str_, @@ -99,8 +107,13 @@ class IRVolValue(_BaseInstrument): The ``curves`` must match the pricing for an :class:`~rateslib.instruments.IRS`, since the atm-rate is determined directly from an *IRS* instance. - Currently the only available ``metric`` is *'vol'* which returns the specific volatility value - for the index value, i.e. a strike for an :class:`~rateslib.instruments.IRS`. + The available ``metric`` are: + + - **'normal_vol'**: which returns a normal volatility in bps suitable for the Bachelier pricing + formula. + - **'black_vol_shift_{}'**: same as above but allowing an explicit shift. + - **'alpha', 'beta', 'rho', 'nu'**: returns the SABR parameters explicitly for a SABR based + pricing object. .. role:: red @@ -122,9 +135,9 @@ class IRVolValue(_BaseInstrument): The standard conventions applied to the underlying :class:`~rateslib.instruments.IRS`. eval_date: datetime, :green:`optional` If expiry is given as string tenor, use eval date to determine the date. - metric: str, :green:`optional (set as 'vol')` + metric: str, IROptionMetric, :green:`optional (set as 'normal_vol')` The default metric to return from the ``rate`` method. - vol: str, IRSabrSmile, :green:`optional` + vol: str, IRVolObj, :green:`optional` The associated object from which to determine the ``rate``. curves : _BaseCurve, str, dict, _Curves, Sequence, :green:`optional` Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. See @@ -153,9 +166,9 @@ def __init__( irs_series=irs_series, vol=self._parse_vol(vol), metric=metric, - curves=IRS._parse_curves(curves), + curves=self._parse_curves(curves), ) - default_args = dict(convention=defaults.convention, metric="vol", curves=NoInput(0)) + default_args = dict(convention=defaults.convention, metric="normal_vol", curves=NoInput(0)) self._kwargs = _KWArgs( spec=NoInput(0), user_args=user_args, @@ -187,15 +200,6 @@ def _ir_option_params(self) -> _IROptionParams: _settlement_method=defaults.ir_option_settlement, ) - def _parse_vol(self, vol: VolT_) -> _Vol: - if isinstance(vol, _Vol): - return vol - elif isinstance(vol, FXVolObj): - raise TypeError( - f"`vol` must be suitable object for IR vol pricing. Got {type(vol).__name__}" - ) - return _Vol(ir_vol=vol) - def rate( self, *, @@ -208,37 +212,109 @@ def rate( forward: datetime_ = NoInput(0), metric: str_ = NoInput(0), ) -> DualTypes: - _vol: _Vol = self._parse_vol(vol) - vol_ = _maybe_get_ir_vol_maybe_from_solver( - vol_meta=self.kwargs.meta["vol"], solver=solver, vol=_vol + ir_vol = _maybe_get_ir_vol_maybe_from_solver( + vol=self._parse_vol(vol), vol_meta=self.kwargs.meta["vol"], solver=solver ) metric_ = _drb(self.kwargs.meta["metric"], metric).lower() + del metric - if metric_ == "vol": - if isinstance(vol_, (IRSabrSmile, IRSabrCube)): - return vol_.get_from_strike( - k=self.kwargs.leg1["strike"], - curves=curves, - expiry=self.kwargs.leg1["expiry"], - tenor=self._ir_option_params.option_fixing.termination, - ).vol - else: - raise ValueError("`vol` as an object must be provided for VolValue.") - elif metric_ in ["alpha", "beta", "rho", "nu"]: - if isinstance(vol_, IRSabrSmile | IRSabrCube): - return vol_._get_sabr_param( + if metric_ in ["alpha", "beta", "rho", "nu"]: + if isinstance(ir_vol, IRSabrSmile | IRSabrCube): + return ir_vol._get_sabr_param( expiry=self.kwargs.leg1["expiry"], tenor=self._ir_option_params.option_fixing.termination, param=metric_, ) else: raise ValueError( - "A SABR parameter `metric` can only be obtained from a SABR type " - "IR Volatility object." + "A SABR parameter `metric` can only be obtained from a SABR type vol pricing " + "object." ) - raise ValueError("`metric` must be in {'vol', 'alpha', 'beta', 'rho', 'nu'}.") + _curves = self._parse_curves(curves) + rate_curve = _maybe_get_curve_maybe_from_solver( + curves=_curves, curves_meta=self.kwargs.meta["curves"], solver=solver, name="rate_curve" + ) + disc_curve: _BaseCurve = _validate_obj_not_no_input( + _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="disc_curve", + ), + name="disc_curve", + ) + index_curve: _BaseCurve = _validate_obj_not_no_input( + _maybe_get_curve_maybe_from_solver( + curves=_curves, + curves_meta=self.kwargs.meta["curves"], + solver=solver, + name="index_curve", + ), + name="index_curve", + ) + + metric__ = _get_ir_option_metric(metric_) + del metric_ + + if not hasattr(ir_vol, "get_from_strike"): + raise TypeError("`vol` for IRVolValue must be of type _BaseIRSmile or _BaseIRCube.") + + params = _get_ir_vol_value_and_forward_maybe_from_obj( + rate_curve=rate_curve, + index_curve=index_curve, + strike=self.kwargs.leg1["strike"], + ir_vol=ir_vol, + irs=self._ir_option_params.option_fixing.irs, + tenor=self._ir_option_params.option_fixing.termination, + expiry=self._ir_option_params.expiry, + t_e=ir_vol.meta._t_expiry(self._ir_option_params.expiry), # type: ignore[union-attr] + ) + + match type(metric__): + case IROptionMetric.Cash | IROptionMetric.PercentNotional: + raise ValueError( + "`metric` cannot be a cash or monetary quantity for this Instrument type" + ) + case IROptionMetric.NormalVol: + if params.pricing_model == OptionPricingModel.Bachelier: + return params.vol + else: + return _OptionModelBlack76.convert_to_bachelier( + f=params.f, k=params.k, shift=params.shift, t_e=params.t_e, vol=params.vol + ) + case IROptionMetric.BlackVolShift: + params = ir_vol.get_from_strike( + k=self.kwargs.leg1["strike"], + curves=curves, + expiry=self.kwargs.leg1["expiry"], + tenor=self._ir_option_params.option_fixing.termination, + ) + shift = metric__.shift() + if params.pricing_model == OptionPricingModel.Bachelier: + return _OptionModelBachelier.convert_to_black76( + f=params.f, k=params.k, shift=shift, t_e=params.t_e, vol=params.vol + ) + else: + return _OptionModelBlack76.convert_to_new_shift( + f=params.f, + k=params.k, + old_shift=params.shift, + target_shift=shift, + t_e=params.t_e, + vol=params.vol, + ) + case _: + raise RuntimeError( # pragma: no cover + "Unexpected error: unmapped IROptionMetric branch - please report." + ) + + def _parse_curves(self, curves: CurvesT_) -> _Curves: + return _BaseIROption._parse_curves(curves) + + def _parse_vol(self, vol: VolT_) -> _Vol: + return _BaseIROption._parse_vol(vol) def npv(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError( diff --git a/python/rateslib/local_types.py b/python/rateslib/local_types.py index 3548b814..4e08c793 100644 --- a/python/rateslib/local_types.py +++ b/python/rateslib/local_types.py @@ -13,6 +13,7 @@ # It avoids all circular import by performing a TYPE_CHECKING check on any component. from collections.abc import Callable as Callable +from collections.abc import Iterable as Iterable from collections.abc import Sequence as Sequence from datetime import datetime as datetime from typing import Any as Any @@ -47,6 +48,7 @@ from rateslib.enums.parameters import FXOptionMetric as FXOptionMetric from rateslib.enums.parameters import IndexMethod as IndexMethod from rateslib.enums.parameters import IROptionMetric as IROptionMetric +from rateslib.enums.parameters import OptionPricingModel as OptionPricingModel from rateslib.enums.parameters import OptionType as OptionType from rateslib.enums.parameters import SpreadCompoundMethod as SpreadCompoundMethod from rateslib.enums.parameters import SwaptionSettlementMethod as SwaptionSettlementMethod @@ -120,6 +122,7 @@ from rateslib.volatility import FXSabrSurface as FXSabrSurface from rateslib.volatility import IRSabrCube as IRSabrCube from rateslib.volatility import IRSabrSmile as IRSabrSmile +from rateslib.volatility import _IRVolPricingParams as _IRVolPricingParams CurveInterpolator: TypeAlias = "FlatBackwardInterpolator | FlatForwardInterpolator | LinearInterpolator | LogLinearInterpolator | LinearZeroRateInterpolator | NullInterpolator" @@ -252,6 +255,17 @@ # ) +class SupportsSolverMutability(Protocol): + @property + def _n(self) -> int: ... + @property + def _ini_solve(self) -> int: ... + def _set_ad_order(self, ad: int) -> None: ... + def _set_node_vector(self, vector: Arr1dObj, ad: int) -> None: ... + def _get_node_vars(self) -> tuple[str, ...]: ... + def _get_node_vector(self) -> Arr1dObj: ... + + class SupportsRate(Protocol): def rate(self, *args: Any, **kwargs: Any) -> DualTypes: ... diff --git a/python/rateslib/periods/ir_volatility.py b/python/rateslib/periods/ir_volatility.py index b777ba96..1c9a1a77 100644 --- a/python/rateslib/periods/ir_volatility.py +++ b/python/rateslib/periods/ir_volatility.py @@ -265,14 +265,14 @@ def _unindexed_reference_cashflow_elements( irs=self.ir_option_params.option_fixing.irs, expiry=self.ir_option_params.expiry, tenor=self.ir_option_params.option_fixing.termination, + t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), ) - t_e = self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial) # time to expiry expected = ( _OptionModelBlack76._value( F=pricing_.f + pricing_.shift, K=pricing_.k + pricing_.shift, - t_e=t_e, + t_e=pricing_.t_e, v2=1.0, # not required vol=pricing_.vol / 100.0, phi=self.ir_option_params.direction.value, # controls calls or put price @@ -407,6 +407,7 @@ def rate( irs=self.ir_option_params.option_fixing.irs, expiry=self.ir_option_params.expiry, tenor=self.ir_option_params.option_fixing.termination, + t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), ) else: pricing_ = pricing @@ -428,7 +429,7 @@ def s(g: DualTypes) -> DualTypes: return _OptionModelBachelier._value( F=pricing_.f, K=pricing_.k, - t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), + t_e=pricing_.t_e, v2=1.0, vol=g, phi=self.ir_option_params.direction.value, @@ -466,7 +467,7 @@ def s(g: DualTypes) -> DualTypes: return _OptionModelBlack76._value( F=pricing_.f + float(required_shift) / 100.0, K=pricing_.k + float(required_shift) / 100.0, - t_e=self.ir_option_params.time_to_expiry(disc_curve_.nodes.initial), + t_e=pricing_.t_e, v2=1.0, vol=g, phi=self.ir_option_params.direction.value, diff --git a/python/rateslib/periods/protocols/analytic_greeks.py b/python/rateslib/periods/protocols/analytic_greeks.py index 855b60b8..03867465 100644 --- a/python/rateslib/periods/protocols/analytic_greeks.py +++ b/python/rateslib/periods/protocols/analytic_greeks.py @@ -666,6 +666,7 @@ def _base_analytic_greeks( irs=self.ir_option_params.option_fixing.irs, expiry=self.ir_option_params.expiry, tenor=self.ir_option_params.option_fixing.termination, + t_e=sqrt_t**2, ) vol_sqrt_t = pricing_.vol / 100.0 * sqrt_t d_plus = _OptionModelBlack76._d_plus_min_u(pricing_.k / pricing_.f, vol_sqrt_t, 0.5) diff --git a/python/rateslib/periods/utils.py b/python/rateslib/periods/utils.py index 1ab34ebd..e2d45a0b 100644 --- a/python/rateslib/periods/utils.py +++ b/python/rateslib/periods/utils.py @@ -19,7 +19,7 @@ from rateslib.curves._parsers import _validate_obj_not_no_input from rateslib.curves.curves import _BaseCurve from rateslib.enums.generics import Err, NoInput, Ok, Result -from rateslib.enums.parameters import FXDeltaMethod +from rateslib.enums.parameters import FXDeltaMethod, OptionPricingModel from rateslib.fx import FXForwards, FXRates from rateslib.instruments.protocols.pricing import _Curves from rateslib.volatility import ( @@ -156,6 +156,7 @@ def _get_ir_vol_value_and_forward_maybe_from_obj( irs: IRS, expiry: datetime, tenor: datetime, + t_e: DualTypes, ) -> _IRVolPricingParams: """ Return the following pring requirements: @@ -192,7 +193,9 @@ def _get_ir_vol_value_and_forward_maybe_from_obj( raise ValueError("`ir_vol` cannot be NoInput when provided to pricing function.") else: # vol given as scalar interpolated as Black Vol Zero shifted - return _IRVolPricingParams(vol=ir_vol, f=f_, k=k_, shift=0.0) + return _IRVolPricingParams( + vol=ir_vol, f=f_, k=k_, shift=0.0, pricing_model=OptionPricingModel.Black76, t_e=t_e + ) def _get_fx_vol_value_maybe_from_obj( diff --git a/python/rateslib/solver.py b/python/rateslib/solver.py index 62d55e8a..f3f043a1 100644 --- a/python/rateslib/solver.py +++ b/python/rateslib/solver.py @@ -59,15 +59,11 @@ Any, Callable, DualTypes, - FXDeltaVolSmile, - FXDeltaVolSurface, FXForwards_, - FXSabrSmile, - FXSabrSurface, Sequence, SupportsRate, + SupportsSolverMutability, Variable, - _FXVolObj, str_, ) @@ -1072,8 +1068,8 @@ class Solver(Gradients, _WithState): def __init__( self, - curves: Sequence[Curve | FXDeltaVolSmile | FXSabrSmile] = (), - surfaces: Sequence[FXDeltaVolSurface | FXSabrSurface] = (), + curves: Sequence[Any] = (), + surfaces: Sequence[Any] = (), instruments: Sequence[SupportsRate] = (), s: Sequence[DualTypes] = (), weights: Sequence[float] | NoInput = NoInput(0), @@ -1135,7 +1131,7 @@ def __init__( self.W = np.diag(self.weights) # `surfaces` are treated identically to `curves`. Introduced in PR - self.curves = { + self.curves: dict[str, SupportsSolverMutability] = { curve.id: curve for curve in list(curves) + list(surfaces) if type(curve) not in NO_PARAMETER_CURVES @@ -1147,13 +1143,13 @@ def __init__( self.n = len(self.variables) # aggregate and organise variables and labels including pre_solvers - self.pre_curves: dict[str, Curve | _FXVolObj] = {} + self.pre_curves: dict[str, Any] = {} self.pre_variables: tuple[str, ...] = () self.pre_instrument_labels: tuple[tuple[str, str], ...] = () self.pre_instruments: tuple[tuple[SupportsRate, dict[str, Any]], ...] = () self.pre_rate_scalars = [] self.pre_m, self.pre_n = self.m, self.n - curve_collection: list[Curve | _FXVolObj] = [] + curve_collection: list[Any] = [] for pre_solver in self.pre_solvers: self.pre_variables += pre_solver.pre_variables self.pre_instrument_labels += pre_solver.pre_instrument_labels @@ -1660,7 +1656,7 @@ def _update_curves_with_parameters(self, v_new: NDArray[Nobject]) -> None: # this was amended in PR126 as performance improvement to keep consistent `vars` # and was restructured in PR## to decouple methods to accomodate vol surfaces n_vars = curve._n - curve._ini_solve - curve._set_node_vector(v_new[var_counter : var_counter + n_vars], self._ad) # type: ignore[arg-type] + curve._set_node_vector(v_new[var_counter : var_counter + n_vars], self._ad) var_counter += n_vars self._update_fx() diff --git a/python/rateslib/volatility/__init__.py b/python/rateslib/volatility/__init__.py index 79123517..95d21e69 100644 --- a/python/rateslib/volatility/__init__.py +++ b/python/rateslib/volatility/__init__.py @@ -25,9 +25,11 @@ from rateslib.volatility.ir import ( IRSabrCube, IRSabrSmile, + IRSplineSmile, _BaseIRSmile, _IRSabrCubeMeta, _IRSmileMeta, + _IRVolPricingParams, ) __all__ = [ @@ -37,6 +39,7 @@ "FXDeltaVolSmile", "IRSabrSmile", "IRSabrCube", + "IRSplineSmile", "_BaseFXSmile", "_BaseIRSmile", "_FXDeltaVolSurfaceMeta", @@ -47,4 +50,5 @@ "_SabrSmileNodes", "_IRSabrCubeMeta", "_IRSmileMeta", + "_IRVolPricingParams", ] diff --git a/python/rateslib/volatility/ir/__init__.py b/python/rateslib/volatility/ir/__init__.py index 68aa2c7f..ccb0d1c6 100644 --- a/python/rateslib/volatility/ir/__init__.py +++ b/python/rateslib/volatility/ir/__init__.py @@ -12,14 +12,17 @@ from rateslib.volatility.ir.base import _BaseIRSmile from rateslib.volatility.ir.sabr import IRSabrCube, IRSabrSmile -from rateslib.volatility.ir.utils import _IRSabrCubeMeta, _IRSmileMeta +from rateslib.volatility.ir.spline import IRSplineSmile +from rateslib.volatility.ir.utils import _IRSabrCubeMeta, _IRSmileMeta, _IRVolPricingParams __all__ = [ "IRSabrSmile", + "IRSplineSmile", "IRSabrCube", "_BaseIRSmile", "_IRSmileMeta", "_IRSabrCubeMeta", + "_IRVolPricingParams", ] IRVols = IRSabrSmile | IRSabrCube diff --git a/python/rateslib/volatility/ir/base.py b/python/rateslib/volatility/ir/base.py index b1a082ff..554e656e 100644 --- a/python/rateslib/volatility/ir/base.py +++ b/python/rateslib/volatility/ir/base.py @@ -12,36 +12,276 @@ from __future__ import annotations # type hinting +from abc import ABC, abstractmethod from typing import TYPE_CHECKING, NoReturn, TypeAlias +import numpy as np + from rateslib.default import PlotOutput, plot from rateslib.dual import Dual, Dual2, Variable +from rateslib.dual.utils import _dual_float from rateslib.enums.generics import NoInput, _drb -from rateslib.mutability import _WithCache, _WithState +from rateslib.enums.parameters import OptionPricingModel +from rateslib.mutability import _clear_cache_post, _new_state_post, _WithCache, _WithState from rateslib.volatility.ir.utils import _IRSmileMeta if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover + Any, CurvesT_, + DualTypes_, + Iterable, + _IRVolPricingParams, + datetime_, + float_, ) DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" # if not defined causes _WithCache failure -class _BaseIRSmile(_WithState, _WithCache[float, DualTypes]): - """Abstract base class for implementing *IR Smiles*.""" +class _WithMutability(ABC): + """Abstract base class containing the necessary methods to interoperate with a + :class:`~rateslib.solver.Solver`.""" + + # Get methods allow the Solver to extract and order the parameters of the pricing object. + + @property + @abstractmethod + def _n(self) -> int: + """The number of parameters associated with the pricing object.""" + pass + + @property + @abstractmethod + def _ini_solve(self) -> int: + """The number of parameters that are initially ignored by + :class:`~rateslib.solver.Solver` and not mutated during iterations.""" + pass + + @abstractmethod + def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: + """Get a 1d array of variables associated with nodes of this object updated by Solver""" + pass + + @abstractmethod + def _get_node_vars(self) -> tuple[str, ...]: + """Get the variable names of elements updated by a Solver""" + pass + + # Set methods allow the Solver to make mutable updates to the pricing object + # Direct methods implement the underlying operations, wrapped methods (which are + # automatically provided) control additionals such as cache clearing and state management. + + @abstractmethod + def _set_node_vector_direct( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: + """ + Allow Solver to update parameter values of the pricing object. + ``ad`` in {1, 2}. + Only the real values in vector are used, dual components are dropped and restructured. + """ + pass + + @abstractmethod + def _set_ad_order_direct(self, order: int | None) -> None: + """ + Update the parameter values of the pricing object. + + None: Do nothing regardless of the AD order of the parameters as stated. + 0: Convert all values to float. + 1: Convert to Dual with vars ordered by `_get_node_vars` + 2: Convert to Dual2 with vars ordered by `_get_node_vars` + """ + pass + + @abstractmethod + def _set_single_node(self, key: Any, value: DualTypes) -> None: + """ + Update a single named node on the pricing object. + """ + pass + + @_new_state_post + @_clear_cache_post + def _set_node_vector( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: + """ + Update the node values in a Solver. ``ad`` in {1, 2}. + Only the real values in vector are used, dual components are dropped and restructured. + """ + return self._set_node_vector_direct(vector, ad) + + @_clear_cache_post + def _set_ad_order(self, order: int | None) -> None: + """This does not alter the beta node, since that is not varied by a Solver. + beta values that are AD sensitive should be given as a Variable and not Dual/Dual2. + + Using `None` allows this Smile to be constructed without overwriting any variable names. + """ + return self._set_ad_order_direct(order) + + @_new_state_post + @_clear_cache_post + def update_node(self, key: str, value: DualTypes) -> None: + """ + Update a single node value on the *Smile*. + + Parameters + ---------- + key: str in {"alpha", "beta", "rho", "nu"} + The node value to update. + value: float, Dual, Dual2, Variable + Value to update on the *Smile*. + + Returns + ------- + None + + Notes + ----- + + .. warning:: + + *Rateslib* is an object-oriented library that uses complex associations. Although + Python may not object to directly mutating attributes of a *Curve* instance, this + should be avoided in *rateslib*. Only use official ``update`` methods to mutate the + values of an existing *Curve* instance. + This class is labelled as a **mutable on update** object. + + """ + return self._set_single_node(key, value) + + +class _BaseIRSmile(_WithState, _WithCache[float, DualTypes], ABC): + """ + Abstract base class for implementing *IR Smiles*. + + Any :class:`~rateslib.volatility._BaseIRSmile` is required to implement the following + **properties**: + + - **ad** (int) + - **meta** (:class:`~rateslib.volatility._IRSmileMeta`) + - **params** (tuple[float | Dual | Dual2 | Variable]) + + Any :class:`~rateslib.volatility._BaseIRSmile` is required to implement the following + **methods**: + + - **_plot(x_axis, f, y_axis, curves)** + - **_get_from_strike(k, f, curves)** + + The directly provided methods with these implementations are: + + - :meth:`~rateslib.volatility._BaseIRSmile.plot`. + - :meth:`~rateslib.volatility._BaseIRSmile.get_from_strike`. + + """ - _ad: int _default_plot_x_axis: str - meta: _IRSmileMeta @property + @abstractmethod def ad(self) -> int: - """Int in {0,1,2} describing the AD order associated with the *Smile*.""" - return self._ad + """Int in {0,1,2} describing the AD order associated with the + :class:`~rateslib.volatility._BaseIRSmile`.""" + pass - def __iter__(self) -> NoReturn: - raise TypeError("`Smile` types are not iterable.") + @property + @abstractmethod + def meta(self) -> _IRSmileMeta: + """An instance of :class:`~rateslib.volatility.ir.utils._IRSmileMeta`.""" + pass + + @property + @abstractmethod + def pricing_params(self) -> Iterable[float | Dual | Dual2 | Variable]: + """An ordered set of pricing parameters associated with the + :class:`~rateslib.volatility._BaseIRSmile`.""" + pass + + @abstractmethod + def _get_from_strike( + self, + k: DualTypes, + f: DualTypes, + ) -> _IRVolPricingParams: + """ + Given an option strike and forward rate return the volatility. + + Note this function does not validate the expiry and tenor of the intended option. + + Parameters + ----------- + k: float, Dual, Dual2 + The strike of the option. + f: float, Dual, Dual2 + The forward rate at delivery of the option. + + Returns + ------- + _IRVolPricingParams + """ + pass + + @abstractmethod + def _plot( + self, + x_axis: str, + f: float, + y_axis: str, + tgt_shift: float_, + ) -> tuple[Iterable[float], Iterable[float]]: + """Perform the necessary calculation to derive (x,y) coordinates for a chart.""" + pass + + def _plot_conversion( + self, + y_axis: str, + x_axis: str, + f: float, + shift: float, + tgt_shift: float, + x: Iterable[float], + y: Iterable[float], + ) -> tuple[Iterable[float], Iterable[float]]: + # def _hagan_convert(k: DualTypes, sigma_b: DualTypes) -> DualTypes: + # if abs(f - k) < 1e-13: + # center = f + shf + # else: + # center = (f - k) / dual_log((f + shf) / (k + shf)) + # return sigma_b * center * (1 - sigma_b ** 2 * sq_t / 24) + + match (self.meta.pricing_model, y_axis.lower()): + case (OptionPricingModel.Black76, "black_vol"): + if shift == tgt_shift: + y_ = y + else: + y_ = [ + _ + * (((f + shift) * (k + shift)) / ((f + tgt_shift) * (k + tgt_shift))) ** 0.5 + for _, k in zip(y, x, strict=True) + ] + case (OptionPricingModel.Bachelier, "normal_vol"): + y_ = y + case (OptionPricingModel.Black76, "normal_vol"): + y_ = [ + sigma_b * ((f + shift) * (k + shift)) ** 0.5 + for (k, sigma_b) in zip(x, y, strict=True) + ] + case (OptionPricingModel.Bachelier, "black_vol"): + y_ = [ + sigma_n * ((f + tgt_shift) * (k + tgt_shift)) ** -0.5 + for (k, sigma_n) in zip(x, y, strict=True) + ] + case _: + raise ValueError("`y_axis` must be in {'normal_vol', 'black_vol'}.") + + if x_axis == "moneyness": + u: Iterable[float] = x / f # type: ignore[operator, assignment] + return u, y_ + else: # x_axis = "strike" + return x, y_ def plot( self, @@ -51,8 +291,9 @@ def plot( y_axis: str | NoInput = NoInput(0), f: DualTypes | NoInput = NoInput(0), curves: CurvesT_ = NoInput(0), + shift: float_ = NoInput(0), ) -> PlotOutput: - """ + r""" Plot volatilities associated with the *Smile*. .. role:: green @@ -70,18 +311,49 @@ def plot( *'strike'* is the natural option for this *SabrSmile*. If *'moneyness'* the strikes are converted using ``f``. y_axis : str in {"black_vol", "normal_vol"}, :green:`optional (set by object)` - Convert the y-axis to a different representation using the Hagan approximation from - `Managing Smile Risk `__ + Convert the y-axis to a different representation using an approximation. f: DualTypes, :green:`optional` The mid-market IRS rate. If ``curves`` are not given then ``f`` is required. curves: Curves, :green:`optional` The *Curves* in the required form for an :class:`~rateslib.instruments.IRS`. If ``f`` is not given then ``curves`` are required. + shift: float, :green:`optional` + If plotting a *'black_vol'* this will use an approximation to convert any native + shift into another that is specified here. If not given uses the native shift meta + attribute of the *Smile*. Returns ------- (fig, ax, line) : Matplotlib.Figure, Matplotplib.Axes, Matplotlib.Lines2D - """ + + Notes + ----- + Any approximations converting between *normal* and *black* vol are done so with the + first order approximation generally attributable to Fei Zhou. These approximations are only + used for charting. Actual instrument pricing metrics are determined more accurately + with root solvers. + + .. math:: + + \sigma_{LN+h} \approx \frac{\sigma_{N}}{\sqrt{(F+h)(K+h)}} + + and, + + .. math:: + + \sigma_{LN+h} \approx \sigma_{LN+h2} \sqrt{ \frac{(F+h_2)(K+h_2)}{(F+h)(K+h)}} + + for *h* and :math:`h_2` potentially different shifts. + + """ # noqa: E501 + if isinstance(f, NoInput) and isinstance(curves, NoInput): + raise ValueError("`f` (ATM-forward interest rate) is required by `_BaseIRSmile.plot`.") + elif isinstance(f, float | Dual | Dual2 | Variable): + f_: float = _dual_float(f) + elif not isinstance(curves, NoInput): + f_ = _dual_float(self.meta.irs_fixing.irs.rate(curves=curves)) + del f + # reversed for intuitive strike direction comparators = _drb([], comparators) labels = _drb([], labels) @@ -90,16 +362,75 @@ def plot( y_axis_: str = _drb(self.meta.plot_y_axis, y_axis) del x_axis, y_axis - x_, y_ = self._plot(x_axis_, f, y_axis_, curves) # type: ignore[attr-defined] + x_, y_ = self._plot(x_axis_, f_, y_axis_, shift) - x = [x_] - y = [y_] + x: list[list[float]] = [list(x_)] + y: list[list[float]] = [list(y_)] if not isinstance(comparators, NoInput): for smile in comparators: if not isinstance(smile, _BaseIRSmile): raise ValueError("A `comparator` must be a valid IR Smile type.") - x_, y_ = smile._plot(x_axis_, f, y_axis_, curves) # type: ignore[attr-defined] - x.append(x_) - y.append(y_) + x_, y_ = smile._plot(x_axis_, f_, y_axis_, shift) + x.append(list(x_)) + y.append(list(y_)) return plot(x, y, labels) + + def get_from_strike( + self, + k: DualTypes, + expiry: datetime_ = NoInput(0), + tenor: datetime_ = NoInput(0), + f: DualTypes_ = NoInput(0), + curves: CurvesT_ = NoInput(0), + ) -> _IRVolPricingParams: + """ + Given an option strike return the volatility. + + Note if the ``expiry`` and ``tenor`` are given these will be validated against the + *_BaseIRSmile* *meta* parameters. + + .. role:: red + + .. role:: green + + Parameters + ----------- + k: float, Dual, Dual2, Variable, :red:`required` + The strike of the option. + expiry: datetime, :green:`optional` + The expiry of the option. Required for temporal interpolation. + tenor: datetime, :green:`optional` + The termination date of the underlying *IRS*, required for parameter interpolation. + f: float, Dual, Dual2, Variable, :green:`optional` + The forward rate at delivery of the option. + curves: _Curves, :green:`optional` + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.IRCall` + for details of allowed inputs. Required if ``f`` is not given. + + Returns + ------- + _IRVolPricingParams + """ + if not isinstance(expiry, NoInput) and self.meta.expiry != expiry: + raise ValueError( + f"`expiry` of _BaseIRSmile and intended price do not match. Got: {expiry} " + f"and {self.meta.expiry}.\nCalculation aborted due to potential pricing errors.", + ) + if not isinstance(tenor, NoInput) and self.meta.irs_fixing.termination != tenor: + raise ValueError( + f"`tenor` of _BaseIRSmile and intended price do not match. Got: {tenor} " + f"and {self.meta.irs_fixing.termination}.\nCalculation aborted due to potential " + f"pricing errors.", + ) + + if isinstance(f, NoInput): + f_: DualTypes = self.meta.irs_fixing.irs.rate(curves=curves) + else: + f_ = f + del f + + return self._get_from_strike(f=f_, k=k) + + def __iter__(self) -> NoReturn: + raise TypeError("`_BaseIRSmile` types are not iterable.") diff --git a/python/rateslib/volatility/ir/sabr.py b/python/rateslib/volatility/ir/sabr.py index b9dcf281..ce33d628 100644 --- a/python/rateslib/volatility/ir/sabr.py +++ b/python/rateslib/volatility/ir/sabr.py @@ -21,16 +21,17 @@ from rateslib.curves.interpolation import index_left from rateslib.data.fixings import IRSSeries, _get_irs_series -from rateslib.dual import Dual, Dual2, Variable, dual_log, set_order_convert +from rateslib.dual import Dual, Dual2, Variable, set_order_convert from rateslib.dual.utils import _dual_float, _to_number, dual_exp, dual_inv_norm_cdf from rateslib.enums.generics import NoInput, _drb +from rateslib.enums.parameters import OptionPricingModel from rateslib.mutability import ( _clear_cache_post, _new_state_post, _WithCache, _WithState, ) -from rateslib.volatility.ir.base import _BaseIRSmile +from rateslib.volatility.ir.base import _BaseIRSmile, _WithMutability from rateslib.volatility.ir.utils import ( _bilinear_interp, _IRSabrCubeMeta, @@ -48,14 +49,14 @@ CurvesT_, DualTypes, DualTypes_, + Iterable, Number, - Sequence, Series, - datetime_, + float_, ) -class IRSabrSmile(_BaseIRSmile): +class IRSabrSmile(_BaseIRSmile, _WithMutability): r""" Create an *IR Volatility Smile* at a given expiry indexed for a specific IRS tenor using SABR parameters. @@ -120,11 +121,6 @@ class IRSabrSmile(_BaseIRSmile): """ - _ini_solve = 1 - _meta: _IRSmileMeta - _id: str - _nodes: _SabrSmileNodes - @_new_state_post def __init__( self, @@ -141,7 +137,7 @@ def __init__( self._id: str = ( uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id ) # 1 in a million clash - self._meta = _IRSmileMeta( + self._meta: _IRSmileMeta = _IRSmileMeta( _tenor_input=tenor, _irs_series=_get_irs_series(irs_series), _eval_date=eval_date, @@ -149,6 +145,7 @@ def __init__( _plot_x_axis="strike", _plot_y_axis="black_vol", _shift=_drb(0.0, shift), + _pricing_model=OptionPricingModel.Black76, ) try: @@ -168,22 +165,22 @@ def __init__( self._set_ad_order(ad) + ### Object unique elements + @property def _n(self) -> int: - """The number of pricing parameters in ``nodes``.""" return self.nodes.n + @property + def _ini_solve(self) -> int: + return 1 + @property def id(self) -> str: """A str identifier to name the *Smile* used in :class:`~rateslib.solver.Solver` mappings.""" return self._id - @property - def meta(self) -> _IRSmileMeta: # type: ignore[override] - """An instance of :class:`~rateslib.volatility.ir.utils._IRSmileMeta`.""" - return self._meta - @property def nodes(self) -> _SabrSmileNodes: """An instance of :class:`~rateslib.volatility.utils._SabrSmileNodes`.""" @@ -215,61 +212,6 @@ def _get_sabr_param(self, expiry: datetime, tenor: datetime, param: str) -> Dual param_ = param.lower() return getattr(self.nodes, param_) # type: ignore[no-any-return] - def get_from_strike( - self, - k: DualTypes, - expiry: datetime_ = NoInput(0), - tenor: datetime_ = NoInput(0), - f: DualTypes_ = NoInput(0), - curves: CurvesT_ = NoInput(0), - ) -> _IRVolPricingParams: - """ - Given an option strike return the volatility. - - Parameters - ----------- - k: float, Dual, Dual2 - The strike of the option. - f: float, Dual, Dual2 - The forward rate at delivery of the option. - expiry: datetime, optional - The expiry of the option. Required for temporal interpolation. - tenor: datetime, optional - The termination date of the underlying *IRS*, required for parameter interpolation. - curves: _Curves, - Pricing objects. See **Pricing** on :class:`~rateslib.instruments.IRCall` - for details of allowed inputs. - - Returns - ------- - _IRVolPricingParams - """ - if isinstance(expiry, datetime) and self._meta.expiry != expiry: - raise ValueError( - "`expiry` of VolSmile and OptionPeriod do not match: calculation aborted " - "due to potential pricing errors.", - ) - - # TODO: catch whether tenor matches the tenor of the Smile and raise an error? - - if isinstance(f, NoInput): - f_: DualTypes = self.meta.irs_fixing.irs.rate(curves=curves) - else: - f_ = f - del f - - vol_ = _SabrModel._d_sabr_d_k_or_f( - _to_number(k + self.meta.rate_shift), - _to_number(f_ + self.meta.rate_shift), - self._meta.t_expiry, - self.nodes.alpha, - self.nodes.beta, - self.nodes.rho, - self.nodes.nu, - derivative=0, - )[0] - return _IRVolPricingParams(vol=vol_ * 100.0, k=k, f=f_, shift=self.meta.rate_shift) - def _d_sabr_d_k_or_f( self, k: DualTypes, @@ -308,6 +250,8 @@ def _d_sabr_d_k_or_f( return _SabrModel._d_sabr_d_k_or_f(k_, f_, t_e, a_, b_, p_, v_, derivative) + ### _WithMutability ABCs: + def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: """Get a 1d array of variables associated with nodes of this object updated by Solver""" return np.array([self.nodes.alpha, self.nodes.rho, self.nodes.nu]) @@ -316,9 +260,7 @@ def _get_node_vars(self) -> tuple[str, ...]: """Get the variable names of elements updated by a Solver""" return tuple(f"{self.id}{i}" for i in range(3)) - @_new_state_post - @_clear_cache_post - def _set_node_vector( + def _set_node_vector_direct( self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int ) -> None: """ @@ -357,8 +299,7 @@ def _set_node_vector( ), ) - @_clear_cache_post - def _set_ad_order(self, order: int | None) -> None: + def _set_ad_order_direct(self, order: int | None) -> None: """This does not alter the beta node, since that is not varied by a Solver. beta values that are AD sensitive should be given as a Variable and not Dual/Dual2. @@ -378,35 +319,7 @@ def _set_ad_order(self, order: int | None) -> None: _nu=set_order_convert(self.nodes.nu, order, [f"{self.id}2"]), ) - @_new_state_post - @_clear_cache_post - def update_node(self, key: str, value: DualTypes) -> None: - """ - Update a single node value on the *SABRSmile*. - - Parameters - ---------- - key: str in {"alpha", "beta", "rho", "nu"} - The node value to update. - value: float, Dual, Dual2, Variable - Value to update on the *Smile*. - - Returns - ------- - None - - Notes - ----- - - .. warning:: - - *Rateslib* is an object-oriented library that uses complex associations. Although - Python may not object to directly mutating attributes of a *Curve* instance, this - should be avoided in *rateslib*. Only use official ``update`` methods to mutate the - values of an existing *Curve* instance. - This class is labelled as a **mutable on update** object. - - """ + def _set_single_node(self, key: str, value: DualTypes) -> None: params = ["alpha", "beta", "rho", "nu"] if key not in params: raise KeyError(f"'{key}' is not in `nodes`.") @@ -415,59 +328,84 @@ def update_node(self, key: str, value: DualTypes) -> None: self._nodes = _SabrSmileNodes(**kwargs) self._set_ad_order(self.ad) - # Plotting + # _BaseIRSmile ABCS: def _plot( self, x_axis: str, - f: DualTypes_, + f: float, y_axis: str, - curves: CurvesT_, - ) -> tuple[list[float], list[DualTypes]]: - if isinstance(f, NoInput) and isinstance(curves, NoInput): - raise ValueError("`f` (ATM-forward FX rate) is required by `FXSabrSmile.plot`.") - elif isinstance(f, float | Dual | Dual2 | Variable): - f_: float = _dual_float(f) - elif not isinstance(curves, NoInput): - f_ = _dual_float(self.meta.irs_fixing.irs.rate(curves=curves)) - del f - - shf = self.meta.shift / 100.0 - v_ = _dual_float(self.get_from_strike(k=f_, f=f_).vol) / 100.0 + tgt_shift: float_, + ) -> tuple[Iterable[float], Iterable[float]]: + shf = _dual_float(self.meta.shift) / 100.0 + v_ = _dual_float(self.get_from_strike(k=f, f=f).vol) / 100.0 sq_t = self._meta.t_expiry_sqrt x_low = _dual_float( - dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.95) * v_ * sq_t) * (f_ + shf) - shf + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.95) * v_ * sq_t) * (f + shf) - shf ) x_top = _dual_float( - dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.05) * v_ * sq_t) * (f_ + shf) - shf + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.05) * v_ * sq_t) * (f + shf) - shf ) x = np.linspace(x_low, x_top, 301, dtype=np.float64) - u: Sequence[float] = x / f_ # type: ignore[assignment] - y: list[DualTypes] = [self.get_from_strike(k=_, f=f_).vol for _ in x] + y: Iterable[float] = [_dual_float(self.get_from_strike(k=_, f=f).vol) for _ in x] - if y_axis == "normal_vol": + return self._plot_conversion( + y_axis=y_axis, x_axis=x_axis, f=f, shift=shf, tgt_shift=_drb(shf, tgt_shift), x=x, y=y + ) - def _hagan_convert( - f: DualTypes, k: DualTypes, sigma_b: DualTypes, t_e: DualTypes - ) -> DualTypes: - if abs(f - k) < 1e-13: - center = f + shf - else: - center = (f - k) / dual_log((f + shf) / (k + shf)) - return sigma_b * center * (1 - sigma_b**2 * t_e / 24) + @property + def ad(self) -> int: + return self._ad - y_ = [ - _hagan_convert(f_, k, sigma_b / 100.0, self._meta.t_expiry) * 100.0 - for (k, sigma_b) in zip(x, y, strict=True) - ] - else: # "black_vol" - y_ = y + @property + def pricing_params(self) -> tuple[float | Dual | Dual2 | Variable, ...]: + return self.nodes.alpha, self.nodes.beta, self.nodes.rho, self.nodes.nu + + @property + def meta(self) -> _IRSmileMeta: + return self._meta + + def _get_from_strike(self, k: DualTypes, f: DualTypes) -> _IRVolPricingParams: + """ + Given an option strike return the volatility. - if x_axis == "moneyness": - return list(u), y_ - else: # x_axis = "strike" - return list(x), y_ + Parameters + ----------- + k: float, Dual, Dual2 + The strike of the option. + f: float, Dual, Dual2 + The forward rate at delivery of the option. + expiry: datetime, optional + The expiry of the option. Required for temporal interpolation. + tenor: datetime, optional + The termination date of the underlying *IRS*, required for parameter interpolation. + curves: _Curves, + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.IRCall` + for details of allowed inputs. + + Returns + ------- + _IRVolPricingParams + """ + vol_ = _SabrModel._d_sabr_d_k_or_f( + _to_number(k + self.meta.rate_shift), + _to_number(f + self.meta.rate_shift), + self._meta.t_expiry, + self.nodes.alpha, + self.nodes.beta, + self.nodes.rho, + self.nodes.nu, + derivative=0, + )[0] + return _IRVolPricingParams( + vol=vol_ * 100.0, + k=k, + f=f, + shift=self.meta.rate_shift, + pricing_model=OptionPricingModel.Black76, + t_e=self._meta.t_expiry, + ) class IRSabrCube(_WithState, _WithCache[tuple[datetime, datetime], IRSabrSmile]): @@ -896,11 +834,15 @@ def get_from_strike( """ Given an option strike, expiry and tenor, return the volatility. + .. role:: red + + .. role:: green + Parameters ----------- - k: float, Dual, Dual2 + k: float, Dual, Dual2, Variable, :red:`required` The strike of the option. - expiry: datetime, optional + expiry: datetime, :green:` The expiry of the option. Required for temporal interpolation. tenor: datetime, optional The termination date of the underlying *IRS*, required for parameter interpolation. diff --git a/python/rateslib/volatility/ir/spline.py b/python/rateslib/volatility/ir/spline.py new file mode 100644 index 00000000..dc033182 --- /dev/null +++ b/python/rateslib/volatility/ir/spline.py @@ -0,0 +1,302 @@ +# SPDX-License-Identifier: LicenseRef-Rateslib-Dual +# +# Copyright (c) 2026 Siffrorna Technology Limited +# +# Dual-licensed: Free Educational Licence or Paid Commercial Licence (commercial/professional use) +# Source-available, not open source. +# +# See LICENSE and https://rateslib.com/py/en/latest/i_licence.html for details, +# and/or contact info (at) rateslib (dot) com +#################################################################################################### + + +from __future__ import annotations # type hinting + +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from uuid import uuid4 + +import numpy as np + +from rateslib.data.fixings import IRSSeries, _get_irs_series +from rateslib.dual import Dual, Dual2, Variable, set_order_convert +from rateslib.dual.utils import _dual_float, dual_exp, dual_inv_norm_cdf +from rateslib.enums.generics import NoInput, _drb +from rateslib.enums.parameters import OptionPricingModel, _get_option_pricing_model +from rateslib.mutability import ( + _new_state_post, +) +from rateslib.volatility.ir.base import _BaseIRSmile, _WithMutability +from rateslib.volatility.ir.utils import ( + _IRSmileMeta, + _IRSplineSmileNodes, + _IRVolPricingParams, +) + +UTC = timezone.utc + +if TYPE_CHECKING: + from rateslib.local_types import ( # pragma: no cover + DualTypes, + DualTypes_, + Iterable, + Sequence, + float_, + ) + + +class IRSplineSmile(_BaseIRSmile, _WithMutability): + r""" + Create an *IR Volatility Smile* at a given expiry indexed for a specific IRS tenor + with normal volatility interpolated by a polynomial spline curve. + + .. warning:: + + *Swaptions* and *IR Volatility* are in Beta status introduced in v2.7.0 + + .. role:: green + + .. role:: red + + Parameters + ---------- + nodes: dict[float, float], :red:`required` + The parameters for the spline. Keys must be basis points relative to the forward rate, + and values are normal volatility basis points. + eval_date: datetime, :red:`required` + Acts like the initial node of a *Curve*. Should be assigned today's immediate date. + expiry: datetime, :red:`required` + The expiry date of the options associated with this *Smile*. + irs_series: IRSSeries, :red:`required` + The :class:`~rateslib.data.fixings.IRSSeries` that contains the parameters for the + underlying :class:`~rateslib.instruments.IRS` that the swaptions are settled against. + tenor: datetime, str, :red:`required` + The tenor parameter for the underlying :class:`~rateslib.instruments.IRS` that the + swaptions are settled against. + k: int in {2, 4}, :green:`optional (set as 2)` + The order of the interpolating spline, with (2, 4) representing (linear, cubic) + interpolation respectively. + id: str, optional, :green:`optional (set as random)` + The unique identifier to distinguish between *Smiles* in a multicurrency framework + and/or *Surface*. + ad: int, :green:`optional (set by default)` + Sets the automatic differentiation order. Defines whether to convert node + values to float, :class:`~rateslib.dual.Dual` or + :class:`~rateslib.dual.Dual2`. It is advised against + using this setting directly. It is mainly used internally. + + Notes + ----- + The keys for ``nodes`` must be basis points relative to the forward rate. For example + + .. code-block:: python + + nodes = {-200.: 50.0, -100.: 47.0, 0.: 46.0, 100.: 48, 200.: 52.0} + + This means that the volatility model of this spline is naturally dependent on the forward + *IRS* rate, very similar to an :class:`~rateslib.volatility.FXDeltaVolSmile`, and any type + SABR type *Smile*. + + The value of ``nodes`` are treated as the parameters that will be calibrated/mutated by + a :class:`~rateslib.solver.Solver` object. The order of the spline, ``k``, in {2, 4} is a + hyper-parameter of this model and will not be mutated. + + Examples + -------- + See :ref:`Constructing a Smile `. + + """ # noqa: E501 + + @_new_state_post + def __init__( + self, + nodes: dict[float, DualTypes], + eval_date: datetime, + expiry: datetime | str, + irs_series: IRSSeries | str, + tenor: datetime | str, + *, + k: int = 2, + pricing_model: OptionPricingModel | str = "normal_vol", + shift: DualTypes_ = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 + ad: int | None = 0, + ): + if k not in [2, 4]: + raise ValueError(f"`k` must imply linear(2) or cubic(4) spline interpolation. Got {k}.") + self._id: str = ( + uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id + ) # 1 in a million clash + self._meta: _IRSmileMeta = _IRSmileMeta( + _tenor_input=tenor, + _irs_series=_get_irs_series(irs_series), + _eval_date=eval_date, + _expiry_input=expiry, + _plot_x_axis="moneyness", + _plot_y_axis="normal_vol", + _shift=_drb(0.0, shift), + _pricing_model=_get_option_pricing_model(pricing_model), + ) + + self._nodes = _IRSplineSmileNodes(nodes=nodes, k=k) + + self._set_ad_order(ad) + + ### Object unique elements + + @property + def _n(self) -> int: + return self.nodes.n + + @property + def _ini_solve(self) -> int: + return 0 + + @property + def id(self) -> str: + """A str identifier to name the *Smile* used in + :class:`~rateslib.solver.Solver` mappings.""" + return self._id + + @property + def nodes(self) -> _IRSplineSmileNodes: + """An instance of :class:`~rateslib.volatility.utils._IRSplineSmileNodes`.""" + return self._nodes + + ### _WithMutability ABCs: + + def _get_node_vector(self) -> np.ndarray[tuple[int, ...], np.dtype[np.object_]]: + """Get a 1d array of variables associated with nodes of this object updated by Solver""" + return np.array(self.nodes.values) + + def _get_node_vars(self) -> tuple[str, ...]: + """Get the variable names of elements updated by a Solver""" + return tuple(f"{self.id}{i}" for i in range(self._n)) + + def _set_node_vector_direct( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: + """ + Update the node values in a Solver. ``ad`` in {1, 2}. + Only the real values in vector are used, dual components are dropped and restructured. + """ + DualType: type[Dual] | type[Dual2] = Dual if ad == 1 else Dual2 + DualArgs: tuple[list[float]] | tuple[list[float], list[float]] = ( + ([],) if ad == 1 else ([], []) + ) + base_obj = DualType(0.0, [f"{self.id}{i}" for i in range(self.nodes.n)], *DualArgs) + ident = np.eye(self.nodes.n) + + nodes_: dict[float, DualTypes] = {} + for i, k in enumerate(self.nodes.keys): + nodes_[k] = DualType.vars_from( + base_obj, # type: ignore[arg-type] + vector[i].real, + base_obj.vars, + ident[i, :].tolist(), + *DualArgs[1:], + ) + self._nodes = _IRSplineSmileNodes(nodes=nodes_, k=self.nodes.k) + self.nodes.spline.csolve(self.nodes, self.ad) + + def _set_ad_order_direct(self, order: int | None) -> None: + """This does not alter the beta node, since that is not varied by a Solver. + beta values that are AD sensitive should be given as a Variable and not Dual/Dual2. + + Using `None` allows this Smile to be constructed without overwriting any variable names. + """ + if order == getattr(self, "ad", None): + return None + elif order not in [0, 1, 2]: + raise ValueError("`order` can only be in {0, 1, 2} for auto diff calcs.") + else: + self._ad = order + nodes: dict[float, DualTypes] = { + k: set_order_convert(v, order, [f"{self.id}{i}"]) + for i, (k, v) in enumerate(self.nodes.nodes.items()) + } + self._nodes = _IRSplineSmileNodes(nodes=nodes, k=self.nodes.spline.k) + self.nodes.spline.csolve(self.nodes, self.ad) + + def _set_single_node(self, key: float, value: DualTypes) -> None: + if key not in self.nodes.keys: + raise KeyError(f"'{key}' is not in `nodes`.") + self.nodes._nodes[key] = value + self.nodes.spline.csolve(self.nodes, self.ad) + + # _BaseIRSmile ABCS: + + def _plot( + self, + x_axis: str, + f: float, + y_axis: str, + tgt_shift: float_, + ) -> tuple[Iterable[float], Iterable[float]]: + + # approximate a range for the x-axis + shf = _dual_float(self.meta.shift) / 100.0 + sq_t = self._meta.t_expiry_sqrt + v_ = _dual_float(self.get_from_strike(k=f, f=f).vol) / 100.0 + if self.meta.pricing_model == OptionPricingModel.Black76: + v_ = v_ + else: + v_ = v_ / (f + shf) + + x_low = _dual_float( + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.95) * v_ * sq_t) * (f + shf) - shf + ) + x_top = _dual_float( + dual_exp(0.5 * v_**2 * sq_t**2 - dual_inv_norm_cdf(0.05) * v_ * sq_t) * (f + shf) - shf + ) + + x = np.linspace(x_low, x_top, 301, dtype=np.float64) + y: Iterable[float] = [_dual_float(self.get_from_strike(k=_, f=f).vol) for _ in x] + + return self._plot_conversion( + y_axis=y_axis, x_axis=x_axis, f=f, shift=shf, tgt_shift=_drb(shf, tgt_shift), x=x, y=y + ) + + @property + def ad(self) -> int: + return self._ad + + @property + def pricing_params(self) -> Sequence[float | Dual | Dual2 | Variable]: + return self.nodes.values + + @property + def meta(self) -> _IRSmileMeta: + return self._meta + + def _get_from_strike(self, k: DualTypes, f: DualTypes) -> _IRVolPricingParams: + """ + Given an option strike return the volatility. + + Parameters + ----------- + k: float, Dual, Dual2 + The strike of the option. + f: float, Dual, Dual2 + The forward rate at delivery of the option. + expiry: datetime, optional + The expiry of the option. Required for temporal interpolation. + tenor: datetime, optional + The termination date of the underlying *IRS*, required for parameter interpolation. + curves: _Curves, + Pricing objects. See **Pricing** on :class:`~rateslib.instruments.IRCall` + for details of allowed inputs. + + Returns + ------- + _IRVolPricingParams + """ + vol_ = self.nodes.spline.evaluate(x=(k - f) * 100.0, m=0) + return _IRVolPricingParams( + vol=vol_, + k=k, + f=f, + shift=self.meta.rate_shift, + pricing_model=self.meta.pricing_model, + t_e=self.meta.t_expiry, + ) diff --git a/python/rateslib/volatility/ir/utils.py b/python/rateslib/volatility/ir/utils.py index 5e010cd6..e486490f 100644 --- a/python/rateslib/volatility/ir/utils.py +++ b/python/rateslib/volatility/ir/utils.py @@ -21,25 +21,38 @@ from pandas import Series from rateslib.data.fixings import IRSFixing, _get_irs_series +from rateslib.dual import set_order_convert from rateslib.enums.generics import NoInput from rateslib.scheduling import Adjuster, add_tenor +from rateslib.splines import PPSplineDual, PPSplineDual2, PPSplineF64 +from rateslib.splines.evaluate import evaluate if TYPE_CHECKING: from rateslib.local_types import ( # pragma: no cover + Any, Arr2dObj, DualTypes, IRSSeries, + Number, + OptionPricingModel, datetime_, ) UTC = timezone.utc +SPLINE_LOWER = -5000.0 +SPLINE_UPPER = 10000.0 + class _IRVolPricingParams(NamedTuple): - vol: DualTypes # Black Shifted Vol - k: DualTypes # Strike - f: DualTypes # Forward - shift: DualTypes # Shift to apply to `k` and `f` to use with `vol` + """Container for parameters for pricing IR options.""" + + vol: DualTypes # vol appropriate for `pricing_model` + k: DualTypes # strike + f: DualTypes # forward + shift: DualTypes # shift to apply to `k` and `f` to use with `vol` + t_e: DualTypes # time to expiry + pricing_model: OptionPricingModel class _IRSmileMeta: @@ -57,6 +70,7 @@ def __init__( _shift: DualTypes, _plot_x_axis: str, _plot_y_axis: str, + _pricing_model: OptionPricingModel, ): self._eval_date = _eval_date self._expiry_input = _expiry_input @@ -72,6 +86,12 @@ def __init__( identifier=NoInput(0), ) self._shift = _shift + self._pricing_model = _pricing_model + + @property + def pricing_model(self) -> OptionPricingModel: + """The option pricing model associated with this *Smile* volatility output.""" + return self._pricing_model @property def eval_date(self) -> datetime: @@ -143,6 +163,10 @@ def t_expiry(self) -> float: """Calendar days from eval to expiry divided by 365.""" return (self.expiry - self.eval_date).days / 365.0 + def _t_expiry(self, expiry: datetime) -> float: + """Calendar days from eval to specified expiry divided by 365.""" + return (expiry - self.eval_date).days / 365.0 + @cached_property def t_expiry_sqrt(self) -> float: """Square root of ``t_expiry``.""" @@ -150,6 +174,212 @@ def t_expiry_sqrt(self) -> float: return ret +class _IRSplineSmileNodes: + """ + A container for data relating to interpolating the `nodes` of a + :class:`~rateslib.volatility.IRSplineSmile`. + """ + + _nodes: dict[float, DualTypes] + _spline: _IRVolSpline + + def __init__(self, nodes: dict[float, DualTypes], k: int) -> None: + self._nodes = dict(sorted(nodes.items())) + + match (self.n, k): + case (1, _) | (2, _): + # 1 DoF yields a flat smile, but treat it as a line of zero gradient + # 2 DoF yields a straight line, usually with some non-zero gradient + k = 2 + t = [SPLINE_LOWER, SPLINE_LOWER, SPLINE_UPPER, SPLINE_UPPER] + case (_, 2): + # more DoF but piecewise linear so can be extended + t = [SPLINE_LOWER, SPLINE_LOWER] + self.keys + [SPLINE_UPPER, SPLINE_UPPER] + case (_, 4): + # more DoF but piecewise cubic so cannot + t = [SPLINE_LOWER] * 4 + self.keys + [SPLINE_UPPER] * 4 + + self._spline = _IRVolSpline(t=t, k=k) + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, _IRSplineSmileNodes): + return False + return self._nodes == other._nodes and self.k == other.k + + @property + def nodes(self) -> dict[float, DualTypes]: + """The initial nodes dict passed for construction of this class.""" + return self._nodes + + @cached_property + def keys(self) -> list[float]: + """A list of the relative strike keys in ``nodes``.""" + return list(self.nodes.keys()) + + @cached_property + def values(self) -> list[DualTypes]: + """A list of the delta index values in ``nodes``.""" + return list(self.nodes.values()) + + @property + def n(self) -> int: + """The number of pricing parameters in ``nodes``.""" + return len(self.keys) + + @property + def k(self) -> int: + """The order of the interpolating polynomial spline.""" + return self.spline.k + + @property + def spline(self) -> _IRVolSpline: + """An instance of :class:`~rateslib.volatility.ir._IRVolSpline`.""" + return self._spline + + +class _IRVolSpline: + """ + A container for data relating to interpolating the `nodes` of + a :class:`~rateslib.volatility.IRSplineSmile` using a PPSpline. + """ + + _k: int + _t: list[float] + _spline: PPSplineF64 | PPSplineDual | PPSplineDual2 + + def __init__(self, t: list[float], k: int) -> None: + self._t = t + self._k = k + self._spline = PPSplineF64(k, [0.0] * 5, None) # placeholder: csolve will reengineer + + @property + def t(self) -> list[float]: + """The knot sequence of the PPSpline.""" + return self._t + + @property + def k(self) -> int: + """The order of the spline.""" + return self._k + + @property + def spline(self) -> PPSplineF64 | PPSplineDual | PPSplineDual2: + """An instance of :class:`~rateslib.splines.PPSplineF64`, + :class:`~rateslib.splines.PPSplineDual` or :class:`~rateslib.splines.PPSplineDual2`""" + return self._spline + + def evaluate(self, x: DualTypes, m: int = 0) -> Number: + """Perform the :meth:`~rateslib.splines.evaluate` method on the object's ``spline``.""" + return evaluate(spline=self.spline, x=x, m=m) + + def _csolve_n_other( + self, nodes: _IRSplineSmileNodes, ad: int + ) -> tuple[list[float], list[DualTypes], int, int]: + """ + Solve a spline with more than one node value. + Premium adjusted delta types have an unbounded right side delta index so a derivative of + 0 is applied to the spline as a boundary condition. + Premium unadjusted delta types have a right side delta index approximately equal to 1.0. + Use a natural spline boundary condition here. + """ + tau = nodes.keys.copy() + y = nodes.values.copy() + + # left side constraint + gradient = (y[1] - y[0]) / (tau[1] - tau[0]) + # project the gradient backwards to SPLINE_LOWER: this simulates an inner left side + # 1st order gradient constraint whilst ensuring a wider domain. + y.insert(0, (SPLINE_LOWER - tau[0]) * gradient + y[0]) + tau.insert(0, SPLINE_LOWER) + if self.k == 4: + # now insert the natural spline 2nd derivative constraint + y.insert(0, set_order_convert(0.0, ad, None)) + tau.insert(0, SPLINE_LOWER) + left_n = 2 # natural spline + else: # == 2 + left_n = 0 + + # right side constraint + gradient = (y[-1] - y[-2]) / (tau[-1] - tau[-2]) + # project the gradient forwards to SPLINE_UPPER: this simulates an inner left side + # 1st order gradient constraint whilst ensuring a wider domain. + y.append((SPLINE_UPPER - tau[-1]) * gradient + y[-1]) + tau.append(SPLINE_UPPER) + if self.k == 4: + tau.append(self.t[-1]) + y.append(set_order_convert(0.0, ad, None)) + right_n = 2 # natural spline + else: # == 2 + right_n = 0 + + return tau, y, left_n, right_n + + def csolve(self, nodes: _IRSplineSmileNodes, ad: int) -> None: + """ + Construct a spline of appropriate AD order and solve the spline coefficients for the + given ``nodes``. + + Parameters + ---------- + nodes: _IRSplineSmileNodes + Required information for constructing a PPSpline. + ad: int + The AD order of the constructed PPSPline. + + Returns + ------- + None + """ + if ad == 0: + Spline: type[PPSplineF64] | type[PPSplineDual] | type[PPSplineDual2] = PPSplineF64 + elif ad == 1: + Spline = PPSplineDual + else: + Spline = PPSplineDual2 + + if nodes.n == 1: + # one node defines a flat line, all spline coefficients are the equivalent value. + self._spline = Spline(self.k, self.t, nodes.values * self.k) # type: ignore[arg-type] + else: + tau, y, left_n, right_n = self._csolve_n_other(nodes, ad) + self._spline = Spline(self.k, self.t, None) + self._spline.csolve(tau, y, left_n, right_n, False) # type: ignore[arg-type] + + # def to_json(self) -> str: + # """ + # Serialize this object to JSON format. + # + # The object can be deserialized using the :meth:`~rateslib.serialization.from_json` method. + # + # Returns + # ------- + # str + # """ + # obj = dict( + # PyNative=dict( + # _FXDeltaVolSpline=dict( + # t=self.t, + # ) + # ) + # ) + # return json.dumps(obj) + # + # @classmethod + # def _from_json(cls, loaded_json: dict[str, Any]) -> _FXDeltaVolSpline: + # return _FXDeltaVolSpline( + # t=loaded_json["t"], + # ) + + def __eq__(self, other: Any) -> bool: + """CurveSplines are considered equal if their knot sequence and endpoints are equivalent. + For the same nodes this will resolve to give the same spline coefficients. + """ + if not isinstance(other, _IRVolSpline): + return False + else: + return self.t == other.t and self.k == other.k + + @dataclass(frozen=True) class _IRSabrCubeMeta: """ @@ -228,6 +458,10 @@ def tenor_dates_posix(self) -> Arr2dObj: (self._n_expiries, self._n_tenors), ) + def _t_expiry(self, expiry: datetime) -> float: + """Calendar days from eval to specified expiry divided by 365.""" + return (expiry - self.eval_date).days / 365.0 + # @cached_property # def tenor_posix(self) -> list[float]: # """A list of the tenors as posix timestamp.""" diff --git a/python/rateslib/volatility/utils.py b/python/rateslib/volatility/utils.py index 01a9812c..3c789fd9 100644 --- a/python/rateslib/volatility/utils.py +++ b/python/rateslib/volatility/utils.py @@ -25,6 +25,7 @@ dual_log, dual_norm_cdf, dual_norm_pdf, + ift_1dim, ) from rateslib.dual.utils import _to_number from rateslib.enums.generics import ( @@ -262,7 +263,7 @@ def _d_plus(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: def _value( F: DualTypes, K: DualTypes, - t_e: float, + t_e: DualTypes, v2: DualTypes, vol: DualTypes, phi: float, @@ -276,7 +277,7 @@ def _value( The forward price for settlement at the delivery date. K: float, Dual, Dual2 The strike price of the option. - t_e: float + t_e: float, Dual, Dual2 The annualised time to expiry. v2: float, Dual, Dual2 The discounting rate to delivery (ccy2 on FX options), at the appropriate collateral @@ -305,6 +306,69 @@ def _value( # _ = df1 * S_imm * Nd1 - K * df2 * Nd2 return _ * v2 + @classmethod + def convert_to_bachelier( + cls, + f: DualTypes, + k: DualTypes, + shift: DualTypes, + vol: DualTypes, + t_e: DualTypes, + ) -> DualTypes: + s_tgt = cls._value( + F=f + shift / 100.0, K=k + shift / 100.0, t_e=t_e, v2=1.0, vol=vol / 100.0, phi=1.0 + ) + + def s(g: DualTypes) -> DualTypes: + """s(g) is the price, s, of an option given a volatility, g,""" + return _OptionModelBachelier._value( + F=f, + K=k, + t_e=t_e, + v2=1.0, + vol=g, + phi=1.0, + ) + + result = ift_1dim(s=s, s_tgt=s_tgt, h="ytm_quadratic", ini_h_args=(0.0001, 0.10, 0.50)) + g: DualTypes = result["g"] + return g * 100.0 + + @classmethod + def convert_to_new_shift( + cls, + f: DualTypes, + k: DualTypes, + old_shift: DualTypes, + target_shift: DualTypes, + vol: DualTypes, + t_e: DualTypes, + ) -> DualTypes: + s_tgt = cls._value( + F=f + old_shift / 100.0, + K=k + old_shift / 100.0, + t_e=t_e, + v2=1.0, + vol=vol / 100.0, + phi=1.0, + ) + + def s(g: DualTypes) -> DualTypes: + """s(g) is the price, s, of an option given a volatility, g,""" + return cls._value( + F=f + target_shift / 100.0, + K=k + target_shift / 100.0, + t_e=t_e, + v2=1.0, + vol=g, + phi=1.0, + ) + + # result = ift_1dim(s=s, s_tgt=s_tgt, h="modified_brent", ini_h_args=(0.0001, 10.0)) + result = ift_1dim(s=s, s_tgt=s_tgt, h="ytm_quadratic", ini_h_args=(0.0001, 0.10, 0.50)) + g: DualTypes = result["g"] + return g * 100.0 + class _OptionModelBachelier: """Container for option pricing formulae relating to the lognormal Black-76 model.""" @@ -313,7 +377,7 @@ class _OptionModelBachelier: def _value( F: DualTypes, K: DualTypes, - t_e: float, + t_e: DualTypes, v2: DualTypes, vol: DualTypes, phi: float, @@ -327,7 +391,7 @@ def _value( The forward price for settlement at the delivery date. K: float, Dual, Dual2 The strike price of the option. - t_e: float + t_e: float, Dual, Dual2 The annualised time to expiry. v2: float, Dual, Dual2 The discounting rate to delivery (ccy2 on FX options), at the appropriate collateral @@ -350,6 +414,33 @@ def _value( _: DualTypes = phi * (F - K) * P + vs * p return _ * v2 + @classmethod + def convert_to_black76( + cls, + f: DualTypes, + k: DualTypes, + shift: DualTypes, + vol: DualTypes, + t_e: DualTypes, + ) -> DualTypes: + s_tgt = cls._value(F=f, K=k, t_e=t_e, v2=1.0, vol=vol / 100.0, phi=1.0) + + def s(g: DualTypes) -> DualTypes: + """s(g) is the price, s, of an option given a volatility, g,""" + return _OptionModelBlack76._value( + F=f + shift / 100.0, + K=k + shift / 100.0, + t_e=t_e, + v2=1.0, + vol=g, + phi=1.0, + ) + + # result = ift_1dim(s=s, s_tgt=s_tgt, h="modified_brent", ini_h_args=(0.0001, 10.0)) + result = ift_1dim(s=s, s_tgt=s_tgt, h="ytm_quadratic", ini_h_args=(0.0001, 0.10, 0.50)) + g: DualTypes = result["g"] + return g * 100.0 + class _SabrModel: """Container for formulae relating to the SABR volatility model.""" diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 931794b0..d5bd68c4 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -9029,7 +9029,6 @@ def test_default_payment_date(self): @pytest.mark.parametrize( ("metric", "expected"), [ - ("LogNormalVol", 25.16), ("BlackVolShift_0", 25.16), ("Cash", 149725.796514), ("NormalVol", 75.792872), @@ -9118,7 +9117,6 @@ def test_cashflows(self): ("Cash", [1.0, 1.0]), ("NormalVol", [0.5, 0.5]), ("BlackVolShift_0", [0.5, 0.5]), - ("LogNormalVol", [0.5, 0.5]), ("BlackVolShift_100", [0.5, 0.5]), ("BlackVolShift_200", [0.5, 0.5]), ("BlackVolShift_300", [0.5, 0.5]), @@ -9324,7 +9322,7 @@ def test_solver_passthrough(self, vol) -> None: irs_series="eur_irs6", eval_date=dt(2001, 1, 1), vol=vol, - metric="vol", + metric="black_vol_shift_0", ) result = v.rate(vol=vol, curves=Curve({dt(2001, 1, 1): 1.0, dt(2005, 1, 1): 0.7})) expected = 15.170743310759043 diff --git a/python/tests/periods/test_periods_legacy.py b/python/tests/periods/test_periods_legacy.py index d9218850..4cd2814f 100644 --- a/python/tests/periods/test_periods_legacy.py +++ b/python/tests/periods/test_periods_legacy.py @@ -5871,7 +5871,6 @@ def test_option_npv_different_csa(self): ("metric", "expected"), [ ("NormalVol", 75.792872), - ("LogNormalVol", 25.16), ("Cash", 149725.796514), ("PercentNotional", 0.149725), ("black_vol_shift_0", 25.16), diff --git a/python/tests/serialization/test_json.py b/python/tests/serialization/test_json.py index 6e28ea02..05190fbb 100644 --- a/python/tests/serialization/test_json.py +++ b/python/tests/serialization/test_json.py @@ -85,7 +85,6 @@ IROptionMetric.Cash(), IROptionMetric.PercentNotional(), IROptionMetric.NormalVol(), - IROptionMetric.LogNormalVol(), IROptionMetric.BlackVolShift(25), LegIndexBase.Initial, LegIndexBase.PeriodOnPeriod, diff --git a/python/tests/test_ir_volatility.py b/python/tests/test_ir_volatility.py index b03b7f0e..aa83950c 100644 --- a/python/tests/test_ir_volatility.py +++ b/python/tests/test_ir_volatility.py @@ -25,6 +25,7 @@ from rateslib.volatility import ( IRSabrCube, IRSabrSmile, + IRSplineSmile, ) from rateslib.volatility.ir.utils import _bilinear_interp from rateslib.volatility.utils import _SabrSmileNodes @@ -132,7 +133,10 @@ def test_sabr_vol_plot_fail(self): tenor="2y", id="vol", ) - with pytest.raises(ValueError, match=r"`f` \(ATM-forward FX rate\) is required by"): + with pytest.raises( + ValueError, + match=r"`f` \(ATM-forward interest rate\) is required by `_BaseIRSmile.plot`.", + ): irss.plot() @pytest.mark.parametrize(("k", "f"), [(1.34, 1.34), (1.33, 1.35), (1.35, 1.33)]) @@ -450,7 +454,9 @@ def test_get_from_strike_expiry_raises(self): tenor="2y", id="myid", ) - with pytest.raises(ValueError, match="`expiry` of VolSmile and OptionPeriod do not match"): + with pytest.raises( + ValueError, match="`expiry` of _BaseIRSmile and intended price do not match" + ): irss.get_from_strike(k=1.0, f=1.0, expiry=dt(1999, 1, 1)) @pytest.mark.parametrize("k", [1.2034, 1.2050, 1.3620, 1.5410, 1.5449]) @@ -1345,6 +1351,56 @@ def test_set_node_vector(self): assert result[9] == Dual(20.0, ["X_v_0_1"], []) +class TestIRSplineSmile: + @pytest.mark.parametrize( + ("strike", "vol"), + [ + (1.2034, 51.0888), + (1.2050, 51.07599999999999), + (1.3395, 50.0), # f == k + (1.3620, 50.2475), + (1.5410, 52.216499999999996), + (1.5449, 52.2594), + ], + ) + def test_spline_vol(self, strike, vol): + # repeat the same test developed for FXSabrSmile + irss = IRSplineSmile( + nodes={-200.0: 70.0, -100.0: 58, 0: 50.0, 100.0: 61, 200.0: 75.0}, + k=2, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ) + result = irss.get_from_strike(k=strike, f=1.3395).vol + assert abs(result - vol) < 1e-2 + + @pytest.mark.parametrize( + ("strike", "vol"), + [ + (1.01, 50.0), + (1.85, 50.0), + (1.3395, 50.0), # f == k + ], + ) + @pytest.mark.parametrize("k", [2, 4]) + def test_spline_vol_flat(self, strike, vol, k): + # repeat the same test developed for FXSabrSmile + irss = IRSplineSmile( + nodes={0: 50.0}, + k=k, + eval_date=dt(2001, 1, 1), + expiry=dt(2002, 1, 1), + irs_series="eur_irs6", + tenor="2y", + id="vol", + ) + result = irss.get_from_strike(k=strike, f=1.3395).vol + assert abs(result - vol) < 1e-2 + + class TestStateAndCache: @pytest.mark.parametrize( "obj", diff --git a/rust/enums/parameters.rs b/rust/enums/parameters.rs index a925a8c3..dec12dad 100644 --- a/rust/enums/parameters.rs +++ b/rust/enums/parameters.rs @@ -56,14 +56,12 @@ impl FloatFixingMethod { /// Specifier for the rate metric on IR Option types. #[derive(Debug, Copy, Clone, PartialEq, Serialize, Deserialize)] pub enum IROptionMetric { - /// Volatility expressed in normalized basis points, i.e. used in the Bachelier pricing model. - NormalVol {}, - /// Alias for BlackVolShift(0) - LogNormalVol {}, /// Cash option premium expressed as a percentage of the notional. PercentNotional {}, /// Option premium expressed as a cash quantity. Cash {}, + /// Volatility expressed in normalized basis points, i.e. used in the Bachelier pricing model. + NormalVol {}, /// Log-normal Black volatility applying a basis-points shift to the forward and strike. BlackVolShift(i32), } diff --git a/rust/enums/py/ir_option_metric.rs b/rust/enums/py/ir_option_metric.rs index 4f650966..b6b384d4 100644 --- a/rust/enums/py/ir_option_metric.rs +++ b/rust/enums/py/ir_option_metric.rs @@ -40,14 +40,12 @@ use serde::{Deserialize, Serialize}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub(crate) enum PyIROptionMetric { #[pyo3(constructor = (_u8=0))] - NormalVol { _u8: u8 }, - #[pyo3(constructor = (_u8=1))] - LogNormalVol { _u8: u8 }, - #[pyo3(constructor = (_u8=2))] PercentNotional { _u8: u8 }, - #[pyo3(constructor = (_u8=3))] + #[pyo3(constructor = (_u8=1))] Cash { _u8: u8 }, - #[pyo3(constructor = (param, _u8=4))] + #[pyo3(constructor = (_u8=2))] + NormalVol { _u8: u8 }, + #[pyo3(constructor = (param, _u8=3))] BlackVolShift { param: i32, _u8: u8 }, } @@ -91,12 +89,11 @@ impl<'py> FromPyObject<'py, 'py> for PyIROptionMetricNewArgs { impl From for PyIROptionMetric { fn from(value: IROptionMetric) -> Self { match value { - IROptionMetric::NormalVol {} => PyIROptionMetric::NormalVol { _u8: 0 }, - IROptionMetric::LogNormalVol {} => PyIROptionMetric::LogNormalVol { _u8: 1 }, - IROptionMetric::PercentNotional {} => PyIROptionMetric::PercentNotional { _u8: 2 }, - IROptionMetric::Cash {} => PyIROptionMetric::Cash { _u8: 3 }, + IROptionMetric::PercentNotional {} => PyIROptionMetric::PercentNotional { _u8: 0 }, + IROptionMetric::Cash {} => PyIROptionMetric::Cash { _u8: 1 }, + IROptionMetric::NormalVol {} => PyIROptionMetric::NormalVol { _u8: 2 }, IROptionMetric::BlackVolShift(n) => { - PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + PyIROptionMetric::BlackVolShift { param: n, _u8: 3 } } } } @@ -106,7 +103,6 @@ impl From for IROptionMetric { fn from(value: PyIROptionMetric) -> Self { match value { PyIROptionMetric::NormalVol { _u8: _ } => IROptionMetric::NormalVol {}, - PyIROptionMetric::LogNormalVol { _u8: _ } => IROptionMetric::LogNormalVol {}, PyIROptionMetric::PercentNotional { _u8: _ } => IROptionMetric::PercentNotional {}, PyIROptionMetric::Cash { _u8: _ } => IROptionMetric::Cash {}, PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { @@ -134,7 +130,6 @@ impl PyIROptionMetric { fn __str__(&self) -> String { match self { PyIROptionMetric::NormalVol { _u8: _ } => "normal_vol".to_string(), - PyIROptionMetric::LogNormalVol { _u8: _ } => "log_normal_vol".to_string(), PyIROptionMetric::PercentNotional { _u8: _ } => "percent_notional".to_string(), PyIROptionMetric::Cash { _u8: _ } => "cash".to_string(), PyIROptionMetric::BlackVolShift { param: n, _u8: _ } => { @@ -146,7 +141,6 @@ impl PyIROptionMetric { fn __getnewargs__(&self) -> PyIROptionMetricNewArgs { match self { PyIROptionMetric::NormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), - PyIROptionMetric::LogNormalVol { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), PyIROptionMetric::PercentNotional { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), PyIROptionMetric::Cash { _u8: u } => PyIROptionMetricNewArgs::NoArgs(*u), PyIROptionMetric::BlackVolShift { param: n, _u8: u } => { @@ -158,12 +152,11 @@ impl PyIROptionMetric { #[new] fn new_py(args: PyIROptionMetricNewArgs) -> PyIROptionMetric { match args { - PyIROptionMetricNewArgs::NoArgs(0) => PyIROptionMetric::NormalVol { _u8: 0 }, - PyIROptionMetricNewArgs::NoArgs(1) => PyIROptionMetric::LogNormalVol { _u8: 1 }, - PyIROptionMetricNewArgs::NoArgs(2) => PyIROptionMetric::PercentNotional { _u8: 2 }, - PyIROptionMetricNewArgs::NoArgs(3) => PyIROptionMetric::Cash { _u8: 3 }, - PyIROptionMetricNewArgs::I32(n, 4) => { - PyIROptionMetric::BlackVolShift { param: n, _u8: 4 } + PyIROptionMetricNewArgs::NoArgs(0) => PyIROptionMetric::PercentNotional { _u8: 0 }, + PyIROptionMetricNewArgs::NoArgs(1) => PyIROptionMetric::Cash { _u8: 1 }, + PyIROptionMetricNewArgs::NoArgs(2) => PyIROptionMetric::NormalVol { _u8: 2 }, + PyIROptionMetricNewArgs::I32(n, 3) => { + PyIROptionMetric::BlackVolShift { param: n, _u8: 3 } } _ => panic!("Undefined behaviour."), }