diff --git a/benchmarks/benchmarks.py b/benchmarks/benchmarks.py index 3b26b345..21665c4c 100644 --- a/benchmarks/benchmarks.py +++ b/benchmarks/benchmarks.py @@ -186,8 +186,8 @@ def time_composite_curve_value_getting(self): self.compcurve[_] def clear_caches(self): - self.curve.clear_cache() - self.curve2.clear_cache() + self.curve._clear_cache() + self.curve2._clear_cache() def time_curve_value_getting_no_cache(self): with default_context("curve_caching", False): diff --git a/docs/source/c_fx_smile.rst b/docs/source/c_fx_smile.rst index 86ca3b2e..af68ebc2 100644 --- a/docs/source/c_fx_smile.rst +++ b/docs/source/c_fx_smile.rst @@ -17,12 +17,6 @@ FX Vol Surfaces ********************************* -.. warning:: - - FX volatility products in *rateslib* are not in stable status. Their API and/or object - interactions *may* incur breaking changes in upcoming releases as they mature and other - classes or pricing models may be added. - The ``rateslib.fx_volatility`` module includes classes for *Smiles* and *Surfaces* which can be used to price *FX Options* and *FX Option Strategies*. diff --git a/docs/source/i_api.rst b/docs/source/i_api.rst index 7af6689a..67105b14 100644 --- a/docs/source/i_api.rst +++ b/docs/source/i_api.rst @@ -44,11 +44,13 @@ Defaults :skip: datetime :skip: Enum :skip: read_csv - :skip: get_named_calendar :skip: Cal :skip: NamedCal :skip: Series :skip: UnionCal + :skip: Callable + :skip: ParamSpec + :skip: TypeVar Calendars ========== @@ -159,10 +161,11 @@ FX Volatility .. automodapi:: rateslib.fx_volatility :no-heading: :no-inheritance-diagram: + :skip: Any + :skip: Variable :skip: set_order_convert :skip: dual_exp :skip: dual_inv_norm_cdf - :skip: DualTypes :skip: Dual :skip: Dual2 :skip: dual_norm_cdf @@ -188,10 +191,11 @@ Link to the :ref:`Periods` section in the user guide. .. automodapi:: rateslib.periods :no-heading: + :skip: Any + :skip: Sequence :skip: Index :skip: NoInput :skip: ABCMeta - :skip: IndexCurve :skip: Variable :skip: MultiIndex :skip: Curve @@ -200,8 +204,6 @@ Link to the :ref:`Periods` section in the user guide. :skip: Dual2 :skip: FXRates :skip: FXForwards - :skip: LineCurve - :skip: CompositeCurve :skip: Series :skip: datetime :skip: comb @@ -236,6 +238,9 @@ Solver .. automodapi:: rateslib.solver :no-heading: + :skip: Callable + :skip: Curve + :skip: ParamSpec :skip: MultiCsaCurve :skip: NoInput :skip: FXRates diff --git a/docs/source/index.rst b/docs/source/index.rst index 5af749b6..240cfe67 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -200,7 +200,7 @@ ticket in Bloomberg for reference point. ust = FixedRateBond( effective=dt(2023, 8, 15), termination=dt(2033, 8, 15), - fixed_rate=3.875, spec="ust" + fixed_rate=3.875, spec="us_gb" ) # Create a US-Treasury bond ust.price(ytm=4.0, settlement=dt(2025, 2, 14)) ust.duration(ytm=4.0, settlement=dt(2025, 2, 14), metric="risk") diff --git a/docs/source/u_dual.rst b/docs/source/u_dual.rst index 4b1c34e6..af6c6d9c 100644 --- a/docs/source/u_dual.rst +++ b/docs/source/u_dual.rst @@ -36,6 +36,10 @@ Methods rateslib.dual.dual_norm_cdf rateslib.dual.dual_inv_norm_cdf rateslib.dual.dual_solve + rateslib.dual.newton_1dim + rateslib.dual.newton_ndim + rateslib.dual.quadratic_eqn + Example ******* diff --git a/pyproject.toml b/pyproject.toml index 4a2bdb44..ec0f5cda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,8 @@ docstring-code-format = false [tool.ruff.lint] select = [ + # "ANN", # flake8-annotations -- Superceded by the use of mypy + # "COM", # flake8-commas -- conflicts with ruff format "E", # pycodestyle "W", "F", # Pyflakes @@ -110,17 +112,17 @@ select = [ "C4", # flake8-comprehensions "S", # flake8-bandit "PIE", # flake8-pie - # "ANN", # flake8-annotations -- Requires work - # "A", # flake8-builtins -- Requires work - # "COM", # flake8-commas -- conflicts with ruff format + "A", # flake8-builtins "Q", # flake8-quotes "PT", # flake8-pytest-style - # "C90", # mccabe complexity -- Requires work + "C90", # mccabe complexity -- Requires work "I", # isort "N", # pep8 naming # "RUF", # -- Requires work + # "D", Pydocs -- requires work ] ignore = [ + "A005", # json and typing module name shadowing is allowed "PT011", # -- Requires work inputting match statements "PIE790", # unnecessary pass "C408", # unnecessary dict call @@ -135,15 +137,24 @@ ignore = [ [tool.ruff.lint.per-file-ignores] "__init__.py" = ["E402", "N801"] -"python/tests/*" = ["F401", "B", "N", "S", "ANN"] +"typing.py" = ["E501"] +"python/tests/*" = ["F401", "B", "N", "S", "ANN", "D"] +"rust/*" = ["D"] + +[tool.ruff.lint.mccabe] +# Flag errors (`C901`) whenever the complexity level exceeds 5. +max-complexity = 14 [tool.mypy] packages = [ "rateslib" ] exclude = [ - "/instruments", - "fx_volatility.py", + "/instruments/bonds/securities.py", + "/instruments/fx_volatility.py", + "/instruments/generics.py", + "/instruments/rates/inflation.py", + "/instruments/rates/multi_currency.py", "solver.py", ] strict = true diff --git a/python/rateslib/__init__.py b/python/rateslib/__init__.py index 8b7c1482..6959e7c2 100644 --- a/python/rateslib/__init__.py +++ b/python/rateslib/__init__.py @@ -143,7 +143,7 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] It aims to be the fundamental high-level building block for practical analysis of fixed income securities, derivatives, FX representation and curve construction in Python. -""" +""" # noqa: A001 # Use __all__ to let type checkers know what is part of the public API. # Rateslib is not (yet) a py.typed library: the public API is determined diff --git a/python/rateslib/calendars/__init__.py b/python/rateslib/calendars/__init__.py index e92b0223..c3d7932d 100644 --- a/python/rateslib/calendars/__init__.py +++ b/python/rateslib/calendars/__init__.py @@ -1,12 +1,12 @@ from __future__ import annotations import calendar as calendar_mod +from collections.abc import Callable from datetime import datetime +from typing import TYPE_CHECKING from rateslib.calendars.dcfs import _DCF from rateslib.calendars.rs import ( - CalInput, - CalTypes, _get_modifier, _get_rollday, get_calendar, @@ -14,6 +14,9 @@ from rateslib.default import NoInput, _drb from rateslib.rs import Cal, Modifier, NamedCal, RollDay, UnionCal +if TYPE_CHECKING: + from rateslib.typing import CalInput + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. @@ -584,11 +587,15 @@ def _get_fx_expiry_and_delivery( return expiry_, delivery_ +_IS_ROLL: dict[str, Callable[..., bool]] = { + "eom": _is_eom, + "som": _is_som, + "imm": _is_imm, +} + __all__ = ( "add_tenor", "Cal", - "CalInput", - "CalTypes", "create_calendar", "dcf", "Modifier", diff --git a/python/rateslib/calendars/dcfs.py b/python/rateslib/calendars/dcfs.py index 4eea9092..1022cbf7 100644 --- a/python/rateslib/calendars/dcfs.py +++ b/python/rateslib/calendars/dcfs.py @@ -2,13 +2,16 @@ import calendar as calendar_mod import warnings -from collections.abc import Callable from datetime import datetime +from typing import TYPE_CHECKING -from rateslib.calendars.rs import CalInput, _get_modifier, _get_rollday, get_calendar +from rateslib.calendars.rs import _get_modifier, _get_rollday, get_calendar from rateslib.default import NoInput from rateslib.rs import Convention +if TYPE_CHECKING: + from rateslib.typing import Any, CalInput, Callable + CONVENTIONS_MAP: dict[str, Convention] = { "ACT365F": Convention.Act365F, "ACT365F+": Convention.Act365FPlus, @@ -38,11 +41,11 @@ def _get_convention(convention: str) -> Convention: raise ValueError(f"`convention`: {convention}, is not valid.") -def _dcf_act365f(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_act365f(start: datetime, end: datetime, *args: Any) -> float: return (end - start).days / 365.0 -def _dcf_act365fplus(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_act365fplus(start: datetime, end: datetime, *args: Any) -> float: """count the number of the years and then add a fractional ACT365F period.""" if end <= datetime(start.year + 1, start.month, start.day): return _dcf_act365f(start, end) @@ -53,28 +56,28 @@ def _dcf_act365fplus(start: datetime, end: datetime, *args) -> float: # type: i return years + _dcf_act365f(datetime(end.year - 1, start.month, start.day), end) -def _dcf_act360(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_act360(start: datetime, end: datetime, *args: Any) -> float: return (end - start).days / 360.0 -def _dcf_30360(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_30360(start: datetime, end: datetime, *args: Any) -> float: ds = min(30, start.day) de = min(ds, end.day) if ds == 30 else end.day y, m = end.year - start.year, (end.month - start.month) / 12.0 return y + m + (de - ds) / 360.0 -def _dcf_30e360(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_30e360(start: datetime, end: datetime, *args: Any) -> float: ds, de = min(30, start.day), min(30, end.day) y, m = end.year - start.year, (end.month - start.month) / 12.0 return y + m + (de - ds) / 360.0 -def _dcf_30e360isda( # type: ignore[no-untyped-def] +def _dcf_30e360isda( start: datetime, end: datetime, termination: datetime | NoInput, - *args, + *args: Any, ) -> float: if isinstance(termination, NoInput): raise ValueError("`termination` must be supplied with specified `convention`.") @@ -91,7 +94,7 @@ def _is_end_feb(date: datetime) -> bool: return y + m + (de - ds) / 360.0 -def _dcf_actactisda(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_actactisda(start: datetime, end: datetime, *args: Any) -> float: if start == end: return 0.0 @@ -240,11 +243,11 @@ def _dcf_actacticma_stub365f( return d_ -def _dcf_1(*args) -> float: # type: ignore[no-untyped-def] +def _dcf_1(*args: Any) -> float: return 1.0 -def _dcf_1plus(start: datetime, end: datetime, *args) -> float: # type: ignore[no-untyped-def] +def _dcf_1plus(start: datetime, end: datetime, *args: Any) -> float: return end.year - start.year + (end.month - start.month) / 12.0 diff --git a/python/rateslib/calendars/rs.py b/python/rateslib/calendars/rs.py index 22c4ab5e..6d66df4c 100644 --- a/python/rateslib/calendars/rs.py +++ b/python/rateslib/calendars/rs.py @@ -1,9 +1,13 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + from rateslib import defaults from rateslib.default import NoInput from rateslib.rs import Cal, Modifier, NamedCal, RollDay, UnionCal -CalTypes = Cal | UnionCal | NamedCal -CalInput = CalTypes | str | NoInput +if TYPE_CHECKING: + from rateslib.typing import CalInput, CalTypes def _get_rollday(roll: str | int | NoInput) -> RollDay: @@ -136,31 +140,24 @@ def _parse_str_calendar(calendar: str, named: bool) -> CalTypes: """Parse the calendar string using Python and construct calendar objects.""" vectors = calendar.split("|") if len(vectors) == 1: - calendars = vectors[0].lower().split(",") - if len(calendars) == 1: # only one named calendar is found - return defaults.calendars[calendars[0]] # lookup Hashmap - else: - # combined calendars are not yet predefined so this does not beenfit from hashmap speed - if named: - return NamedCal(calendar) - else: - cals = [defaults.calendars[_] for _ in calendars] - cals_: list[Cal] = [] - for cal in cals: - if isinstance(cal, Cal): - cals_.append(cal) - elif isinstance(cal, NamedCal): - cals_.extend(cal.union_cal.calendars) - else: - cals_.extend(cal.calendars) - return UnionCal(cals_, None) + return _parse_str_calendar_no_associated(vectors[0], named) elif len(vectors) == 2: + return _parse_str_calendar_with_associated(vectors[0], vectors[1], named) + else: + raise ValueError("Cannot use more than one pipe ('|') operator in `calendar`.") + + +def _parse_str_calendar_no_associated(calendar: str, named: bool) -> CalTypes: + calendars = calendar.lower().split(",") + if len(calendars) == 1: # only one named calendar is found + return defaults.calendars[calendars[0]] # lookup Hashmap + else: + # combined calendars are not yet predefined so this does not benefit from hashmap speed if named: return NamedCal(calendar) else: - calendars = vectors[0].lower().split(",") cals = [defaults.calendars[_] for _ in calendars] - cals_ = [] + cals_: list[Cal] = [] for cal in cals: if isinstance(cal, Cal): cals_.append(cal) @@ -168,18 +165,35 @@ def _parse_str_calendar(calendar: str, named: bool) -> CalTypes: cals_.extend(cal.union_cal.calendars) else: cals_.extend(cal.calendars) + return UnionCal(cals_, None) - settlement_calendars = vectors[1].lower().split(",") - sets = [defaults.calendars[_] for _ in settlement_calendars] - sets_: list[Cal] = [] - for cal in sets: - if isinstance(cal, Cal): - sets_.append(cal) - elif isinstance(cal, NamedCal): - sets_.extend(cal.union_cal.calendars) - else: - sets_.extend(cal.calendars) - return UnionCal(cals_, sets_) +def _parse_str_calendar_with_associated( + calendar: str, associated_calendar: str, named: bool +) -> CalTypes: + if named: + return NamedCal(calendar + "|" + associated_calendar) else: - raise ValueError("Cannot use more than one pipe ('|') operator in `calendar`.") + calendars = calendar.lower().split(",") + cals = [defaults.calendars[_] for _ in calendars] + cals_ = [] + for cal in cals: + if isinstance(cal, Cal): + cals_.append(cal) + elif isinstance(cal, NamedCal): + cals_.extend(cal.union_cal.calendars) + else: + cals_.extend(cal.calendars) + + settlement_calendars = associated_calendar.lower().split(",") + sets = [defaults.calendars[_] for _ in settlement_calendars] + sets_: list[Cal] = [] + for cal in sets: + if isinstance(cal, Cal): + sets_.append(cal) + elif isinstance(cal, NamedCal): + sets_.extend(cal.union_cal.calendars) + else: + sets_.extend(cal.calendars) + + return UnionCal(cals_, sets_) diff --git a/python/rateslib/curves/_parsers.py b/python/rateslib/curves/_parsers.py new file mode 100644 index 00000000..c2682580 --- /dev/null +++ b/python/rateslib/curves/_parsers.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING + +from rateslib import defaults +from rateslib.curves import MultiCsaCurve, ProxyCurve +from rateslib.default import NoInput + +if TYPE_CHECKING: + from rateslib.typing import ( + Curve, + Curve_, + CurveInput, + CurveInput_, + CurveOption, + CurveOption_, + CurveOrId, + Curves_, + Curves_DiscTuple, + Curves_Tuple, + Solver, + ) + + +def _map_curve_or_id_from_solver_(curve: CurveOrId, solver: Solver) -> Curve: + """ + Maps a "Curve | str" to a "Curve" via a Solver mapping. + + If a Curve, runs a check against whether that Curve is associated with the given Solver, + and perform an action based on `defaults.curve_not_in_solver` + """ + if isinstance(curve, str): + return solver._get_pre_curve(curve) + elif type(curve) is ProxyCurve or type(curve) is MultiCsaCurve: + # TODO: (mid) consider also adding CompositeCurves as exceptions under the same rule + # Proxy curves and MultiCsaCurves can exist outside of Solvers but be constructed + # directly from an FXForwards object tied to a Solver using only a Solver's + # dependent curves and AD variables. + return curve + else: + try: + # it is a safeguard to load curves from solvers when a solver is + # provided and multiple curves might have the same id + __: Curve = solver._get_pre_curve(curve.id) + if id(__) != id(curve): # Python id() is a memory id, not a string label id. + raise ValueError( + "A curve has been supplied, as part of ``curves``, which has the same " + f"`id` ('{curve.id}'),\nas one of the curves available as part of the " + "Solver's collection but is not the same object.\n" + "This is ambiguous and cannot price.\n" + "Either refactor the arguments as follows:\n" + "1) remove the conflicting curve: [curves=[..], solver=] -> " + "[curves=None, solver=]\n" + "2) change the `id` of the supplied curve and ensure the rateslib.defaults " + "option 'curve_not_in_solver' is set to 'ignore'.\n" + " This will remove the ability to accurately price risk metrics.", + ) + return __ + except AttributeError: + raise AttributeError( + "`curve` has no attribute `id`, likely it not a valid object, got: " + f"{curve}.\nSince a solver is provided have you missed labelling the `curves` " + f"of the instrument or supplying `curves` directly?", + ) + except KeyError: + if defaults.curve_not_in_solver == "ignore": + return curve + elif defaults.curve_not_in_solver == "warn": + warnings.warn("`curve` not found in `solver`.", UserWarning) + return curve + else: + raise ValueError("`curve` must be in `solver`.") + + +def _map_curve_from_solver_(curve: CurveInput, solver: Solver) -> CurveOption: + """ + Maps a "Curve | str | dict[str, Curve | str]" to a "Curve | dict[str, Curve]" via a Solver. + + If curve input involves strings get objects directly from solver curves mapping. + + This is the explicit variety which does not handle NoInput. + """ + if isinstance(curve, dict): + mapped_dict: dict[str, Curve] = { + k: _map_curve_or_id_from_solver_(v, solver) for k, v in curve.items() + } + return mapped_dict + else: + return _map_curve_or_id_from_solver_(curve, solver) + + +def _map_curve_from_solver(curve: CurveInput_, solver: Solver) -> CurveOption_: + """ + Maps a "Curve | str | dict[str, Curve | str] | NoInput" to a + "Curve | dict[str, Curve] | NoInput" via a Solver. + + This is the inexplicit variety which handles NoInput. + """ + if isinstance(curve, NoInput) or curve is None: + return NoInput(0) + else: + return _map_curve_from_solver_(curve, solver) + + +def _validate_curve_not_str(curve: CurveOrId) -> Curve: + if isinstance(curve, str): + raise ValueError("`curves` must contain Curve, not str, if `solver` not given.") + return curve + + +def _validate_no_str_in_curve_input(curve: CurveInput_) -> CurveOption_: + """ + If a Solver is not available then raise an Exception if a CurveInput contains string Id. + """ + if isinstance(curve, dict): + return {k: _validate_curve_not_str(v) for k, v in curve.items()} + elif isinstance(curve, NoInput) or curve is None: + return NoInput(0) + else: + return _validate_curve_not_str(curve) + + +def _get_curves_maybe_from_solver( + curves_attr: Curves_, + solver: Solver | NoInput, + curves: Curves_, +) -> Curves_DiscTuple: + """ + Attempt to resolve curves as a variety of input types to a 4-tuple consisting of: + (leg1 forecasting, leg1 discounting, leg2 forecasting, leg2 discounting) + + Parameters + ---------- + curves_attr : Curves + This is an external set of Curves which is used as a substitute for pricing. These might + be taken from an Instrument at initialisation, for example. + solver: Solver + Solver containing the Curves mapping + curves: Curves + A possible override option to allow curves to be specified directly, even if they exist + as an attribute on the Instrument. + + Returns + ------- + 4-Tuple of Curve, dict[str, Curve], NoInput + """ + if isinstance(curves, NoInput) and isinstance(curves_attr, NoInput): + # no data is available so consistently return a 4-tuple of no data + return (NoInput(0), NoInput(0), NoInput(0), NoInput(0)) + elif isinstance(curves, NoInput): + # set the `curves` input as that which is set as attribute at instrument init. + curves = curves_attr + + # refactor curves into a list + if not isinstance(curves, list | tuple): + # convert isolated value input to list + curves_as_list: list[ + Curve | dict[str, str | Curve] | dict[str, str] | dict[str, Curve] | NoInput | str + ] = [curves] + else: + curves_as_list = curves + + # parse curves_as_list + if isinstance(solver, NoInput): + curves_parsed: tuple[CurveOption_, ...] = tuple( + _validate_no_str_in_curve_input(curve) for curve in curves_as_list + ) + else: + try: + curves_parsed = tuple(_map_curve_from_solver(curve, solver) for curve in curves_as_list) + except KeyError as e: + raise ValueError( + "`curves` must contain str curve `id` s existing in `solver` " + "(or its associated `pre_solvers`).\n" + f"The sought id was: '{e.args[0]}'.\n" + f"The available ids are {list(solver.pre_curves.keys())}.", + ) + + curves_tuple = _make_4_tuple_of_curve(curves_parsed) + return _validate_disc_curves_are_not_dict(curves_tuple) + + +def _make_4_tuple_of_curve(curves: tuple[CurveOption_, ...]) -> Curves_Tuple: + """Convert user sequence input to a 4-Tuple.""" + n = len(curves) + if n == 1: + curves *= 4 + elif n == 2: + curves *= 2 + elif n == 3: + curves += (curves[1],) + elif n > 4: + raise ValueError("Can only supply a maximum of 4 `curves`.") + return curves # type: ignore[return-value] + + +def _validate_disc_curve_is_not_dict(curve: CurveOption_) -> Curve_: + if isinstance(curve, dict): + raise ValueError("`disc_curve` cannot be supplied as, or inferred from, a dict of Curves.") + return curve + + +def _validate_disc_curves_are_not_dict(curves_tuple: Curves_Tuple) -> Curves_DiscTuple: + return ( + curves_tuple[0], + _validate_disc_curve_is_not_dict(curves_tuple[1]), + curves_tuple[2], + _validate_disc_curve_is_not_dict(curves_tuple[3]), + ) diff --git a/python/rateslib/curves/curves.py b/python/rateslib/curves/curves.py index 03e6628f..92b61857 100644 --- a/python/rateslib/curves/curves.py +++ b/python/rateslib/curves/curves.py @@ -1,11 +1,3 @@ -""" -.. ipython:: python - :suppress: - - from rateslib.curves import * - from datetime import datetime as dt -""" - from __future__ import annotations import json @@ -20,17 +12,13 @@ from pytz import UTC from rateslib import defaults -from rateslib.calendars import CalInput, add_tenor, dcf +from rateslib.calendars import add_tenor, dcf from rateslib.calendars.dcfs import _DCF1d -from rateslib.calendars.rs import CalTypes, get_calendar +from rateslib.calendars.rs import get_calendar from rateslib.default import NoInput, PlotOutput, _drb, _validate_states, _WithState, plot -from rateslib.dual import ( # type: ignore[attr-defined] - Arr1dF64, - Arr1dObj, +from rateslib.dual import ( Dual, Dual2, - DualTypes, - Number, dual_exp, dual_log, set_order_convert, @@ -41,6 +29,7 @@ if TYPE_CHECKING: from rateslib.fx import FXForwards # pragma: no cover + from rateslib.typing import Arr1dF64, Arr1dObj, CalInput, CalTypes, DualTypes, Number # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International @@ -188,7 +177,7 @@ def __init__( # type: ignore[no-untyped-def] t: list[datetime] | NoInput = NoInput(0), c: list[float] | NoInput = NoInput(0), endpoints: str | tuple[str, str] | NoInput = NoInput(0), - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 convention: str | NoInput = NoInput(0), modifier: str | NoInput = NoInput(0), calendar: CalInput = NoInput(0), @@ -499,7 +488,7 @@ def _rate_with_raise( def shift( self, spread: DualTypes, - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 composite: bool = True, collateral: str | NoInput = NoInput(0), ) -> Curve: @@ -1788,7 +1777,7 @@ def _rate_with_raise( def shift( self, spread: DualTypes, - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 composite: bool = True, collateral: str | NoInput = NoInput(0), ) -> Curve: @@ -2349,7 +2338,7 @@ class CompositeCurve(Curve): def __init__( self, curves: list[Curve] | tuple[Curve, ...], - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 ) -> None: self.id = _drb(uuid4().hex[:5], id) # 1 in a million clash @@ -2533,7 +2522,7 @@ def __getitem__(self, date: datetime) -> DualTypes: def shift( self, spread: DualTypes, - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 composite: bool = True, collateral: str | NoInput = NoInput(0), ) -> CompositeCurve: @@ -2688,7 +2677,7 @@ class MultiCsaCurve(CompositeCurve): def __init__( self, curves: list[Curve] | tuple[Curve, ...], - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 multi_csa_min_step: int = 1, multi_csa_max_step: int = 1825, ) -> None: @@ -2848,7 +2837,7 @@ def roll(self, tenor: datetime | str) -> MultiCsaCurve: def shift( self, spread: DualTypes, - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 composite: bool | NoInput = True, collateral: str | NoInput = NoInput(0), ) -> MultiCsaCurve: @@ -2968,14 +2957,13 @@ def __init__( convention: str | NoInput = NoInput(0), modifier: str | NoInput = NoInput(1), # inherits from existing curve objects calendar: CalInput = NoInput(1), # inherits from existing curve objects - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 ): self.index_base = NoInput(0) self.index_lag = 0 # not relevant for proxy curve self.id = _drb(uuid4().hex[:5], id) # 1 in a million clash cash_ccy, coll_ccy = cashflow.lower(), collateral.lower() self.collateral = coll_ccy - self._is_proxy = True self.fx_forwards = fx_forwards self.cash_currency = cash_ccy self.cash_pair = f"{cash_ccy}{cash_ccy}" diff --git a/python/rateslib/curves/rs.py b/python/rateslib/curves/rs.py index e6e2893d..96d45e26 100644 --- a/python/rateslib/curves/rs.py +++ b/python/rateslib/curves/rs.py @@ -2,14 +2,14 @@ from collections.abc import Callable from datetime import datetime -from typing import Any, TypeAlias +from typing import TYPE_CHECKING, Any from uuid import uuid4 from rateslib import defaults -from rateslib.calendars import CalInput, _get_modifier, get_calendar # type: ignore[attr-defined] +from rateslib.calendars import _get_modifier, get_calendar # type: ignore[attr-defined] from rateslib.calendars.dcfs import _get_convention from rateslib.default import NoInput, _drb -from rateslib.dual import DualTypes, Number, _get_adorder +from rateslib.dual.utils import _get_adorder from rateslib.rs import ( ADOrder, FlatBackwardInterpolator, @@ -23,14 +23,8 @@ ) from rateslib.rs import Curve as CurveObj # noqa: F401 -CurveInterpolator: TypeAlias = ( - FlatBackwardInterpolator - | FlatForwardInterpolator - | LinearInterpolator - | LogLinearInterpolator - | LinearZeroRateInterpolator - | NullInterpolator -) +if TYPE_CHECKING: + from rateslib.typing import CalInput, CurveInterpolator, DualTypes, Number class CurveRs: @@ -41,7 +35,7 @@ def __init__( interpolation: str | Callable[[datetime, dict[datetime, DualTypes]], DualTypes] | NoInput = NoInput(0), - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 convention: str | NoInput = NoInput(0), modifier: str | NoInput = NoInput(0), calendar: CalInput = NoInput(0), diff --git a/python/rateslib/default.py b/python/rateslib/default.py index 790da8df..f08c9ef8 100644 --- a/python/rateslib/default.py +++ b/python/rateslib/default.py @@ -53,8 +53,8 @@ class Fixings: """ @staticmethod - def _load_csv(dir: str, path: str) -> Series[float]: - target = os.path.join(dir, path) + def _load_csv(directory: str, path: str) -> Series[float]: + target = os.path.join(directory, path) if version.parse(pandas.__version__) < version.parse("2.0"): # pragma: no cover # this is tested by the minimum version gitflow actions. # TODO (low:dependencies) remove when pandas min version is bumped to 2.0 @@ -294,52 +294,87 @@ def _t_n(v: str) -> str: # teb-newline _: str = f"""\ Scheduling:\n -{''.join([_t_n(f'{attribute}: {getattr(self, attribute)}') for attribute in [ - 'stub', - 'stub_length', - 'modifier', - 'eom', - 'eom_fx', - 'eval_mode', - 'frequency_months', -]])} +{ + "".join( + [ + _t_n(f"{attribute}: {getattr(self, attribute)}") + for attribute in [ + "stub", + "stub_length", + "modifier", + "eom", + "eom_fx", + "eval_mode", + "frequency_months", + ] + ] + ) + } Instruments:\n -{''.join([_t_n(f'{attribute}: {getattr(self, attribute)}') for attribute in [ - 'convention', - 'payment_lag', - 'payment_lag_exchange', - 'payment_lag_specific', - 'notional', - 'fixing_method', - 'fixing_method_param', - 'spread_compound_method', - 'base_currency', - 'fx_delivery_lag', - 'fx_delta_type', - 'fx_option_metric', - 'cds_premium_accrued', - 'cds_recovery_rate', - 'cds_protection_discretization', -]])} +{ + "".join( + [ + _t_n(f"{attribute}: {getattr(self, attribute)}") + for attribute in [ + "convention", + "payment_lag", + "payment_lag_exchange", + "payment_lag_specific", + "notional", + "fixing_method", + "fixing_method_param", + "spread_compound_method", + "base_currency", + "fx_delivery_lag", + "fx_delta_type", + "fx_option_metric", + "cds_premium_accrued", + "cds_recovery_rate", + "cds_protection_discretization", + ] + ] + ) + } Curves:\n -{''.join([_t_n(f'{attribute}: {getattr(self, attribute)}') for attribute in [ - 'interpolation', - 'endpoints', - 'multi_csa_steps', - 'curve_caching', -]])} +{ + "".join( + [ + _t_n(f"{attribute}: {getattr(self, attribute)}") + for attribute in [ + "interpolation", + "endpoints", + "multi_csa_steps", + "curve_caching", + ] + ] + ) + } Solver:\n -{''.join([_t_n(f'{attribute}: {getattr(self, attribute)}') for attribute in [ - 'algorithm', - 'tag', - 'curve_not_in_solver', -]])} +{ + "".join( + [ + _t_n(f"{attribute}: {getattr(self, attribute)}") + for attribute in [ + "algorithm", + "tag", + "curve_not_in_solver", + ] + ] + ) + } Miscellaneous:\n -{''.join([_t_n(f'{attribute}: {getattr(self, attribute)}') for attribute in [ - 'headers', - 'no_fx_fixings_for_xcs', - 'pool', -]])} +{ + "".join( + [ + _t_n(f"{attribute}: {getattr(self, attribute)}") + for attribute in [ + "headers", + "no_fx_fixings_for_xcs", + "pool", + ] + ] + ) + } """ # noqa: W291 return _ diff --git a/python/rateslib/dual/__init__.py b/python/rateslib/dual/__init__.py index 0903c452..df148acd 100644 --- a/python/rateslib/dual/__init__.py +++ b/python/rateslib/dual/__init__.py @@ -1,353 +1,28 @@ from __future__ import annotations -import math -from functools import partial -from statistics import NormalDist -from typing import TypeAlias - -import numpy as np - -from rateslib.dual.variable import FLOATS, INTS, Arr1dF64, Arr1dObj, Arr2dF64, Arr2dObj, Variable -from rateslib.rs import ADOrder, Dual, Dual2, _dsolve1, _dsolve2, _fdsolve1, _fdsolve2 +from rateslib.dual.newton import newton_1dim, newton_ndim +from rateslib.dual.quadratic import quadratic_eqn +from rateslib.dual.utils import ( + dual_exp, + dual_inv_norm_cdf, + dual_log, + dual_norm_cdf, + dual_norm_pdf, + dual_solve, + gradient, + set_order, + set_order_convert, +) +from rateslib.dual.variable import Variable +from rateslib.rs import Dual, Dual2 Dual.__doc__ = "Dual number data type to perform first derivative automatic differentiation." Dual2.__doc__ = "Dual number data type to perform second derivative automatic differentiation." -DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" -Number: TypeAlias = "float | Dual | Dual2" - # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. - -def _dual_float(val: DualTypes) -> float: - """Overload for the float() builtin to handle Pyo3 issues with Variable""" - try: - return float(val) # type: ignore[arg-type] - except TypeError as e: # val is not Number but a Variable - if isinstance(val, Variable): - # This does not work well with rust. - # See: https://github.com/PyO3/pyo3/issues/3672 - # and https://github.com/PyO3/pyo3/discussions/3911 - return val.real - raise e - - -def _abs_float(val: DualTypes) -> float: - """Overload the abs() builtin to return the abs of the real component only""" - if isinstance(val, Dual | Dual2 | Variable): - return abs(val.real) - else: - return abs(val) - - -def set_order(val: DualTypes, order: int) -> DualTypes: - """ - Changes the order of a :class:`Dual` or :class:`Dual2` and a sets a :class:`Variable` - leaving floats and ints unchanged. - - Parameters - ---------- - val : float, int, Dual or Dual2 - The value to convert the order of. - order : int in [0, 1, 2] - The AD order to convert to. If ``val`` is float or int 0 will be used. - - Returns - ------- - float, int, Dual or Dual2 - """ - if order == 2 and isinstance(val, Dual | Variable): - return val.to_dual2() - elif order == 1 and isinstance(val, Dual2 | Variable): - return val.to_dual() - elif order == 0: - return _dual_float(val) - - # otherwise: - # - val is a Float or an Int - # - val is a Dual and order == 1 OR val is Dual2 and order == 2 - return val - - -def set_order_convert( - val: DualTypes, order: int, tag: list[str] | None, vars_from: Dual | Dual2 | None = None -) -> DualTypes: - """ - Convert a float, :class:`Dual` or :class:`Dual2` type to a specified alternate type. - - Parameters - ---------- - val : float, Dual or Dual2 - The value to convert. - order : int - The AD order to convert the value to if necessary. - tag : list of str, optional - The variable name(s) if upcasting a float to a Dual or Dual2 - vars_from : optional, Dual or Dual2 - A pre-existing Dual of correct order from which the Vars are extracted. Improves efficiency - when given. - - Returns - ------- - float, Dual, Dual2 - """ - if isinstance(val, FLOATS | INTS): - _ = [] if tag is None else tag - if order == 0: - return float(val) - elif order == 1: - if vars_from is None: - return Dual(val, _, []) - elif isinstance(vars_from, Dual): - return Dual.vars_from(vars_from, val, _, []) - else: - raise TypeError("`vars_from` must be a Dual when converting to ADOrder:1.") - elif order == 2: - if vars_from is None: - return Dual2(val, _, [], []) - elif isinstance(vars_from, Dual2): - return Dual2.vars_from(vars_from, val, _, [], []) - else: - raise TypeError("`vars_from` must be a Dual2 when converting to ADOrder:2.") - # else val is Dual or Dual2 so convert directly - return set_order(val, order) - - -def gradient( - dual: Dual | Dual2 | Variable, - vars: list[str] | None = None, - order: int = 1, - keep_manifold: bool = False, -) -> Arr1dF64 | Arr2dF64: - """ - Return derivatives of a dual number. - - Parameters - ---------- - dual : Dual, Dual2, Variable - The dual variable from which to derive derivatives. - vars : str, tuple, list optional - Name of the variables which to return gradients for. If not given - defaults to all vars attributed to the instance. - order : {1, 2} - Whether to return the first or second derivative of the dual number. - Second order will raise if applied to a ``Dual`` and not ``Dual2`` instance. - keep_manifold : bool - If ``order`` is 1 and the type is ``Dual2`` one can return a ``Dual2`` - where the ``dual2`` values are converted to ``dual`` values to represent - a first order manifold of the first derivative (and the ``dual2`` values - set to zero). Useful for propagation in iterations. - - Returns - ------- - float, ndarray, Dual2 - """ - if not isinstance(dual, Dual | Dual2 | Variable): - raise TypeError("Can call `gradient` only on dual-type variables.") - if order == 1: - if isinstance(dual, Variable): - dual = Dual(dual.real, vars=dual.vars, dual=dual.dual) - if vars is None and not keep_manifold: - return dual.dual - elif vars is not None and not keep_manifold: - return dual.grad1(vars) - elif isinstance(dual, Dual): # and keep_manifold: - raise TypeError("Dual type cannot perform `keep_manifold`.") - _ = dual.grad1_manifold(dual.vars if vars is None else vars) - return np.asarray(_) # type: ignore[return-value] - - elif order == 2: - if isinstance(dual, Variable): - dual = Dual2(dual.real, vars=dual.vars, dual=dual.dual, dual2=[]) - elif isinstance(dual, Dual): - raise TypeError("Dual type cannot derive second order automatic derivatives.") - - if vars is None: - return 2.0 * dual.dual2 # type: ignore[return-value] - else: - return dual.grad2(vars) - else: - raise ValueError("`order` must be in {1, 2} for gradient calculation.") - - -def dual_exp(x: DualTypes) -> Number: - """ - Calculate the exponential value of a regular int or float or a dual number. - - Parameters - ---------- - x : int, float, Dual, Dual2, Variable - Value to calculate exponent of. - - Returns - ------- - float, Dual, Dual2 - """ - if isinstance(x, Dual | Dual2 | Variable): - return x.__exp__() - return math.exp(x) - - -def dual_log(x: DualTypes, base: int | None = None) -> Number: - """ - Calculate the logarithm of a regular int or float or a dual number. - - Parameters - ---------- - x : int, float, Dual, Dual2, Variable - Value to calculate exponent of. - base : int, float, optional - Base of the logarithm. Defaults to e to compute natural logarithm - - Returns - ------- - float, Dual, Dual2 - """ - if isinstance(x, Dual | Dual2 | Variable): - val = x.__log__() - if base is None: - return val - else: - return val * (1 / math.log(base)) - elif base is None: - return math.log(x) - else: - return math.log(x, base) - - -def dual_norm_pdf(x: DualTypes) -> Number: - """ - Return the standard normal probability density function. - - Parameters - ---------- - x : float, Dual, Dual2, Variable - - Returns - ------- - float, Dual, Dual2 - """ - return dual_exp(-0.5 * x**2) / math.sqrt(2.0 * math.pi) - - -def dual_norm_cdf(x: DualTypes) -> Number: - """ - Return the cumulative standard normal distribution for given value. - - Parameters - ---------- - x : float, Dual, Dual2, Variable - - Returns - ------- - float, Dual, Dual2 - """ - if isinstance(x, Dual | Dual2 | Variable): - return x.__norm_cdf__() - else: - return NormalDist().cdf(x) - - -def dual_inv_norm_cdf(x: DualTypes) -> Number: - """ - Return the inverse cumulative standard normal distribution for given value. - - Parameters - ---------- - x : float, Dual, Dual2, Variable - - Returns - ------- - float, Dual, Dual2 - """ - if isinstance(x, Dual | Dual2 | Variable): - return x.__norm_inv_cdf__() - else: - return NormalDist().inv_cdf(x) - - -def dual_solve( - A: Arr2dObj | Arr2dF64, - b: Arr1dObj | Arr1dF64, - allow_lsq: bool = False, - types: tuple[type[float] | type[Dual] | type[Dual2], type[float] | type[Dual] | type[Dual2]] = ( - Dual, - Dual, - ), -) -> Arr1dObj | Arr1dF64: - """ - Solve a linear system of equations involving dual number data types. - - The `x` value is found for the equation :math:`Ax=b`. - - .. warning:: - - This method has not yet implemented :class:`~rateslib.dual.Variable` types. - - Parameters - ---------- - A: 2-d array - Left side matrix of values. - b: 1-d array - Right side vector of values. - allow_lsq: bool - Whether to allow solutions for non-square `A`, i.e. when `len(b) > len(x)`. - types: tuple - Defining the input data type elements of `A` and `b`, e.g. (float, float) or (Dual, Dual). - - Returns - ------- - 1-d array - """ - if types == (float, float): - # Use basic Numpy LinAlg - if allow_lsq: - return np.linalg.lstsq(A, b, rcond=None)[0] # type: ignore[arg-type,return-value] - else: - return np.linalg.solve(A, b) # type: ignore[arg-type,return-value] - - # Move to Rust implementation - if types in [(Dual, float), (Dual2, float)]: - raise TypeError( - "Not implemented for type crossing. Use (Dual, Dual) or (Dual2, Dual2). It is no less" - "efficient to preconvert `b` to dual types and then solve.", - ) - - map = {float: 0, Dual: 1, Dual2: 2} - A_ = np.vectorize(partial(set_order_convert, tag=[], order=map[types[0]], vars_from=None))(A) - b_ = np.vectorize(partial(set_order_convert, tag=[], order=map[types[1]], vars_from=None))(b) - - a_ = [item for sublist in A_.tolist() for item in sublist] # 1D array of A_ - b_ = b_[:, 0].tolist() - - if types == (Dual, Dual): - return np.array(_dsolve1(a_, b_, allow_lsq))[:, None] # type: ignore[return-value] - elif types == (Dual2, Dual2): - return np.array(_dsolve2(a_, b_, allow_lsq))[:, None] # type: ignore[return-value] - elif types == (float, Dual): - return np.array(_fdsolve1(A_, b_, allow_lsq))[:, None] # type: ignore[return-value] - elif types == (float, Dual2): - return np.array(_fdsolve2(A_, b_, allow_lsq))[:, None] # type: ignore[return-value] - else: - raise TypeError( - "Provided `types` argument are not permitted. Must be a 2-tuple with " - "elements from {float, Dual, Dual2}" - ) - - -def _get_adorder(order: int) -> ADOrder: - if order == 1: - return ADOrder.One - elif order == 0: - return ADOrder.Zero - elif order == 2: - return ADOrder.Two - else: - raise ValueError("Order for AD can only be in {0,1,2}") - - __all__ = [ "Dual", "Dual2", @@ -361,4 +36,7 @@ def _get_adorder(order: int) -> ADOrder: "gradient", "set_order_convert", "set_order", + "newton_ndim", + "newton_1dim", + "quadratic_eqn", ] diff --git a/python/rateslib/dual/newton.py b/python/rateslib/dual/newton.py new file mode 100644 index 00000000..ac2bb2ec --- /dev/null +++ b/python/rateslib/dual/newton.py @@ -0,0 +1,326 @@ +from __future__ import annotations + +from collections.abc import Callable, Sequence +from time import time +from typing import TYPE_CHECKING, Any, ParamSpec + +import numpy as np + +from rateslib.dual.utils import _dual_float, dual_solve +from rateslib.rs import Dual, Dual2 + +if TYPE_CHECKING: + from rateslib.typing import DualTypes +P = ParamSpec("P") + +# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International +# Commercial use of this code, and/or copying and redistribution is prohibited. +# Contact rateslib at gmail.com if this code is observed outside its intended sphere. + +STATE_MAP = { + 1: ["SUCCESS", "`conv_tol` reached"], + 2: ["SUCCESS", "`func_tol` reached"], + 3: ["SUCCESS", "closed form valid"], + -1: ["FAILURE", "`max_iter` breached"], +} + + +def _solver_result( + state: int, i: int, func_val: DualTypes, time: float, log: bool, algo: str +) -> dict[str, Any]: + if log: + print( + f"{STATE_MAP[state][0]}: {STATE_MAP[state][1]} after {i} iterations " + f"({algo}), `f_val`: {func_val}, " + f"`time`: {time:.4f}s", + ) + return { + "status": STATE_MAP[state][0], + "state": state, + "g": func_val, + "iterations": i, + "time": time, + } + + +def _float_if_not_string(x: str | DualTypes) -> str | float: + if not isinstance(x, str): + return _dual_float(x) + return x + + +def newton_1dim( + f: Callable[P, tuple[DualTypes, DualTypes]], + g0: DualTypes, + max_iter: int = 50, + func_tol: float = 1e-14, + conv_tol: float = 1e-9, + args: tuple[str | DualTypes, ...] = (), + pre_args: tuple[str | DualTypes, ...] = (), + final_args: tuple[str | DualTypes, ...] = (), + raise_on_fail: bool = True, +) -> dict[str, Any]: + """ + Use the Newton-Raphson algorithm to determine the root of a function searching **one** variable. + + Parameters + ---------- + f: callable + The function, *f*, to find the root of. Of the signature: `f(g, *args)`. + Must return a tuple where the second value is the derivative of *f* with respect to *g*. + g0: DualTypes + Initial guess of the root. Should be reasonable to avoid failure. + max_iter: int + The maximum number of iterations to try before exiting. + func_tol: float, optional + The absolute function tolerance to reach before exiting. + conv_tol: float, optional + The convergence tolerance for subsequent iterations of *g*. + args: tuple of float, Dual, Dual2 or str + Additional arguments passed to ``f``. + pre_args: tuple of float, Dual, Dual2 or str + Additional arguments passed to ``f`` used only in the float solve section of + the algorithm. + Functions are called with the signature `f(g, *(*args[as float], *pre_args))`. + final_args: tuple of float, Dual, Dual2 or str + Additional arguments passed to ``f`` in the final iteration of the algorithm + to capture AD sensitivities. + Functions are called with the signature `f(g, *(*args, *final_args))`. + raise_on_fail: bool, optional + If *False* will return a solver result dict with state and message indicating failure. + + Returns + ------- + dict + + Notes + ------ + Solves the root equation :math:`f(g; s_i)=0` for *g*. This method is AD-safe, meaning the + iteratively determined solution will preserve AD sensitivities, if the functions are suitable. + Functions which are not AD suitable, such as discontinuous functions or functions with + no derivative at given points, may yield spurious derivative results. + + This method works by first solving in the domain of floats (which is typically faster + for most complex functions), and then performing final iterations in higher AD modes to + capture derivative sensitivities. + + For special cases arguments can be passed separately to each of these modes using the + ``pre_args`` and ``final_args`` arguments, rather than generically supplying it to ``args``. + + Examples + -------- + Iteratively solve the equation: :math:`f(g, s) = g^2 - s = 0`. This has solution + :math:`g=\\pm \\sqrt{s}` and :math:`\\frac{dg}{ds} = \\frac{1}{2 \\sqrt{s}}`. + Thus for :math:`s=2` we expect the solution :code:`g=Dual(1.41.., ["s"], [0.35..])`. + + .. ipython:: python + + from rateslib.dual import newton_1dim + + def f(g, s): + f0 = g**2 - s # Function value + f1 = 2*g # Analytical derivative is required + return f0, f1 + + s = Dual(2.0, ["s"], []) + newton_1dim(f, g0=1.0, args=(s,)) + """ + t0 = time() + i = 0 + + # First attempt solution using faster float calculations + float_args = tuple(_float_if_not_string(_) for _ in args) + g0 = _dual_float(g0) + state = -1 + + while i < max_iter: + f0, f1 = f(*(g0, *float_args, *pre_args)) # type: ignore[call-arg, arg-type] + i += 1 + g1 = g0 - f0 / f1 + if abs(f0) < func_tol: + state = 2 + break + elif abs(g1 - g0) < conv_tol: + state = 1 + break + g0 = g1 + + if i == max_iter: + if raise_on_fail: + raise ValueError(f"`max_iter`: {max_iter} exceeded in 'newton_1dim' algorithm'.") + else: + return _solver_result(-1, i, g1, time() - t0, log=True, algo="newton_1dim") + + # # Final iteration method to preserve AD + f0, f1 = f(*(g1, *args, *final_args)) # type: ignore[call-arg, arg-type] + if isinstance(f0, Dual | Dual2) or isinstance(f1, Dual | Dual2): + i += 1 + g1 = g1 - f0 / f1 + if isinstance(f0, Dual2) or isinstance(f1, Dual2): + f0, f1 = f(*(g1, *args, *final_args)) # type: ignore[call-arg, arg-type] + i += 1 + g1 = g1 - f0 / f1 + + # # Analytical approach to capture AD sensitivities + # f0, f1 = f(g1, *(*args, *final_args)) + # if isinstance(f0, Dual): + # g1 = Dual.vars_from(f0, float(g1), f0.vars, float(f1) ** -1 * -gradient(f0)) + # if isinstance(f0, Dual2): + # g1 = Dual2.vars_from(f0, float(g1), f0.vars, float(f1) ** -1 * -gradient(f0), []) + # f02, f1 = f(g1, *(*args, *final_args)) + # + # #f0_beta = gradient(f0, order=1, vars=f0.vars, keep_manifold=True) + # + # f0_gamma = gradient(f02, order=2) + # f0_beta = gradient(f0, order=1) + # # f1 = set_order_convert(g1, tag=[], order=2) + # f1_gamma = gradient(f1, f0.vars, order=2) + # f1_beta = gradient(f1, f0.vars, order=1) + # + # g1_beta = -float(f1) ** -1 * f0_beta + # g1_gamma = ( + # -float(f1)**-1 * f0_gamma + + # float(f1)**-2 * ( + # np.matmul(f0_beta[:, None], f1_beta[None, :]) + + # np.matmul(f1_beta[:, None], f0_beta[None, :]) + + # float(f0) * f1_gamma + # ) - + # 2 * float(f1)**-3 * float(f0) * np.matmul(f1_beta[:, None], f1_beta[None, :]) + # ) + # g1 = Dual2.vars_from(f0, float(g1), f0.vars, g1_beta, g1_gamma.flatten()) + + return _solver_result(state, i, g1, time() - t0, log=False, algo="newton_1dim") + + +def newton_ndim( + f: Callable[P, tuple[Any, Any]], + g0: Sequence[DualTypes], + max_iter: int = 50, + func_tol: float = 1e-14, + conv_tol: float = 1e-9, + args: tuple[str | DualTypes, ...] = (), + pre_args: tuple[str | DualTypes, ...] = (), + final_args: tuple[str | DualTypes, ...] = (), + raise_on_fail: bool = True, +) -> dict[str, Any]: + r""" + Use the Newton-Raphson algorithm to determine a function root searching **many** variables. + + Solves the *n* root equations :math:`f_i(g_1, \hdots, g_n; s_k)=0` for each :math:`g_j`. + + Parameters + ---------- + f: callable + The function, *f*, to find the root of. Of the signature: `f([g_1, .., g_n], *args)`. + Must return a tuple where the second value is the Jacobian of *f* with respect to *g*. + g0: Sequence of DualTypes + Initial guess of the root values. Should be reasonable to avoid failure. + max_iter: int + The maximum number of iterations to try before exiting. + func_tol: float, optional + The absolute function tolerance to reach before exiting. + conv_tol: float, optional + The convergence tolerance for subsequent iterations of *g*. + args: tuple of float, Dual or Dual2 + Additional arguments passed to ``f``. + pre_args: tuple + Additional arguments passed to ``f`` only in the float solve section + of the algorithm. + Functions are called with the signature `f(g, *(*args[as float], *pre_args))`. + final_args: tuple of float, Dual, Dual2 + Additional arguments passed to ``f`` in the final iteration of the algorithm + to capture AD sensitivities. + Functions are called with the signature `f(g, *(*args, *final_args))`. + raise_on_fail: bool, optional + If *False* will return a solver result dict with state and message indicating failure. + + Returns + ------- + dict + + Examples + -------- + Iteratively solve the equation system: + + - :math:`f_0(\mathbf{g}, s) = g_1^2 + g_2^2 + s = 0`. + - :math:`f_1(\mathbf{g}, s) = g_1^2 - 2g_2^2 + s = 0`. + + .. ipython:: python + + from rateslib.dual import newton_ndim + + def f(g, s): + # Function value + f0 = g[0] ** 2 + g[1] ** 2 + s + f1 = g[0] ** 2 - 2 * g[1]**2 - s + # Analytical derivative as Jacobian matrix is required + f00 = 2 * g[0] + f01 = 2 * g[1] + f10 = 2 * g[0] + f11 = -4 * g[1] + return [f0, f1], [[f00, f01], [f10, f11]] + + s = Dual(-2.0, ["s"], []) + newton_ndim(f, g0=[1.0, 1.0], args=(s,)) + """ + t0 = time() + i = 0 + n = len(g0) + + # First attempt solution using faster float calculations + float_args = tuple(_float_if_not_string(_) for _ in args) + g0_ = np.array([_dual_float(_) for _ in g0]) + state = -1 + + while i < max_iter: + f0, f1 = f(*(g0_, *float_args, *pre_args)) # type: ignore[call-arg, arg-type] + f0 = np.array(f0)[:, np.newaxis] + f1 = np.array(f1) + + i += 1 + g1 = g0_ - np.matmul(np.linalg.inv(f1), f0)[:, 0] + if all(abs(_) < func_tol for _ in f0[:, 0]): + state = 2 + break + elif all(abs(g1[_] - g0_[_]) < conv_tol for _ in range(n)): + state = 1 + break + g0_ = g1 + + if i == max_iter: + if raise_on_fail: + raise ValueError(f"`max_iter`: {max_iter} exceeded in 'newton_ndim' algorithm'.") + else: + return _solver_result(-1, i, g1, time() - t0, log=True, algo="newton_ndim") + + # Final iteration method to preserve AD + f0, f1 = f(*(g1, *args, *final_args)) # type: ignore[call-arg] + f1, f0 = np.array(f1), np.array(f0) + + # get AD type + ad: int = 0 + if _is_any_dual(f0) or _is_any_dual(f1): + ad = 1 + DualType: type[Dual] | type[Dual2] = Dual + elif _is_any_dual2(f0) or _is_any_dual2(f1): + ad = 2 + DualType = Dual2 + + if ad > 0: + i += 1 + g1 = g0_ - dual_solve(f1, f0[:, None], allow_lsq=False, types=(DualType, DualType))[:, 0] + if ad == 2: + f0, f1 = f(*(g1, *args, *final_args)) # type: ignore[call-arg] + f1, f0 = np.array(f1), np.array(f0) + i += 1 + g1 = g1 - dual_solve(f1, f0[:, None], allow_lsq=False, types=(DualType, DualType))[:, 0] + + return _solver_result(state, i, g1, time() - t0, log=False, algo="newton_ndim") + + +def _is_any_dual(arr: np.ndarray[tuple[int, ...], np.dtype[np.object_]]) -> bool: + return any(isinstance(_, Dual) for _ in arr.flatten()) + + +def _is_any_dual2(arr: np.ndarray[tuple[int, ...], np.dtype[np.object_]]) -> bool: + return any(isinstance(_, Dual2) for _ in arr.flatten()) diff --git a/python/rateslib/dual/quadratic.py b/python/rateslib/dual/quadratic.py new file mode 100644 index 00000000..c62b9519 --- /dev/null +++ b/python/rateslib/dual/quadratic.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from rateslib.dual.newton import _solver_result + +if TYPE_CHECKING: + from rateslib.typing import DualTypes + +# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International +# Commercial use of this code, and/or copying and redistribution is prohibited. +# Contact rateslib at gmail.com if this code is observed outside its intended sphere. + + +def quadratic_eqn( + a: DualTypes, b: DualTypes, c: DualTypes, x0: DualTypes, raise_on_fail: bool = True +) -> dict[str, Any]: + """ + Solve the quadratic equation, :math:`ax^2 + bx +c = 0`, with error reporting. + + Parameters + ---------- + a: float, Dual Dual2 + The *a* coefficient value. + b: float, Dual Dual2 + The *b* coefficient value. + c: float, Dual Dual2 + The *c* coefficient value. + x0: float, Dual, Dual2 + The expected solution to discriminate between two possible solutions. + raise_on_fail: bool, optional + Whether to raise if unsolved or return a solver result in failed state. + + Returns + ------- + dict + + Notes + ----- + If ``a`` is evaluated to be less that 1e-15 in absolute terms then it is treated as zero and the + equation is solved as a linear equation in ``b`` and ``c`` only. + + Examples + -------- + .. ipython:: python + + from rateslib.dual import quadratic_eqn + + quadratic_eqn(a=1.0, b=1.0, c=Dual(-6.0, ["c"], []), x0=-2.9) + + """ + discriminant = b**2 - 4 * a * c + if discriminant < 0.0: + if raise_on_fail: + raise ValueError("`quadratic_eqn` has failed to solve: discriminant is less than zero.") + else: + return _solver_result( + state=-1, + i=0, + func_val=1e308, + time=0.0, + log=True, + algo="quadratic_eqn", + ) + + if abs(a) > 1e-15: # machine tolerance on normal float64 is 2.22e-16 + sqrt_d = discriminant**0.5 + _1 = (-b + sqrt_d) / (2 * a) + _2 = (-b - sqrt_d) / (2 * a) + if abs(x0 - _1) < abs(x0 - _2): + return _solver_result( + state=3, + i=1, + func_val=_1, + time=0.0, + log=False, + algo="quadratic_eqn", + ) + else: + return _solver_result( + state=3, + i=1, + func_val=_2, + time=0.0, + log=False, + algo="quadratic_eqn", + ) + else: + # 'a' is considered too close to zero for the quadratic eqn, solve the linear eqn + # to avoid division by zero errors + return _solver_result( + state=3, + i=1, + func_val=-c / b, + time=0.0, + log=False, + algo="quadratic_eqn->linear_eqn", + ) diff --git a/python/rateslib/dual/utils.py b/python/rateslib/dual/utils.py new file mode 100644 index 00000000..cee07345 --- /dev/null +++ b/python/rateslib/dual/utils.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +import math +from functools import partial +from statistics import NormalDist +from typing import TYPE_CHECKING + +import numpy as np + +from rateslib import defaults +from rateslib.dual.variable import FLOATS, INTS, Variable +from rateslib.rs import ADOrder, Dual, Dual2, _dsolve1, _dsolve2, _fdsolve1, _fdsolve2 + +if TYPE_CHECKING: + from rateslib.typing import Arr1dF64, Arr1dObj, Arr2dF64, Arr2dObj, DualTypes, Number + +Dual.__doc__ = "Dual number data type to perform first derivative automatic differentiation." +Dual2.__doc__ = "Dual number data type to perform second derivative automatic differentiation." + +# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International +# Commercial use of this code, and/or copying and redistribution is prohibited. +# Contact rateslib at gmail.com if this code is observed outside its intended sphere. + + +def _dual_float(val: DualTypes) -> float: + """Overload for the float() builtin to handle Pyo3 issues with Variable""" + try: + return float(val) # type: ignore[arg-type] + except TypeError as e: # val is not Number but a Variable + if isinstance(val, Variable): + # This does not work well with rust. + # See: https://github.com/PyO3/pyo3/issues/3672 + # and https://github.com/PyO3/pyo3/discussions/3911 + return val.real + raise e + + +def _abs_float(val: DualTypes) -> float: + """Overload the abs() builtin to return the abs of the real component only""" + if isinstance(val, Dual | Dual2 | Variable): + return abs(val.real) + else: + return abs(val) + + +def _get_order_of(val: DualTypes) -> int: + """Get the AD order of a DualType including checking the globals for the current order.""" + if isinstance(val, Dual): + ad_order: int = 1 + elif isinstance(val, Dual2): + ad_order = 2 + elif isinstance(val, Variable): + ad_order = defaults._global_ad_order + else: + ad_order = 0 + return ad_order + + +def set_order(val: DualTypes, order: int) -> DualTypes: + """ + Changes the order of a :class:`Dual` or :class:`Dual2` and a sets a :class:`Variable` + leaving floats and ints unchanged. + + Parameters + ---------- + val : float, int, Dual or Dual2 + The value to convert the order of. + order : int in [0, 1, 2] + The AD order to convert to. If ``val`` is float or int 0 will be used. + + Returns + ------- + float, int, Dual or Dual2 + """ + if order == 2 and isinstance(val, Dual | Variable): + return val.to_dual2() + elif order == 1 and isinstance(val, Dual2 | Variable): + return val.to_dual() + elif order == 0: + return _dual_float(val) + + # otherwise: + # - val is a Float or an Int + # - val is a Dual and order == 1 OR val is Dual2 and order == 2 + return val + + +def set_order_convert( + val: DualTypes, order: int, tag: list[str] | None, vars_from: Dual | Dual2 | None = None +) -> DualTypes: + """ + Convert a float, :class:`Dual` or :class:`Dual2` type to a specified alternate type. + + Parameters + ---------- + val : float, Dual or Dual2 + The value to convert. + order : int + The AD order to convert the value to if necessary. + tag : list of str, optional + The variable name(s) if upcasting a float to a Dual or Dual2 + vars_from : optional, Dual or Dual2 + A pre-existing Dual of correct order from which the Vars are extracted. Improves efficiency + when given. + + Returns + ------- + float, Dual, Dual2 + """ + if isinstance(val, FLOATS | INTS): + _ = [] if tag is None else tag + if order == 0: + return float(val) + elif order == 1: + if vars_from is None: + return Dual(val, _, []) + elif isinstance(vars_from, Dual): + return Dual.vars_from(vars_from, val, _, []) + else: + raise TypeError("`vars_from` must be a Dual when converting to ADOrder:1.") + elif order == 2: + if vars_from is None: + return Dual2(val, _, [], []) + elif isinstance(vars_from, Dual2): + return Dual2.vars_from(vars_from, val, _, [], []) + else: + raise TypeError("`vars_from` must be a Dual2 when converting to ADOrder:2.") + # else val is Dual or Dual2 so convert directly + return set_order(val, order) + + +def gradient( + dual: Dual | Dual2 | Variable, + vars: list[str] | None = None, # noqa: A002 + order: int = 1, + keep_manifold: bool = False, +) -> Arr1dF64 | Arr2dF64: + """ + Return derivatives of a dual number. + + Parameters + ---------- + dual : Dual, Dual2, Variable + The dual variable from which to derive derivatives. + vars : str, tuple, list optional + Name of the variables which to return gradients for. If not given + defaults to all vars attributed to the instance. + order : {1, 2} + Whether to return the first or second derivative of the dual number. + Second order will raise if applied to a ``Dual`` and not ``Dual2`` instance. + keep_manifold : bool + If ``order`` is 1 and the type is ``Dual2`` one can return a ``Dual2`` + where the ``dual2`` values are converted to ``dual`` values to represent + a first order manifold of the first derivative (and the ``dual2`` values + set to zero). Useful for propagation in iterations. + + Returns + ------- + float, ndarray, Dual2 + """ + if not isinstance(dual, Dual | Dual2 | Variable): + raise TypeError("Can call `gradient` only on dual-type variables.") + if order == 1: + if isinstance(dual, Variable): + dual = Dual(dual.real, vars=dual.vars, dual=dual.dual) + if vars is None and not keep_manifold: + return dual.dual + elif vars is not None and not keep_manifold: + return dual.grad1(vars) + elif isinstance(dual, Dual): # and keep_manifold: + raise TypeError("Dual type cannot perform `keep_manifold`.") + _ = dual.grad1_manifold(dual.vars if vars is None else vars) + return np.asarray(_) # type: ignore[return-value] + + elif order == 2: + if isinstance(dual, Variable): + dual = Dual2(dual.real, vars=dual.vars, dual=dual.dual, dual2=[]) + elif isinstance(dual, Dual): + raise TypeError("Dual type cannot derive second order automatic derivatives.") + + if vars is None: + return 2.0 * dual.dual2 # type: ignore[return-value] + else: + return dual.grad2(vars) + else: + raise ValueError("`order` must be in {1, 2} for gradient calculation.") + + +def dual_exp(x: DualTypes) -> Number: + """ + Calculate the exponential value of a regular int or float or a dual number. + + Parameters + ---------- + x : int, float, Dual, Dual2, Variable + Value to calculate exponent of. + + Returns + ------- + float, Dual, Dual2 + """ + if isinstance(x, Dual | Dual2 | Variable): + return x.__exp__() + return math.exp(x) + + +def dual_log(x: DualTypes, base: int | None = None) -> Number: + """ + Calculate the logarithm of a regular int or float or a dual number. + + Parameters + ---------- + x : int, float, Dual, Dual2, Variable + Value to calculate exponent of. + base : int, float, optional + Base of the logarithm. Defaults to e to compute natural logarithm + + Returns + ------- + float, Dual, Dual2 + """ + if isinstance(x, Dual | Dual2 | Variable): + val = x.__log__() + if base is None: + return val + else: + return val * (1 / math.log(base)) + elif base is None: + return math.log(x) + else: + return math.log(x, base) + + +def dual_norm_pdf(x: DualTypes) -> Number: + """ + Return the standard normal probability density function. + + Parameters + ---------- + x : float, Dual, Dual2, Variable + + Returns + ------- + float, Dual, Dual2 + """ + return dual_exp(-0.5 * x**2) / math.sqrt(2.0 * math.pi) + + +def dual_norm_cdf(x: DualTypes) -> Number: + """ + Return the cumulative standard normal distribution for given value. + + Parameters + ---------- + x : float, Dual, Dual2, Variable + + Returns + ------- + float, Dual, Dual2 + """ + if isinstance(x, Dual | Dual2 | Variable): + return x.__norm_cdf__() + else: + return NormalDist().cdf(x) + + +def dual_inv_norm_cdf(x: DualTypes) -> Number: + """ + Return the inverse cumulative standard normal distribution for given value. + + Parameters + ---------- + x : float, Dual, Dual2, Variable + + Returns + ------- + float, Dual, Dual2 + """ + if isinstance(x, Dual | Dual2 | Variable): + return x.__norm_inv_cdf__() + else: + return NormalDist().inv_cdf(x) + + +def dual_solve( + A: Arr2dObj | Arr2dF64, + b: Arr1dObj | Arr1dF64, + allow_lsq: bool = False, + types: tuple[type[float] | type[Dual] | type[Dual2], type[float] | type[Dual] | type[Dual2]] = ( + Dual, + Dual, + ), +) -> Arr1dObj | Arr1dF64: + """ + Solve a linear system of equations involving dual number data types. + + The `x` value is found for the equation :math:`Ax=b`. + + .. warning:: + + This method has not yet implemented :class:`~rateslib.dual.Variable` types. + + Parameters + ---------- + A: 2-d array + Left side matrix of values. + b: 1-d array + Right side vector of values. + allow_lsq: bool + Whether to allow solutions for non-square `A`, i.e. when `len(b) > len(x)`. + types: tuple + Defining the input data type elements of `A` and `b`, e.g. (float, float) or (Dual, Dual). + + Returns + ------- + 1-d array + """ + if types == (float, float): + # Use basic Numpy LinAlg + if allow_lsq: + return np.linalg.lstsq(A, b, rcond=None)[0] # type: ignore[arg-type,return-value] + else: + return np.linalg.solve(A, b) # type: ignore[arg-type,return-value] + + # Move to Rust implementation + if types in [(Dual, float), (Dual2, float)]: + raise TypeError( + "Not implemented for type crossing. Use (Dual, Dual) or (Dual2, Dual2). It is no less" + "efficient to preconvert `b` to dual types and then solve.", + ) + + map_ = {float: 0, Dual: 1, Dual2: 2} + A_ = np.vectorize(partial(set_order_convert, tag=[], order=map_[types[0]], vars_from=None))(A) + b_ = np.vectorize(partial(set_order_convert, tag=[], order=map_[types[1]], vars_from=None))(b) + + a_ = [item for sublist in A_.tolist() for item in sublist] # 1D array of A_ + b_ = b_[:, 0].tolist() + + if types == (Dual, Dual): + return np.array(_dsolve1(a_, b_, allow_lsq))[:, None] # type: ignore[return-value] + elif types == (Dual2, Dual2): + return np.array(_dsolve2(a_, b_, allow_lsq))[:, None] # type: ignore[return-value] + elif types == (float, Dual): + return np.array(_fdsolve1(A_, b_, allow_lsq))[:, None] # type: ignore[return-value] + elif types == (float, Dual2): + return np.array(_fdsolve2(A_, b_, allow_lsq))[:, None] # type: ignore[return-value] + else: + raise TypeError( + "Provided `types` argument are not permitted. Must be a 2-tuple with " + "elements from {float, Dual, Dual2}" + ) + + +def _get_adorder(order: int) -> ADOrder: + """Convert int AD order to an ADOrder enum type.""" + if order == 1: + return ADOrder.One + elif order == 0: + return ADOrder.Zero + elif order == 2: + return ADOrder.Two + else: + raise ValueError("Order for AD can only be in {0,1,2}") diff --git a/python/rateslib/dual/variable.py b/python/rateslib/dual/variable.py index 18f1e07a..0aaaf679 100644 --- a/python/rateslib/dual/variable.py +++ b/python/rateslib/dual/variable.py @@ -2,7 +2,7 @@ import math from collections.abc import Sequence -from typing import Any, TypeAlias +from typing import TYPE_CHECKING, Any import numpy as np @@ -10,16 +10,13 @@ from rateslib.default import NoInput from rateslib.rs import Dual, Dual2 +if TYPE_CHECKING: + from rateslib.typing import Arr1dF64 + PRECISION = 1e-14 FLOATS = float | np.float16 | np.float32 | np.float64 | np.longdouble INTS = int | np.int8 | np.int16 | np.int32 | np.int32 | np.int64 -# https://stackoverflow.com/questions/68916893/ -Arr1dF64: TypeAlias = "np.ndarray[tuple[int], np.dtype[np.float64]]" -Arr2dF64: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.float64]]" -Arr1dObj: TypeAlias = "np.ndarray[tuple[int], np.dtype[np.object_]]" -Arr2dObj: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.object_]]" - class Variable: """ @@ -50,7 +47,7 @@ class Variable: def __init__( self, real: float, - vars: Sequence[str] = (), + vars: Sequence[str] = (), # noqa: A002 dual: list[float] | Arr1dF64 | NoInput = NoInput(0), ): self.real: float = float(real) diff --git a/python/rateslib/fx/fx_forwards.py b/python/rateslib/fx/fx_forwards.py index adf969a7..b1399688 100644 --- a/python/rateslib/fx/fx_forwards.py +++ b/python/rateslib/fx/fx_forwards.py @@ -4,17 +4,20 @@ import warnings from datetime import datetime, timedelta from itertools import product -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np from pandas import DataFrame, Series -from rateslib.calendars import CalInput, add_tenor +from rateslib.calendars import add_tenor from rateslib.curves import Curve, LineCurve, MultiCsaCurve, ProxyCurve from rateslib.default import NoInput, PlotOutput, _validate_states, _WithState, plot -from rateslib.dual import Dual, DualTypes, Number, gradient +from rateslib.dual import Dual, gradient from rateslib.fx.fx_rates import FXRates +if TYPE_CHECKING: + from rateslib.typing import CalInput, DualTypes, Number + """ .. ipython:: python :suppress: @@ -330,7 +333,7 @@ def __repr__(self) -> str: if len(self.currencies_list) > 5: return ( f"" + f"+{len(self.currencies_list) - 2} others] at {hex(id(self))}>" ) else: return f"" @@ -371,7 +374,7 @@ def _get_forwards_transformation_matrix( ) elif T.sum() < (2 * q) - 1: raise ValueError( - f"`fx_curves` is underspecified. {2 * q -1} curves are expected " + f"`fx_curves` is underspecified. {2 * q - 1} curves are expected " f"but {len(fx_curves.keys())} provided.", ) elif np.linalg.matrix_rank(T) != q: @@ -814,7 +817,7 @@ def convert_positions( # j = self.currencies[base] # return np.sum(array_ * self.fx_array[:, j]) - sum: DualTypes = 0.0 + sum_: DualTypes = 0.0 for d in array_.columns: d_sum: DualTypes = 0.0 for ccy in array_.index: @@ -822,11 +825,11 @@ def convert_positions( value_: DualTypes | None = self.convert(array_.loc[ccy, d], ccy, base, d) # type: ignore[arg-type] d_sum += 0.0 if value_ is None else value_ if abs(d_sum) < 1e-2: - sum += d_sum + sum_ += d_sum else: # only discount if there is a real value value_ = self.convert(d_sum, base, base, d, self.immediate) # type: ignore[arg-type] - sum += 0.0 if value_ is None else value_ - return sum + sum_ += 0.0 if value_ is None else value_ + return sum_ @_validate_states def swap( @@ -912,7 +915,7 @@ def curve( convention: str | NoInput = NoInput(1), # will inherit from available curve modifier: str | NoInput = NoInput(1), # will inherit from available curve calendar: CalInput = NoInput(1), # will inherit from available curve - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 ) -> Curve: """ Return a cash collateral curve. diff --git a/python/rateslib/fx/fx_rates.py b/python/rateslib/fx/fx_rates.py index 1eaa0d2b..7f7184d5 100644 --- a/python/rateslib/fx/fx_rates.py +++ b/python/rateslib/fx/fx_rates.py @@ -3,18 +3,21 @@ import warnings from datetime import datetime from functools import cached_property -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np from pandas import DataFrame, Series from rateslib import defaults from rateslib.default import NoInput, _drb, _make_py_json, _WithState -from rateslib.dual import Dual, DualTypes, Number, _get_adorder, gradient -from rateslib.dual.variable import Arr1dF64, Arr1dObj, Arr2dObj +from rateslib.dual import Dual, gradient +from rateslib.dual.utils import _get_adorder from rateslib.rs import Ccy, FXRate from rateslib.rs import FXRates as FXRatesObj +if TYPE_CHECKING: + from rateslib.typing import Arr1dF64, Arr1dObj, Arr2dObj, DualTypes, Number + """ .. ipython:: python :suppress: @@ -150,8 +153,8 @@ def __copy__(self) -> FXRates: def __repr__(self) -> str: if len(self.currencies_list) > 5: return ( - f"" + f"" ) else: return f"" diff --git a/python/rateslib/fx_volatility.py b/python/rateslib/fx_volatility.py index e6812df6..2213bf72 100644 --- a/python/rateslib/fx_volatility.py +++ b/python/rateslib/fx_volatility.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from datetime import datetime as dt +from typing import TYPE_CHECKING, Any from uuid import uuid4 import numpy as np @@ -10,23 +11,26 @@ from rateslib import defaults from rateslib.calendars import get_calendar -from rateslib.default import NoInput, _drb, _WithState, plot, plot3d +from rateslib.default import NoInput, PlotOutput, _drb, _WithState, plot, plot3d from rateslib.dual import ( Dual, Dual2, - DualTypes, Variable, dual_exp, dual_inv_norm_cdf, dual_log, dual_norm_cdf, dual_norm_pdf, + newton_1dim, set_order_convert, ) +from rateslib.dual.utils import _dual_float from rateslib.rs import index_left_f64 -from rateslib.solver import newton_1dim from rateslib.splines import PPSplineDual, PPSplineDual2, PPSplineF64, evaluate +if TYPE_CHECKING: + from rateslib.typing import DualTypes + TERMINAL_DATE = dt(2100, 1, 1) @@ -80,24 +84,26 @@ class FXDeltaVolSmile(_WithState): def __init__( self, - nodes: dict, + nodes: dict[float, DualTypes], eval_date: datetime, expiry: datetime, delta_type: str, - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 ad: int = 0, ): - self.id = uuid4().hex[:5] + "_" if id is NoInput.blank else id # 1 in a million clash - self.eval_date = eval_date - self.expiry = expiry - self.t_expiry = (expiry - eval_date).days / 365.0 - self.t_expiry_sqrt = self.t_expiry**0.5 + self.id: str = ( + uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id + ) # 1 in a million clash + self.eval_date: datetime = eval_date + self.expiry: datetime = expiry + self.t_expiry: float = (expiry - eval_date).days / 365.0 + self.t_expiry_sqrt: float = self.t_expiry**0.5 self.delta_type: str = _validate_delta_type(delta_type) self.__set_nodes__(nodes, ad) def __set_nodes__(self, nodes: dict[float, DualTypes], ad: int) -> None: - self.ad = None + # self.ad = None self.nodes = nodes self.node_keys = list(self.nodes.keys()) @@ -117,16 +123,16 @@ def __set_nodes__(self, nodes: dict[float, DualTypes], ad: int) -> None: self._right_n = 2 # right hand spline endpoint will be constrained by derivative if self.n in [1, 2]: - self.t = [0.0] * 4 + [float(upper_bound)] * 4 + self.t = [0.0] * 4 + [_dual_float(upper_bound)] * 4 else: - self.t = [0.0] * 4 + self.node_keys[1:-1] + [float(upper_bound)] * 4 + self.t = [0.0] * 4 + self.node_keys[1:-1] + [_dual_float(upper_bound)] * 4 self._set_ad_order(ad) # includes _csolve() - def __iter__(self): + def __iter__(self) -> Any: raise TypeError("`FXDeltaVolSmile` is not iterable.") - def __getitem__(self, item): + def __getitem__(self, item: DualTypes) -> DualTypes: """ Get a value from the DeltaVolSmile given an item which is a delta_index. """ @@ -145,7 +151,9 @@ def __getitem__(self, item): else: return evaluate(self.spline, item, 0) - def _get_index(self, delta_index: float, expiry: NoInput(0)): + def _get_index( + self, delta_index: DualTypes, expiry: datetime | NoInput = NoInput(0) + ) -> DualTypes: """ Return a volatility from a given delta index Used internally alongside Surface, where a surface also requires an expiry. @@ -154,13 +162,13 @@ def _get_index(self, delta_index: float, expiry: NoInput(0)): def get( self, - delta: float, + delta: DualTypes, delta_type: str, phi: float, w_deli: DualTypes | NoInput = NoInput(0), w_spot: DualTypes | NoInput = NoInput(0), u: DualTypes | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return a volatility for a provided real option delta. @@ -193,9 +201,9 @@ def get_from_strike( self, k: DualTypes, f: DualTypes, - w_deli: DualTypes | NoInput = NoInput(0), - w_spot: DualTypes | NoInput = NoInput(0), - expiry: datetime | NoInput(0) = NoInput(0), + w_deli: DualTypes | NoInput, + w_spot: DualTypes | NoInput, + expiry: datetime | NoInput = NoInput(0), ) -> tuple[DualTypes, DualTypes, DualTypes]: """ Given an option strike return associated delta and vol values. @@ -231,34 +239,46 @@ def get_from_strike( "due to potential pricing errors.", ) - u = k / f # moneyness - eta, z_w, z_u = _delta_type_constants(self.delta_type, w_deli / w_spot, u) + u: DualTypes = k / f # moneyness + w: DualTypes | NoInput = ( + NoInput(0) + if isinstance(w_deli, NoInput) or isinstance(w_spot, NoInput) + else w_deli / w_spot + ) + eta, z_w, z_u = _delta_type_constants(self.delta_type, w, u) # Variables are passed to these functions so that iteration can take place using float # which is faster and then a final iteration at the fixed point can be included with Dual # variables to capture fixed point sensitivity. - def root(delta, u, sqrt_t, z_u, z_w, ad): + def root( + delta: DualTypes, + u: DualTypes, + sqrt_t: DualTypes, + z_u: DualTypes, + z_w: DualTypes, + ad: int, + ) -> tuple[DualTypes, DualTypes]: # Function value delta_index = -delta vol_ = self[delta_index] / 100.0 - vol_ = float(vol_) if ad == 0 else vol_ + vol_ = _dual_float(vol_) if ad == 0 else vol_ vol_sqrt_t = sqrt_t * vol_ d_plus_min = -dual_log(u) / vol_sqrt_t + eta * vol_sqrt_t f0 = delta + z_w * z_u * dual_norm_cdf(-d_plus_min) # Derivative dvol_ddelta = -1.0 * evaluate(self.spline, delta_index, 1) / 100.0 - dvol_ddelta = float(dvol_ddelta) if ad == 0 else dvol_ddelta + dvol_ddelta = _dual_float(dvol_ddelta) if ad == 0 else dvol_ddelta dd_ddelta = dvol_ddelta * (dual_log(u) * sqrt_t / vol_sqrt_t**2 + eta * sqrt_t) f1 = 1 - z_w * z_u * dual_norm_pdf(-d_plus_min) * dd_ddelta return f0, f1 # Initial approximation is obtained through the closed form solution of the delta given # an approximated delta at close to the base of the smile. - avg_vol = float(list(self.nodes.values())[int(self.n / 2)]) / 100.0 - d_plus_min = -dual_log(float(u)) / ( - avg_vol * float(self.t_expiry_sqrt) - ) + eta * avg_vol * float(self.t_expiry_sqrt) - delta_0 = -float(z_u) * float(z_w) * dual_norm_cdf(-d_plus_min) + avg_vol = _dual_float(list(self.nodes.values())[int(self.n / 2)]) / 100.0 + d_plus_min = -dual_log(_dual_float(u)) / ( + avg_vol * _dual_float(self.t_expiry_sqrt) + ) + eta * avg_vol * _dual_float(self.t_expiry_sqrt) + delta_0 = -_dual_float(z_u) * _dual_float(z_w) * dual_norm_cdf(-d_plus_min) solver_result = newton_1dim( root, @@ -274,13 +294,13 @@ def root(delta, u, sqrt_t, z_u, z_w, ad): def _convert_delta( self, - delta, - delta_type, - phi, - w_deli, - w_spot, - u, - ): + delta: DualTypes, + delta_type: str, + phi: float, + w_deli: DualTypes | NoInput, + w_spot: DualTypes | NoInput, + u: DualTypes | NoInput, + ) -> DualTypes: """ Convert the given option delta into a delta index associated with the *Smile*. @@ -303,7 +323,11 @@ def _convert_delta( ------- DualTypes """ - z_w = NoInput(0) if (w_deli is NoInput(0) or w_spot is NoInput(0)) else w_deli / w_spot + z_w = ( + NoInput(0) + if (isinstance(w_deli, NoInput) or isinstance(w_spot, NoInput)) + else w_deli / w_spot + ) eta_0, z_w_0, z_u_0 = _delta_type_constants(delta_type, z_w, u) eta_1, z_w_1, z_u_1 = _delta_type_constants(self.delta_type, z_w, u) @@ -318,26 +342,35 @@ def _convert_delta( else: # root solver phi_inv = dual_inv_norm_cdf(-delta / (z_w_0 * z_u_0)) - def root(delta_idx, z_1, eta_0, eta_1, sqrt_t, ad): + def root( + delta_idx: DualTypes, + z_1: DualTypes, + eta_0: float, + eta_1: float, + sqrt_t: DualTypes, + ad: int, + ) -> tuple[DualTypes, DualTypes]: # Function value vol_ = self[delta_idx] / 100.0 - vol_ = float(vol_) if ad == 0 else vol_ + vol_ = _dual_float(vol_) if ad == 0 else vol_ _ = phi_inv - (eta_1 - eta_0) * vol_ * sqrt_t f0 = delta_idx - z_1 * dual_norm_cdf(_) # Derivative dvol_ddelta_idx = evaluate(self.spline, delta_idx, 1) / 100.0 - dvol_ddelta_idx = float(dvol_ddelta_idx) if ad == 0 else dvol_ddelta_idx + dvol_ddelta_idx = _dual_float(dvol_ddelta_idx) if ad == 0 else dvol_ddelta_idx f1 = 1 - z_1 * dual_norm_pdf(_) * (eta_1 - eta_0) * sqrt_t * dvol_ddelta_idx return f0, f1 + g0: DualTypes = min(-delta, _dual_float(w_deli / w_spot)) # type: ignore[operator, assignment] solver_result = newton_1dim( f=root, - g0=min(-delta, float(w_deli / w_spot)), + g0=g0, args=(z_u_1 * z_w_1, eta_0, eta_1, self.t_expiry_sqrt), pre_args=(0,), final_args=(1,), ) - return solver_result["g"] + ret: DualTypes = solver_result["g"] + return ret def _delta_index_from_call_or_put_delta( self, @@ -345,7 +378,7 @@ def _delta_index_from_call_or_put_delta( phi: float, z_w: DualTypes | NoInput = NoInput(0), u: DualTypes | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Get the *Smile* index delta given an option delta of the same type as the *Smile*. @@ -371,11 +404,11 @@ def _delta_index_from_call_or_put_delta( if self.delta_type == "forward": put_delta = delta - 1.0 elif self.delta_type == "spot": - put_delta = delta - z_w + put_delta = delta - z_w # type: ignore[operator] elif self.delta_type == "forward_pa": - put_delta = delta - u + put_delta = delta - u # type: ignore[operator] else: # self.delta_type == "spot_pa": - put_delta = delta - z_w * u + put_delta = delta - z_w * u # type: ignore[operator] else: put_delta = delta return -1.0 * put_delta @@ -464,11 +497,11 @@ def _delta_index_from_call_or_put_delta( # self.spline_u_delta_approx.csolve(u, delta.tolist()[::-1], 0, 0, False) # return None - def _get_node_vector(self): + 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(list(self.nodes.values())) - def _get_node_vars(self): + 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)) @@ -480,7 +513,7 @@ def plot( difference: bool = False, labels: list[str] | NoInput = NoInput(0), x_axis: str = "delta", - ): + ) -> PlotOutput: """ Plot given forward tenor rates from the curve. @@ -515,30 +548,30 @@ def plot( # reversed for intuitive strike direction comparators = _drb([], comparators) labels = _drb([], labels) - x = np.linspace(self.plot_upper_bound, self.t[0], 301) - vols = self.spline.ppev(x) + x: list[float] = np.linspace(_dual_float(self.plot_upper_bound), self.t[0], 301) # type: ignore[assignment] + vols: list[float] | list[Dual] | list[Dual2] = self.spline.ppev(x) if x_axis == "moneyness": x, vols = x[40:-40], vols[40:-40] - x_as_u = [ + x_as_u: list[float] | list[Dual] | list[Dual2] = [ # type: ignore[assignment] dual_exp( - _2 + _2 # type: ignore[operator] * self.t_expiry_sqrt / 100.0 - * (dual_inv_norm_cdf(_1) * _2 * self.t_expiry_sqrt * _2 / 100.0), + * (dual_inv_norm_cdf(_1) * _2 * self.t_expiry_sqrt * _2 / 100.0), # type: ignore[operator] ) - for (_1, _2) in zip(x, vols, strict=False) + for (_1, _2) in zip(x, vols, strict=True) ] - if not difference: + if difference and not isinstance(comparators, NoInput): + y: list[list[float] | list[Dual] | list[Dual2]] = [] + for comparator in comparators: + diff = [(y_ - v_) for y_, v_ in zip(comparator.spline.ppev(x), vols, strict=True)] # type: ignore[operator] + y.append(diff) + else: # not difference: y = [vols] - if comparators is not None: + if not isinstance(comparators, NoInput): for comparator in comparators: y.append(comparator.spline.ppev(x)) - elif difference and len(comparators) > 0: - y = [] - for comparator in comparators: - diff = [comparator.spline.ppev(x) - vols] - y.append(diff) # reverse for intuitive strike direction if x_axis == "moneyness": @@ -567,7 +600,7 @@ def _clear_cache(self) -> None: # Mutation - def _csolve_n1(self): + def _csolve_n1(self) -> tuple[list[float], list[DualTypes], int, int]: # create a straight line by converting from one to two nodes with the first at tau=0. tau = list(self.nodes.keys()) tau.insert(0, self.t[0]) @@ -583,7 +616,7 @@ def _csolve_n1(self): right_n = self._right_n return tau, y, left_n, right_n - def _csolve_n_other(self): + def _csolve_n_other(self) -> tuple[list[float], list[DualTypes], int, int]: tau = list(self.nodes.keys()) y = list(self.nodes.values()) @@ -600,7 +633,7 @@ def _csolve_n_other(self): def _csolve(self) -> None: # Get the Spline classs by data types if self.ad == 0: - Spline = PPSplineF64 + Spline: type[PPSplineF64] | type[PPSplineDual] | type[PPSplineDual2] = PPSplineF64 elif self.ad == 1: Spline = PPSplineDual else: @@ -611,10 +644,10 @@ def _csolve(self) -> None: else: tau, y, left_n, right_n = self._csolve_n_other() - self.spline = Spline(4, self.t, None) - self.spline.csolve(tau, y, left_n, right_n, False) + self.spline: PPSplineF64 | PPSplineDual | PPSplineDual2 = Spline(4, self.t, None) + self.spline.csolve(tau, y, left_n, right_n, False) # type: ignore[arg-type] - def csolve(self): + def csolve(self) -> None: """ Solves **and sets** the coefficients, ``c``, of the :class:`PPSpline`. @@ -634,19 +667,26 @@ def csolve(self): self._clear_cache() self._set_new_state() - def _set_node_vector(self, vector, ad): - """Update the node values in a Solver. ``ad`` in {1, 2}.""" - DualType = Dual if ad == 1 else Dual2 - DualArgs = ([],) if ad == 1 else ([], []) + 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. + """ + 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.n)], *DualArgs) ident = np.eye(self.n) for i, k in enumerate(self.node_keys): self.nodes[k] = DualType.vars_from( - base_obj, + base_obj, # type: ignore[arg-type] vector[i].real, base_obj.vars, - ident[i, :].tolist(), + ident[i, :].tolist(), # type: ignore[arg-type] *DualArgs[1:], ) self._csolve() @@ -706,13 +746,13 @@ def update( # self._clear_cache() is performed in set_nodes self._set_new_state() - def update_node(self, key: datetime, value: DualTypes) -> None: + def update_node(self, key: float, value: DualTypes) -> None: """ Update a single node value on the *Curve*. Parameters ---------- - key: datetime + key: float The node date to update. Must exist in ``nodes``. value: float, Dual, Dual2, Variable Value to update on the *Curve*. @@ -734,7 +774,7 @@ def update_node(self, key: datetime, value: DualTypes) -> None: """ if key not in self.nodes: - raise KeyError("`key` is not in *Curve* ``nodes``.") + raise KeyError("`key` is not in Curve ``nodes``.") self.nodes[key] = value self._csolve() @@ -800,30 +840,36 @@ class FXDeltaVolSurface(_WithState): def __init__( self, - delta_indexes: list | NoInput, - expiries: list | NoInput, - node_values: list | NoInput, - eval_date: datetime | NoInput, - delta_type: str | NoInput, - weights: Series | NoInput = NoInput(0), - id: str | NoInput = NoInput(0), + delta_indexes: list[float], + expiries: list[datetime], + node_values: list[DualTypes], + eval_date: datetime, + delta_type: str, + weights: Series[float] | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 ad: int = 0, ): - node_values = np.asarray(node_values) - self.id = uuid4().hex[:5] + "_" if id is NoInput.blank else id # 1 in a million clash - self.eval_date = eval_date - self.eval_posix = self.eval_date.replace(tzinfo=UTC).timestamp() - self.expiries = expiries - self.expiries_posix = [_.replace(tzinfo=UTC).timestamp() for _ in self.expiries] + self.id: str = ( + uuid4().hex[:5] + "_" if isinstance(id, NoInput) else id + ) # 1 in a million clash + self.delta_indexes: list[float] = delta_indexes + self.delta_type: str = _validate_delta_type(delta_type) + + self.expiries: list[datetime] = expiries + self.expiries_posix: list[float] = [ + _.replace(tzinfo=UTC).timestamp() for _ in self.expiries + ] for idx in range(1, len(self.expiries)): if self.expiries[idx - 1] >= self.expiries[idx]: raise ValueError("Surface `expiries` are not sorted or contain duplicates.\n") - self.delta_indexes = delta_indexes - self.delta_type: str = _validate_delta_type(delta_type) + self.eval_date: datetime = eval_date + self.eval_posix: float = self.eval_date.replace(tzinfo=UTC).timestamp() + + node_values_: np.ndarray[tuple[int, ...], np.dtype[np.object_]] = np.asarray(node_values) self.smiles = [ FXDeltaVolSmile( - nodes=dict(zip(self.delta_indexes, node_values[i, :], strict=False)), + nodes=dict(zip(self.delta_indexes, node_values_[i, :], strict=False)), expiry=expiry, eval_date=self.eval_date, delta_type=self.delta_type, @@ -831,14 +877,16 @@ def __init__( ) for i, expiry in enumerate(self.expiries) ] - self.n = len(self.expiries) * len(self.delta_indexes) + self.n: int = len(self.expiries) * len(self.delta_indexes) self.weights = self._validate_weights(weights) - self.weights_cum = NoInput(0) if self.weights is NoInput.blank else self.weights.cumsum() + self.weights_cum = ( + NoInput(0) if isinstance(self.weights, NoInput) else self.weights.cumsum() + ) self._set_ad_order(ad) # includes csolve on each smile - def clear_cache(self): + def _clear_cache(self) -> None: """ Clear the cache of cross-sectional *Smiles* on a *Surface* type. @@ -854,44 +902,46 @@ def clear_cache(self): Alternatively set ``defaults.curve_caching`` to *False* to turn off global caching in general. """ - self._cache = dict() + self._cache: dict[datetime, FXDeltaVolSmile] = dict() self._set_new_state() - def _get_composited_state(self): + def _get_composited_state(self) -> int: return hash(smile._state for smile in self.smiles) def _validate_state(self) -> None: if self._state != self._get_composited_state(): # If any of the associated curves have been mutated then the cache is invalidated - self.clear_cache() + self._clear_cache() - def _maybe_add_to_cache(self, date, val): + def _maybe_add_to_cache(self, date: datetime, val: FXDeltaVolSmile) -> None: if defaults.curve_caching: self._cache[date] = val - def _set_ad_order(self, order: int): + def _set_ad_order(self, order: int) -> None: self.ad = order for smile in self.smiles: smile._set_ad_order(order) - self.clear_cache() + self._clear_cache() - def _set_node_vector(self, vector: np.array, ad: int): + def _set_node_vector( + self, vector: np.ndarray[tuple[int, ...], np.dtype[np.object_]], ad: int + ) -> None: m = len(self.delta_indexes) for i in range(int(len(vector) / m)): # smiles are indexed by expiry, shortest first self.smiles[i]._set_node_vector(vector[i * m : i * m + m], ad) - self.clear_cache() + self._clear_cache() - def _get_node_vector(self): + 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([list(_.nodes.values()) for _ in self.smiles]).ravel() - def _get_node_vars(self): + def _get_node_vars(self) -> tuple[str, ...]: """Get the variable names of elements updated by a Solver""" - vars = () + vars_: tuple[str, ...] = () for smile in self.smiles: - vars += tuple(f"{smile.id}{i}" for i in range(smile.n)) - return vars + vars_ += tuple(f"{smile.id}{i}" for i in range(smile.n)) + return vars_ def get_smile(self, expiry: datetime) -> FXDeltaVolSmile: """ @@ -991,7 +1041,15 @@ def get_smile(self, expiry: datetime) -> FXDeltaVolSmile: self._maybe_add_to_cache(expiry, smile) return smile - def _t_var_interp(self, expiry_index, expiry, expiry_posix, vol1, vol2, bounds_flag): + def _t_var_interp( + self, + expiry_index: int, + expiry: datetime, + expiry_posix: float, + vol1: DualTypes, + vol2: DualTypes, + bounds_flag: int, + ) -> DualTypes: """ Return the volatility of an intermediate timestamp via total linear variance interpolation. Possibly scaled by time weights if weights is available. @@ -1018,7 +1076,7 @@ def _t_var_interp(self, expiry_index, expiry, expiry_posix, vol1, vol2, bounds_f """ # 86400 posix seconds per day # 31536000 posix seconds per 365 day year - if self.weights is NoInput.blank: + if isinstance(self.weights_cum, NoInput): # weights must also be NoInput if bounds_flag == 0: ep1 = self.expiries_posix[expiry_index] ep2 = self.expiries_posix[expiry_index + 1] @@ -1033,7 +1091,7 @@ def _t_var_interp(self, expiry_index, expiry, expiry_posix, vol1, vol2, bounds_f t_var_1 = (ep1 - self.eval_posix) * vol1**2 t_var_2 = (ep2 - self.eval_posix) * vol2**2 - _ = t_var_1 + (t_var_2 - t_var_1) * (expiry_posix - ep1) / (ep2 - ep1) + _: DualTypes = t_var_1 + (t_var_2 - t_var_1) * (expiry_posix - ep1) / (ep2 - ep1) _ /= expiry_posix - self.eval_posix else: if bounds_flag == 0: @@ -1063,8 +1121,8 @@ def get_from_strike( f: DualTypes, w_deli: DualTypes | NoInput = NoInput(0), w_spot: DualTypes | NoInput = NoInput(0), - expiry: datetime | NoInput(0) = NoInput(0), - ) -> tuple: + expiry: datetime | NoInput = NoInput(0), + ) -> tuple[DualTypes, DualTypes, DualTypes]: """ Given an option strike and expiry return associated delta and vol values. @@ -1091,46 +1149,49 @@ def get_from_strike( volatility attributed to the delta at that point. Recall that the delta index is the negated put option delta for the given strike ``k``. """ - if expiry is NoInput.blank: + if isinstance(expiry, NoInput): raise ValueError("`expiry` required to get cross-section of FXDeltaVolSurface.") smile = self.get_smile(expiry) return smile.get_from_strike(k, f, w_deli, w_spot, expiry) - def _get_index(self, delta_index: float, expiry: datetime): + def _get_index(self, delta_index: DualTypes, expiry: datetime) -> DualTypes: """ Return a volatility from a given delta index. Used internally alongside Surface, where a surface also requires an expiry. """ return self.get_smile(expiry)[delta_index] - def plot(self): + def plot(self) -> PlotOutput: plot_upper_bound = max([_.plot_upper_bound for _ in self.smiles]) deltas = np.linspace(0.0, plot_upper_bound, 20) vols = np.array([[_._get_index(d, NoInput(0)) for d in deltas] for _ in self.smiles]) expiries = [(_ - self.eval_posix) / (365 * 24 * 60 * 60.0) for _ in self.expiries_posix] - return plot3d(deltas, expiries, vols) + return plot3d(deltas, expiries, vols) # type: ignore[arg-type, return-value] - def _validate_weights(self, weights): - if weights is NoInput.blank: + def _validate_weights(self, weights: Series[float] | NoInput) -> Series[float] | NoInput: + if isinstance(weights, NoInput): return weights - else: - w = Series(1.0, index=get_calendar("all").cal_date_range(self.eval_date, TERMINAL_DATE)) - w.update(weights) - w = w.sort_index() # restrict to sorted - w = w[self.eval_date :] # restrict any outlier values - node_points = [self.eval_date] + self.expiries + [TERMINAL_DATE] + w: Series[float] = Series( + 1.0, index=get_calendar("all").cal_date_range(self.eval_date, TERMINAL_DATE) + ) + w.update(weights) + # restrict to sorted and filtered for outliers + w = w.sort_index() + w = w[self.eval_date :] # type: ignore[misc] + + node_points: list[datetime] = [self.eval_date] + self.expiries + [TERMINAL_DATE] for i in range(len(self.expiries) + 1): s, e = node_points[i] + timedelta(days=1), node_points[i + 1] days = (e - s).days + 1 - w[s:e] = ( - w[s:e] * days / w[s:e].sum() + w[s:e] = ( # type: ignore[misc] + w[s:e] * days / w[s:e].sum() # type: ignore[misc] ) # scale the weights to allocate the correct time between nodes. w[self.eval_date] = 0.0 return w -def _validate_delta_type(delta_type: str): +def _validate_delta_type(delta_type: str) -> str: if delta_type.lower() not in ["spot", "spot_pa", "forward", "forward_pa"]: raise ValueError("`delta_type` must be in {'spot', 'spot_pa', 'forward', 'forward_pa'}.") return delta_type.lower() @@ -1293,7 +1354,7 @@ def _black76( v2: DualTypes, vol: DualTypes, phi: float, -): +) -> DualTypes: """ Option price in points terms for immediate premium settlement. @@ -1322,7 +1383,7 @@ def _black76( d1 = _d_plus(K, F, vs) d2 = d1 - vs Nd1, Nd2 = dual_norm_cdf(phi * d1), dual_norm_cdf(phi * d2) - _ = phi * (F * Nd1 - K * Nd2) + _: DualTypes = phi * (F * Nd1 - K * Nd2) # Spot formulation instead of F (Garman Kohlhagen formulation) # https://quant.stackexchange.com/a/63661/29443 # r1, r2 = dual_log(df1) / -t, dual_log(df2) / -t @@ -1353,7 +1414,7 @@ def _d_plus(K: DualTypes, f: DualTypes, vol_sqrt_t: DualTypes) -> DualTypes: def _delta_type_constants( - delta_type: str, w: DualTypes, u: DualTypes + delta_type: str, w: DualTypes | NoInput, u: DualTypes | NoInput ) -> tuple[float, DualTypes, DualTypes]: """ Get the values: (eta, z_w, z_u) for the type of expressed delta @@ -1362,13 +1423,13 @@ def _delta_type_constants( u: should be input as K / f_d """ if delta_type == "forward": - return (0.5, 1.0, 1.0) + return 0.5, 1.0, 1.0 elif delta_type == "spot": - return (0.5, w, 1.0) + return 0.5, w, 1.0 # type: ignore[return-value] elif delta_type == "forward_pa": - return (-0.5, 1.0, u) + return -0.5, 1.0, u # type: ignore[return-value] else: # "spot_pa" - return (-0.5, w, u) + return -0.5, w, u # type: ignore[return-value] FXVols = FXDeltaVolSmile | FXDeltaVolSurface diff --git a/python/rateslib/instruments/__init__.py b/python/rateslib/instruments/__init__.py index eaf463d8..8efa1340 100644 --- a/python/rateslib/instruments/__init__.py +++ b/python/rateslib/instruments/__init__.py @@ -1,14 +1,6 @@ -# Sphinx substitutions - -""" -.. ipython:: python - :suppress: - - from rateslib import * -""" - from __future__ import annotations +from rateslib.instruments.base import BaseDerivative, BaseMixin from rateslib.instruments.bonds import ( Bill, BillCalcMode, @@ -19,6 +11,7 @@ FloatRateNote, IndexFixedRateBond, ) +from rateslib.instruments.credit import CDS from rateslib.instruments.fx_volatility import ( FXBrokerFly, FXCall, @@ -30,26 +23,19 @@ FXStrangle, ) from rateslib.instruments.generics import Fly, Portfolio, Spread, Value, VolValue -from rateslib.instruments.inst_core import ( - BaseMixin, - Sensitivities, -) -from rateslib.instruments.rates_derivatives import ( - CDS, +from rateslib.instruments.rates import ( FRA, IIRS, IRS, SBS, + XCS, ZCIS, ZCS, - BaseDerivative, - STIRFuture, -) -from rateslib.instruments.rates_multi_ccy import ( - XCS, FXExchange, FXSwap, + STIRFuture, ) +from rateslib.instruments.sensitivities import Sensitivities # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. diff --git a/python/rateslib/instruments/base.py b/python/rateslib/instruments/base.py new file mode 100644 index 00000000..6a9c245c --- /dev/null +++ b/python/rateslib/instruments/base.py @@ -0,0 +1,578 @@ +from __future__ import annotations + +import warnings +from abc import ABCMeta, abstractmethod +from datetime import datetime +from typing import TYPE_CHECKING + +from pandas import DataFrame, concat, isna + +from rateslib import defaults +from rateslib.default import NoInput +from rateslib.instruments.sensitivities import Sensitivities +from rateslib.instruments.utils import ( + _get_curves_fx_and_base_maybe_from_solver, + _inherit_or_negate, + _push, +) +from rateslib.solver import Solver + +if TYPE_CHECKING: + from rateslib.typing import FX_, NPV, Any, CalInput, Curves_, DualTypes, Leg + + +class BaseMixin: + _fixed_rate_mixin: bool = False + _float_spread_mixin: bool = False + _leg2_fixed_rate_mixin: bool = False + _leg2_float_spread_mixin: bool = False + _index_base_mixin: bool = False + _leg2_index_base_mixin: bool = False + _rate_scalar: float = 1.0 + + leg1: Leg + leg2: Leg + curves: Curves_ + + @property + def fixed_rate(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``fixed_rate`` of the contained + leg1. + + .. note:: + ``fixed_rate``, ``float_spread``, ``leg2_fixed_rate`` and + ``leg2_float_spread`` are attributes only applicable to certain + ``Instruments``. *AttributeErrors* are raised if calling or setting these + is invalid. + + """ + return self._fixed_rate + + @fixed_rate.setter + def fixed_rate(self, value: DualTypes | NoInput) -> None: + if not self._fixed_rate_mixin: + raise AttributeError("Cannot set `fixed_rate` for this Instrument.") + self._fixed_rate = value + self.leg1.fixed_rate = value # type: ignore[union-attr] + + @property + def leg2_fixed_rate(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``fixed_rate`` of the contained + leg2. + """ + return self._leg2_fixed_rate + + @leg2_fixed_rate.setter + def leg2_fixed_rate(self, value: DualTypes | NoInput) -> None: + if not self._leg2_fixed_rate_mixin: + raise AttributeError("Cannot set `leg2_fixed_rate` for this Instrument.") + self._leg2_fixed_rate = value + self.leg2.fixed_rate = value # type: ignore[union-attr] + + @property + def float_spread(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``float_spread`` of contained + leg1. + """ + return self._float_spread + + @float_spread.setter + def float_spread(self, value: DualTypes | NoInput) -> None: + if not self._float_spread_mixin: + raise AttributeError("Cannot set `float_spread` for this Instrument.") + self._float_spread = value + self.leg1.float_spread = value # type: ignore[union-attr] + # if getattr(self, "_float_mixin_leg", None) is NoInput.blank: + # self.leg1.float_spread = value + # else: + # # allows fixed_rate and float_rate to exist simultaneously for diff legs. + # leg = getattr(self, "_float_mixin_leg", None) + # getattr(self, f"leg{leg}").float_spread = value + + @property + def leg2_float_spread(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``float_spread`` of contained + leg2. + """ + return self._leg2_float_spread + + @leg2_float_spread.setter + def leg2_float_spread(self, value: DualTypes | NoInput) -> None: + if not self._leg2_float_spread_mixin: + raise AttributeError("Cannot set `leg2_float_spread` for this Instrument.") + self._leg2_float_spread = value + self.leg2.float_spread = value # type: ignore[union-attr] + + @property + def index_base(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``index_base`` of the contained + leg1. + + .. note:: + ``index_base`` and ``leg2_index_base`` are attributes only applicable to certain + ``Instruments``. *AttributeErrors* are raised if calling or setting these + is invalid. + + """ + return self._index_base + + @index_base.setter + def index_base(self, value: DualTypes | NoInput) -> None: + if not self._index_base_mixin: + raise AttributeError("Cannot set `index_base` for this Instrument.") + self._index_base = value + self.leg1.index_base = value # type: ignore[union-attr] + + @property + def leg2_index_base(self) -> DualTypes | NoInput: + """ + float or None : If set will also set the ``index_base`` of the contained + leg1. + + .. note:: + ``index_base`` and ``leg2_index_base`` are attributes only applicable to certain + ``Instruments``. *AttributeErrors* are raised if calling or setting these + is invalid. + + """ + return self._leg2_index_base + + @leg2_index_base.setter + def leg2_index_base(self, value: DualTypes | NoInput) -> None: + if not self._leg2_index_base_mixin: + raise AttributeError("Cannot set `leg2_index_base` for this Instrument.") + self._leg2_index_base = value + self.leg2.index_base = value # type: ignore[union-attr] + + @abstractmethod + def analytic_delta(self, *args: Any, leg: int = 1, **kwargs: Any) -> DualTypes: + """ + Return the analytic delta of a leg of the derivative object. + + Parameters + ---------- + args : + Required positional arguments supplied to + :meth:`BaseLeg.analytic_delta`. + leg : int in [1, 2] + The leg identifier of which to take the analytic delta. + kwargs : + Required Keyword arguments supplied to + :meth:`BaseLeg.analytic_delta()`. + + Returns + ------- + float, Dual, Dual2 + + Examples + -------- + .. ipython:: python + :suppress: + + from rateslib import Curve, FXRates, IRS, dt + + .. ipython:: python + + curve = Curve({dt(2021,1,1): 1.00, dt(2025,1,1): 0.83}, id="SONIA") + fxr = FXRates({"gbpusd": 1.25}, base="usd") + + .. ipython:: python + + irs = IRS( + effective=dt(2022, 1, 1), + termination="6M", + frequency="Q", + currency="gbp", + notional=1e9, + fixed_rate=5.0, + ) + irs.analytic_delta(curve, curve) + irs.analytic_delta(curve, curve, fxr) + irs.analytic_delta(curve, curve, fxr, "gbp") + """ + _: DualTypes = getattr(self, f"leg{leg}").analytic_delta(*args, **kwargs) + return _ + + @abstractmethod + def cashflows( + self, + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ) -> DataFrame: + """ + Return the properties of all legs used in calculating cashflows. + + Parameters + ---------- + curves : CurveType, str or list of such, optional + A single :class:`~rateslib.curves.Curve`, + :class:`~rateslib.curves.LineCurve` or id or a + list of such. A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + solver : Solver, optional + The numerical :class:`~rateslib.solver.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 + :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object, + converts from local currency into ``base``. + base : str, optional + The base currency to convert cashflows into (3-digit code). + Only used if ``fx`` is an :class:`~rateslib.fx.FXRates` or + :class:`~rateslib.fx.FXForwards` object. If not given defaults + to ``fx.base``. + + Returns + ------- + DataFrame + + Notes + ----- + If **only one curve** is given this is used as all four curves. + + If **two curves** are given the forecasting curve is used as the forecasting + curve on both legs and the discounting curve is used as the discounting + curve for both legs. + + If **three curves** are given the single discounting curve is used as the + discounting curve for both legs. + + Examples + -------- + .. ipython:: python + + irs.cashflows([curve], fx=fxr) + """ + curves_, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + + df1 = self.leg1.cashflows(curves_[0], curves_[1], fx_, base_) + df2 = self.leg2.cashflows(curves_[2], curves_[3], fx_, base_) + # filter empty or all NaN + dfs_filtered = [_ for _ in [df1, df2] if not (_.empty or isna(_).all(axis=None))] + + with warnings.catch_warnings(): + # TODO: pandas 2.1.0 has a FutureWarning for concatenating DataFrames with Null entries + warnings.filterwarnings("ignore", category=FutureWarning) + _: DataFrame = concat(dfs_filtered, keys=["leg1", "leg2"]) + return _ + + @abstractmethod + def npv( + self, + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + ) -> NPV: + """ + Return the NPV of the derivative object by summing legs. + + Parameters + ---------- + curves : Curve, LineCurve, str or list of such + A single :class:`~rateslib.curves.Curve`, + :class:`~rateslib.curves.LineCurve` or id or a + list of such. A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + solver : Solver, optional + The numerical :class:`~rateslib.solver.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 + :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object, + converts from local currency into ``base``. + base : str, optional + The base currency to convert cashflows into (3-digit code). + Only used if ``fx`` is an :class:`~rateslib.fx.FXRates` or + :class:`~rateslib.fx.FXForwards` object. If not given defaults + to ``fx.base``. + local : bool, optional + If `True` will return a dict identifying NPV by local currencies on each + leg. Useful for multi-currency derivatives and for ensuring risk + sensitivities are allocated to local currencies without conversion. + + Returns + ------- + float, Dual or Dual2, or dict of such. + + Notes + ----- + If **only one curve** is given this is used as all four curves. + + If **two curves** are given the forecasting curve is used as the forecasting + curve on both legs and the discounting curve is used as the discounting + curve for both legs. + + If **three curves** are given the single discounting curve is used as the + discounting curve for both legs. + + Examples + -------- + .. ipython:: python + + irs.npv(curve) + irs.npv([curve], fx=fxr) + irs.npv([curve], fx=fxr, base="gbp") + """ + curves_, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + leg1_npv: NPV = self.leg1.npv(curves_[0], curves_[1], fx_, base_, local) + leg2_npv: NPV = self.leg2.npv(curves_[2], curves_[3], fx_, base_, local) + if local: + return { + k: leg1_npv.get(k, 0) + leg2_npv.get(k, 0) # type: ignore[union-attr] + for k in set(leg1_npv) | set(leg2_npv) # type: ignore[arg-type] + } + else: + return leg1_npv + leg2_npv # type: ignore[operator] + + @abstractmethod + def rate(self, *args: Any, **kwargs: Any) -> DualTypes: + """ + Return the `rate` or typical `price` for a derivative instrument. + + Returns + ------- + Dual + + Notes + ----- + This method must be implemented for instruments to function effectively in + :class:`Solver` iterations. + """ + pass # pragma: no cover + + def __repr__(self) -> str: + return f"" + + +class BaseDerivative(Sensitivities, BaseMixin, metaclass=ABCMeta): + """ + Abstract base class with common parameters for many *Derivative* subclasses. + + Parameters + ---------- + effective : datetime + The adjusted or unadjusted effective date. + termination : datetime or str + The adjusted or unadjusted termination date. If a string, then a tenor must be + given expressed in days (`"D"`), months (`"M"`) or years (`"Y"`), e.g. `"48M"`. + frequency : str in {"M", "B", "Q", "T", "S", "A", "Z"}, optional + The frequency of the schedule. + stub : str combining {"SHORT", "LONG"} with {"FRONT", "BACK"}, optional + The stub type to enact on the swap. Can provide two types, for + example "SHORTFRONTLONGBACK". + front_stub : datetime, optional + An adjusted or unadjusted date for the first stub period. + back_stub : datetime, optional + An adjusted or unadjusted date for the back stub period. + See notes for combining ``stub``, ``front_stub`` and ``back_stub`` + and any automatic stub inference. + roll : int in [1, 31] or str in {"eom", "imm", "som"}, optional + The roll day of the schedule. Inferred if not given. + eom : bool, optional + Use an end of month preference rather than regular rolls for inference. Set by + default. Not required if ``roll`` is specified. + modifier : str, optional + The modification rule, in {"F", "MF", "P", "MP"} + calendar : calendar or str, optional + The holiday calendar object to use. If str, looks up named calendar from + static data. + payment_lag : int, optional + The number of business days to lag payments by. + notional : float, optional + The leg notional, which is applied to each period. + amortization: float, optional + The amount by which to adjust the notional each successive period. Should have + sign equal to that of notional if the notional is to reduce towards zero. + convention: str, optional + The day count convention applied to calculations of period accrual dates. + See :meth:`~rateslib.calendars.dcf`. + leg2_kwargs: Any + All ``leg2`` arguments can be similarly input as above, e.g. ``leg2_frequency``. + If **not** given, any ``leg2`` + argument inherits its value from the ``leg1`` arguments, except in the case of + ``notional`` and ``amortization`` where ``leg2`` inherits the negated value. + curves : Curve, LineCurve, str or list of such, optional + A single :class:`~rateslib.curves.Curve`, + :class:`~rateslib.curves.LineCurve` or id or a + list of such. A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` or + :class:`~rateslib.curves.LineCurve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + spec : str, optional + An identifier to pre-populate many field with conventional values. See + :ref:`here` for more info and available values. + + Attributes + ---------- + effective : datetime + termination : datetime + frequency : str + stub : str + front_stub : datetime + back_stub : datetime + roll : str, int + eom : bool + modifier : str + calendar : Calendar + payment_lag : int + notional : float + amortization : float + convention : str + leg2_effective : datetime + leg2_termination : datetime + leg2_frequency : str + leg2_stub : str + leg2_front_stub : datetime + leg2_back_stub : datetime + leg2_roll : str, int + leg2_eom : bool + leg2_modifier : str + leg2_calendar : Calendar + leg2_payment_lag : int + leg2_notional : float + leg2_amortization : float + leg2_convention : str + """ + + @abstractmethod + def __init__( + self, + effective: datetime | NoInput = NoInput(0), + termination: datetime | str | NoInput = NoInput(0), + frequency: int | NoInput = NoInput(0), + stub: str | NoInput = NoInput(0), + front_stub: datetime | NoInput = NoInput(0), + back_stub: datetime | NoInput = NoInput(0), + roll: str | int | NoInput = NoInput(0), + eom: bool | NoInput = NoInput(0), + modifier: str | NoInput = NoInput(0), + calendar: CalInput = NoInput(0), + payment_lag: int | NoInput = NoInput(0), + notional: float | NoInput = NoInput(0), + currency: str | NoInput = NoInput(0), + amortization: float | NoInput = NoInput(0), + convention: str | NoInput = NoInput(0), + leg2_effective: datetime | NoInput = NoInput(1), + leg2_termination: datetime | str | NoInput = NoInput(1), + leg2_frequency: int | NoInput = NoInput(1), + leg2_stub: str | NoInput = NoInput(1), + leg2_front_stub: datetime | NoInput = NoInput(1), + leg2_back_stub: datetime | NoInput = NoInput(1), + leg2_roll: str | int | NoInput = NoInput(1), + leg2_eom: bool | NoInput = NoInput(1), + leg2_modifier: str | NoInput = NoInput(1), + leg2_calendar: CalInput = NoInput(1), + leg2_payment_lag: int | NoInput = NoInput(1), + leg2_notional: float | NoInput = NoInput(-1), + leg2_currency: str | NoInput = NoInput(1), + leg2_amortization: float | NoInput = NoInput(-1), + leg2_convention: str | NoInput = NoInput(1), + curves: Curves_ = NoInput(0), + spec: str | NoInput = NoInput(0), + ): + self.kwargs: dict[str, Any] = dict( + effective=effective, + termination=termination, + frequency=frequency, + stub=stub, + front_stub=front_stub, + back_stub=back_stub, + roll=roll, + eom=eom, + modifier=modifier, + calendar=calendar, + payment_lag=payment_lag, + notional=notional, + currency=currency, + amortization=amortization, + convention=convention, + leg2_effective=leg2_effective, + leg2_termination=leg2_termination, + leg2_frequency=leg2_frequency, + leg2_stub=leg2_stub, + leg2_front_stub=leg2_front_stub, + leg2_back_stub=leg2_back_stub, + leg2_roll=leg2_roll, + leg2_eom=leg2_eom, + leg2_modifier=leg2_modifier, + leg2_calendar=leg2_calendar, + leg2_payment_lag=leg2_payment_lag, + leg2_notional=leg2_notional, + leg2_currency=leg2_currency, + leg2_amortization=leg2_amortization, + leg2_convention=leg2_convention, + ) + self.kwargs = _push(spec, self.kwargs) + # set some defaults if missing + self.kwargs["notional"] = ( + defaults.notional + if self.kwargs["notional"] is NoInput.blank + else self.kwargs["notional"] + ) + if self.kwargs["payment_lag"] is NoInput.blank: + self.kwargs["payment_lag"] = defaults.payment_lag_specific[type(self).__name__] + self.kwargs = _inherit_or_negate(self.kwargs) # inherit or negate the complete arg list + + self.curves = curves + self.spec = spec + + @abstractmethod + def _set_pricing_mid(self, *args: Any, **kwargs: Any) -> None: # pragma: no cover + pass + + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: + """ + Calculate the delta of the *Instrument*. + + For arguments see :meth:`Sensitivities.delta()`. + """ + return super().delta(*args, **kwargs) + + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: + """ + Calculate the gamma of the *Instrument*. + + For arguments see :meth:`Sensitivities.gamma()`. + """ + return super().gamma(*args, **kwargs) diff --git a/python/rateslib/instruments/bonds/conventions/__init__.py b/python/rateslib/instruments/bonds/conventions/__init__.py index 0d9c148f..81df7986 100644 --- a/python/rateslib/instruments/bonds/conventions/__init__.py +++ b/python/rateslib/instruments/bonds/conventions/__init__.py @@ -1,9 +1,14 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from rateslib import defaults from rateslib.instruments.bonds.conventions.accrued import ACC_FRAC_FUNCS from rateslib.instruments.bonds.conventions.discounting import V1_FUNCS, V2_FUNCS, V3_FUNCS +if TYPE_CHECKING: + from rateslib.typing import Security + class BondCalcMode: """ @@ -138,7 +143,7 @@ def __init__( self._v2 = V2_FUNCS[v2_type.lower()] self._v3 = V3_FUNCS[v3_type.lower()] - self._kwargs: dict = { + self._kwargs: dict[str, str] = { "settle_accrual": settle_accrual_type, "ytm_accrual": ytm_accrual_type, "v1": v1_type, @@ -147,7 +152,7 @@ def __init__( } @property - def kwargs(self) -> dict: + def kwargs(self) -> dict[str, str]: """String representation of the parameters for the calculation convention.""" return self._kwargs @@ -187,7 +192,7 @@ def __init__( # accrual type uses "linear days" by default. This correctly scales ACT365f and ACT360 # DCF conventions and prepares for any non-standard DCFs. # currently no identified cases where anything else is needed. Revise as necessary. - ytm_clone_kwargs: dict | str, + ytm_clone_kwargs: dict[str, str] | str, ): self._price_type = price_type price_accrual_type = "linear_days" @@ -196,14 +201,14 @@ def __init__( self._ytm_clone_kwargs = ytm_clone_kwargs else: self._ytm_clone_kwargs = defaults.spec[ytm_clone_kwargs] - self._kwargs = { + self._kwargs: dict[str, str] = { "price_type": price_type, "price_accrual_type": price_accrual_type, "ytm_clone": "Custom dict" if isinstance(ytm_clone_kwargs, dict) else ytm_clone_kwargs, } @property - def kwargs(self): + def kwargs(self) -> dict[str, str]: """String representation of the parameters for the calculation convention.""" return self._kwargs @@ -356,14 +361,15 @@ def _get_bond_calc_mode(calc_mode: str | BondCalcMode) -> BondCalcMode: def _get_calc_mode_for_class( - obj, calc_mode: str | BondCalcMode | BillCalcMode + obj: Security, calc_mode: str | BondCalcMode | BillCalcMode ) -> BondCalcMode | BillCalcMode: if isinstance(calc_mode, str): - map_ = { + map_: dict[str, dict[str, BondCalcMode] | dict[str, BillCalcMode]] = { "FixedRateBond": BOND_MODE_MAP, "Bill": BILL_MODE_MAP, "FloatRateNote": BOND_MODE_MAP, "IndexFixedRateBond": BOND_MODE_MAP, } - return map_[type(obj).__name__][calc_mode.lower()] + klass: str = type(obj).__name__ + return map_[klass][calc_mode.lower()] return calc_mode diff --git a/python/rateslib/instruments/bonds/conventions/accrued.py b/python/rateslib/instruments/bonds/conventions/accrued.py index f640b228..872f1e72 100644 --- a/python/rateslib/instruments/bonds/conventions/accrued.py +++ b/python/rateslib/instruments/bonds/conventions/accrued.py @@ -1,9 +1,15 @@ +from __future__ import annotations + from datetime import datetime +from typing import TYPE_CHECKING, Any from rateslib import defaults from rateslib.calendars import add_tenor, dcf from rateslib.default import NoInput +if TYPE_CHECKING: + from rateslib.typing import Security + """ All functions in this module are designed to take a Bond object and return the **fraction** of the current coupon period associated with the given settlement. @@ -12,7 +18,9 @@ """ -def _acc_linear_proportion_by_days(obj, settlement: datetime, acc_idx: int, *args): +def _acc_linear_proportion_by_days( + obj: Security, settlement: datetime, acc_idx: int, *args: Any +) -> float: """ Return the fraction of an accrual period between start and settlement. @@ -28,20 +36,22 @@ def _acc_linear_proportion_by_days(obj, settlement: datetime, acc_idx: int, *arg def _acc_linear_proportion_by_days_long_stub_split( - obj, + obj: Security, settlement: datetime, acc_idx: int, - *args, -): + *args: Any, +) -> float: """ For long stub periods this splits the accrued interest into two components. Otherwise, returns the regular linear proportion. [Designed primarily for US Treasuries] """ - if obj.leg1.periods[acc_idx].stub: + # TODO: handle this union attribute by segregating Securities periods into different + # categories, perhaps when also integrating deterministic amortised bonds. + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] fm = defaults.frequency_months[obj.leg1.schedule.frequency] f = 12 / fm - if obj.leg1.periods[acc_idx].dcf * f > 1: + if obj.leg1.periods[acc_idx].dcf * f > 1: # type: ignore[union-attr] # long stub quasi_coupon = add_tenor( obj.leg1.schedule.uschedule[acc_idx + 1], @@ -62,8 +72,8 @@ def _acc_linear_proportion_by_days_long_stub_split( r = quasi_coupon - settlement s = quasi_coupon - quasi_start r_ = quasi_coupon - obj.leg1.schedule.uschedule[acc_idx] - _ = (r_ - r) / s - return _ / (obj.leg1.periods[acc_idx].dcf * f) + _: float = (r_ - r) / s + return _ / (obj.leg1.periods[acc_idx].dcf * f) # type: ignore[union-attr] else: # then second part of long stub r = obj.leg1.schedule.uschedule[acc_idx + 1] - settlement @@ -71,12 +81,12 @@ def _acc_linear_proportion_by_days_long_stub_split( r_ = quasi_coupon - obj.leg1.schedule.uschedule[acc_idx] s_ = quasi_coupon - quasi_start _ = r_ / s_ + (s - r) / s - return _ / (obj.leg1.periods[acc_idx].dcf * f) + return _ / (obj.leg1.periods[acc_idx].dcf * f) # type: ignore[union-attr] return _acc_linear_proportion_by_days(obj, settlement, acc_idx, *args) -def _acc_30e360(obj, settlement: datetime, acc_idx: int, *args): +def _acc_30e360(obj: Security, settlement: datetime, acc_idx: int, *args: Any) -> float: """ Ignoring the convention on the leg uses "30E360" to determine the accrual fraction. Measures between unadjusted date and settlement. @@ -84,15 +94,17 @@ def _acc_30e360(obj, settlement: datetime, acc_idx: int, *args): If stub revert to linear proportioning. """ - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] return _acc_linear_proportion_by_days(obj, settlement, acc_idx) f = 12 / defaults.frequency_months[obj.leg1.schedule.frequency] - _ = dcf(settlement, obj.leg1.schedule.uschedule[acc_idx + 1], "30e360") * f + _: float = dcf(settlement, obj.leg1.schedule.uschedule[acc_idx + 1], "30e360") * f _ = 1 - _ return _ -def _acc_act365_with_1y_and_stub_adjustment(obj, settlement: datetime, acc_idx: int, *args): +def _acc_act365_with_1y_and_stub_adjustment( + obj: Security, settlement: datetime, acc_idx: int, *args: Any +) -> float: """ Ignoring the convention on the leg uses "Act365f" to determine the accrual fraction. Measures between unadjusted date and settlement. @@ -100,13 +112,13 @@ def _acc_act365_with_1y_and_stub_adjustment(obj, settlement: datetime, acc_idx: If the period is a stub reverts to a straight line interpolation [this is primarily designed for Canadian Government Bonds] """ - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] return _acc_linear_proportion_by_days(obj, settlement, acc_idx) f = 12 / defaults.frequency_months[obj.leg1.schedule.frequency] r = (settlement - obj.leg1.schedule.uschedule[acc_idx]).days s = (obj.leg1.schedule.uschedule[acc_idx + 1] - obj.leg1.schedule.uschedule[acc_idx]).days if r == s: - _ = 1.0 # then settlement falls on the coupon date + _: float = 1.0 # then settlement falls on the coupon date elif r > 365.0 / f: _ = 1.0 - ((s - r) * f) / 365.0 # counts remaining days else: diff --git a/python/rateslib/instruments/bonds/conventions/discounting.py b/python/rateslib/instruments/bonds/conventions/discounting.py index 46b262b3..3a2b4545 100644 --- a/python/rateslib/instruments/bonds/conventions/discounting.py +++ b/python/rateslib/instruments/bonds/conventions/discounting.py @@ -1,15 +1,24 @@ +from __future__ import annotations + +from collections.abc import Callable from datetime import datetime +from typing import TYPE_CHECKING, Any from rateslib.calendars import dcf -from rateslib.dual import DualTypes + +if TYPE_CHECKING: + from rateslib.typing import DualTypes, Security """ The calculations for v2 (the interim, regular period discount value) are more standardised than the other calculations because they exclude the scenarios for stub handling. """ +# TODO fix the union-attr type ignores by considering aggergating coupon periods distinct from +# cashflow periods -def _v2_(obj, ytm: DualTypes, f: int, *args): + +def _v2_(obj: Security, ytm: DualTypes, f: int, *args: Any) -> DualTypes: """ Default method for a single regular period discounted in the regular portion of bond. Implies compounding at the same frequency as the coupons. @@ -17,7 +26,7 @@ def _v2_(obj, ytm: DualTypes, f: int, *args): return 1 / (1 + ytm / (100 * f)) -def _v2_annual(obj, ytm: DualTypes, f: int, *args): +def _v2_annual(obj: Security, ytm: DualTypes, f: int, *args: Any) -> DualTypes: """ ytm is expressed annually but coupon payments are on another frequency """ @@ -30,15 +39,15 @@ def _v2_annual(obj, ytm: DualTypes, f: int, *args): def _v1_compounded_by_remaining_accrual_fraction( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - accrual: callable, - *args, -): + accrual: Callable[[Security, datetime, int], float], + *args: Any, +) -> DualTypes: """ Determine the discount factor for the first cashflow after settlement. @@ -47,10 +56,10 @@ def _v1_compounded_by_remaining_accrual_fraction( Method: compounds "v2" by the accrual fraction of the period. """ acc_frac = accrual(obj, settlement, acc_idx) - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] # If it is a stub then the remaining fraction must be scaled by the relative size of the # stub period compared with a regular period. - fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) + fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) # type: ignore[union-attr] else: # 1 minus acc_fra is the fraction of the period remaining until the next cashflow. fd0 = 1 - acc_frac @@ -58,15 +67,15 @@ def _v1_compounded_by_remaining_accrual_fraction( def _v1_compounded_by_remaining_accrual_frac_except_simple_final_period( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - accrual: callable, - *args, -): + accrual: Callable[[Security, datetime, int], float], + *args: Any, +) -> DualTypes: """ Uses regular fractional compounding except if it is last period, when simple money-mkt yield is used instead. @@ -91,17 +100,17 @@ def _v1_compounded_by_remaining_accrual_frac_except_simple_final_period( def _v1_comp_stub_act365f( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - accrual: callable, - *args, -): + accrual: Callable[[Security, datetime, int], float], + *args: Any, +) -> DualTypes: """Compounds the yield. In a stub period the act365f DCF is used""" - if not obj.leg1.periods[acc_idx].stub: + if not obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] return _v1_compounded_by_remaining_accrual_fraction( obj, ytm, @@ -118,22 +127,22 @@ def _v1_comp_stub_act365f( def _v1_simple( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - accrual: callable, - *args, -): + accrual: Callable[[Security, datetime, int], float], + *args: Any, +) -> DualTypes: """ Use simple rates with a yield which matches the frequency of the coupon. """ acc_frac = accrual(obj, settlement, acc_idx) - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] # is a stub so must account for discounting in a different way. - fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) + fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) # type: ignore[union-attr] else: fd0 = 1 - acc_frac @@ -142,15 +151,15 @@ def _v1_simple( def _v1_simple_1y_adjustment( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - accrual: callable, - *args, -): + accrual: Callable[[Security, datetime, int], float], + *args: Any, +) -> DualTypes: """ Use simple rates with a yield which matches the frequency of the coupon. @@ -158,9 +167,9 @@ def _v1_simple_1y_adjustment( discount param ``v``. """ acc_frac = accrual(obj, settlement, acc_idx) - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] # is a stub so must account for discounting in a different way. - fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) + fd0 = obj.leg1.periods[acc_idx].dcf * f * (1 - acc_frac) # type: ignore[union-attr] else: fd0 = 1 - acc_frac @@ -172,56 +181,50 @@ def _v1_simple_1y_adjustment( def _v3_compounded( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, v2: DualTypes, - *args, -): + *args: Any, +) -> DualTypes: """ Final period uses a compounding approach where the power is determined by the DCF of that period under the bond's specified convention. """ - if obj.leg1.periods[acc_idx].stub: + if obj.leg1.periods[acc_idx].stub: # type: ignore[union-attr] # If it is a stub then the remaining fraction must be scaled by the relative size of the # stub period compared with a regular period. - fd0 = obj.leg1.periods[acc_idx].dcf * f + fd0 = obj.leg1.periods[acc_idx].dcf * f # type: ignore[union-attr] else: fd0 = 1 return v2**fd0 def _v3_30e360_u_simple( - obj, + obj: Security, ytm: DualTypes, f: int, settlement: datetime, acc_idx: int, - v2: DualTypes, - *args, -): + *args: Any, +) -> DualTypes: """ The final period is discounted by a simple interest method under a 30E360 convention. The YTM is assumed to have the same frequency as the coupons. """ - d_ = dcf(obj.leg1.periods[acc_idx].start, obj.leg1.periods[acc_idx].end, "30E360") + d_ = dcf(obj.leg1.periods[acc_idx].start, obj.leg1.periods[acc_idx].end, "30E360") # type: ignore[union-attr] return 1 / (1 + d_ * ytm / 100) # simple interest def _v3_simple( - obj, + obj: Security, ytm: DualTypes, - f: int, - settlement: datetime, - acc_idx: int, - v2: DualTypes, - accrual: callable, - *args, -): - v_ = 1 / (1 + obj.leg1.periods[-2].dcf * ytm / 100.0) + *args: Any, +) -> DualTypes: + v_ = 1 / (1 + obj.leg1.periods[-2].dcf * ytm / 100.0) # type: ignore[union-attr] return v_ diff --git a/python/rateslib/instruments/bonds/futures.py b/python/rateslib/instruments/bonds/futures.py index 4c14a58b..90a986e4 100644 --- a/python/rateslib/instruments/bonds/futures.py +++ b/python/rateslib/instruments/bonds/futures.py @@ -1,22 +1,25 @@ from __future__ import annotations +from collections.abc import Sequence from datetime import datetime +from typing import TYPE_CHECKING from pandas import DataFrame from rateslib import defaults from rateslib.calendars import _get_years_and_months, get_calendar from rateslib.curves import Curve -from rateslib.default import NoInput -from rateslib.dual import Dual, Dual2 -from rateslib.fx import FXForwards, FXRates -from rateslib.instruments.bonds.securities import FixedRateBond -from rateslib.instruments.inst_core import Sensitivities +from rateslib.default import NoInput, _drb +from rateslib.dual.utils import _dual_float +from rateslib.instruments.sensitivities import Sensitivities from rateslib.periods import ( _get_fx_and_base, ) from rateslib.solver import Solver +if TYPE_CHECKING: + from rateslib.typing import FX_, Any, Curves_, DualTypes, FixedRateBond, Solver_ + class BondFuture(Sensitivities): """ @@ -194,27 +197,25 @@ def __init__( currency: str | NoInput = NoInput(0), calc_mode: str | NoInput = NoInput(0), ): - self.currency = defaults.base_currency if currency is NoInput.blank else currency.lower() + self.currency = _drb(defaults.base_currency, currency).lower() self.coupon = coupon if isinstance(delivery, datetime): self.delivery = (delivery, delivery) else: - self.delivery = tuple(delivery) + self.delivery = tuple(delivery) # type: ignore[assignment] self.basket = tuple(basket) self.calendar = get_calendar(calendar) # self.last_trading = delivery[1] if last_trading is NoInput.blank else - self.nominal = defaults.notional if nominal is NoInput.blank else nominal - self.contracts = 1 if contracts is NoInput.blank else contracts - self.calc_mode = ( - defaults.calc_mode_futures if calc_mode is NoInput.blank else calc_mode.lower() - ) - self._cfs = NoInput(0) + self.nominal: float = _drb(defaults.notional, nominal) + self.contracts: int = _drb(1, contracts) + self.calc_mode = _drb(defaults.calc_mode_futures, calc_mode).lower() + self._cfs: tuple[DualTypes, ...] | NoInput = NoInput(0) - def __repr__(self): + def __repr__(self) -> str: return f"" @property - def notional(self): + def notional(self) -> float: """ Return the notional as number of contracts multiplied by contract nominal. @@ -225,7 +226,7 @@ def notional(self): return self.nominal * self.contracts * -1 # long positions is negative notn @property - def cfs(self): + def cfs(self) -> tuple[DualTypes, ...]: """ Return the conversion factors for each bond in the ordered ``basket``. @@ -281,11 +282,11 @@ def cfs(self): future.cfs """ - if self._cfs is NoInput.blank: + if isinstance(self._cfs, NoInput): self._cfs = self._conversion_factors() return self._cfs - def _conversion_factors(self): + def _conversion_factors(self) -> tuple[DualTypes, ...]: if self.calc_mode == "ytm": return tuple(bond.price(self.coupon, self.delivery[0]) / 100 for bond in self.basket) elif self.calc_mode == "ust_short": @@ -295,9 +296,10 @@ def _conversion_factors(self): else: raise ValueError("`calc_mode` must be in {'ytm', 'ust_short', 'ust_long'}") - def _cfs_ust(self, bond: FixedRateBond, short: bool): + def _cfs_ust(self, bond: FixedRateBond, short: bool) -> float: + # TODO: This method is not AD safe: it uses "round" function which destroys derivatives # See CME pdf in doc Notes for formula. - coupon = bond.fixed_rate / 100.0 + coupon = _dual_float(bond.fixed_rate / 100.0) n, z = _get_years_and_months(self.delivery[0], bond.leg1.schedule.termination) if not short: mapping = { @@ -329,18 +331,19 @@ def _cfs_ust(self, bond: FixedRateBond, short: bool): c = 1 / 1.03 ** (2 * n + 1) d = (coupon / 0.06) * (1 - c) factor = a * ((coupon / 2) + c + d) - b - return round(factor, 4) + _: float = round(factor, 4) + return _ def dlv( self, - future_price: float | Dual | Dual2, - prices: list[float, Dual, Dual2], - repo_rate: float | Dual | Dual2 | list | tuple, + future_price: DualTypes, + prices: list[DualTypes], + repo_rate: DualTypes | tuple[DualTypes, ...], settlement: datetime, delivery: datetime | NoInput = NoInput(0), convention: str | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> DataFrame: """ Return an aggregated DataFrame of metrics similar to the Bloomberg DLV function. @@ -409,19 +412,19 @@ def dlv( dirty=dirty, ) df["Bond"] = [ - f"{bond.fixed_rate:,.3f}% " f"{bond.leg1.schedule.termination.strftime('%d-%m-%Y')}" + f"{bond.fixed_rate:,.3f}% {bond.leg1.schedule.termination.strftime('%d-%m-%Y')}" for bond in self.basket ] return df def cms( self, - prices: list[float], + prices: Sequence[float], settlement: datetime, - shifts: list[float], + shifts: Sequence[float], delivery: datetime | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> DataFrame: """ Perform CTD multi-security analysis. @@ -431,7 +434,7 @@ def cms( The prices of the bonds in the deliverable basket (ordered). settlement: datetime The settlement date of the bonds. - shifts : list of float + shifts : Sequence[float] The scenarios to analyse. delivery: datetime, optional The date of the futures delivery. If not given uses the final delivery @@ -454,7 +457,7 @@ def cms( # build a curve for pricing today = self.basket[0].leg1.schedule.calendar.lag( settlement, - -self.basket[0].kwargs["settle"], + -self.basket[0].kwargs["settle"], # type: ignore[arg-type, operator] False, ) unsorted_nodes = { @@ -472,18 +475,18 @@ def cms( solver = Solver( curves=[bcurve], instruments=[(_, (), {"curves": bcurve, "metric": metric}) for _ in self.basket], - s=prices, + s=prices, # type: ignore[arg-type] ) if solver.result["status"] != "SUCCESS": - return ValueError( + raise ValueError( "A bond curve could not be solved for analysis. " "See 'Cookbook: Bond Future CTD Multi-Security Analysis'.", ) bcurve._set_ad_order(order=0) # turn of AD for efficiency - data = { + data: dict[str | float, Any] = { "Bond": [ - f"{bond.fixed_rate:,.3f}% " f"{bond.leg1.schedule.termination.strftime('%d-%m-%Y')}" + f"{bond.fixed_rate:,.3f}% {bond.leg1.schedule.termination.strftime('%d-%m-%Y')}" for bond in self.basket ], } @@ -494,7 +497,7 @@ def cms( shift: self.net_basis( future_price=self.rate(curves=_curve), prices=[_.rate(curves=_curve, metric=metric) for _ in self.basket], - repo_rate=_curve.rate(settlement, self.delivery[1], "NONE"), + repo_rate=_curve._rate_with_raise(settlement, self.delivery[1], "NONE"), settlement=settlement, delivery=delivery, convention=_curve.convention, @@ -503,16 +506,16 @@ def cms( }, ) - _ = DataFrame(data=data) + _: DataFrame = DataFrame(data=data) return _ def gross_basis( self, - future_price: float | Dual | Dual2, - prices: list[float, Dual, Dual2], + future_price: DualTypes, + prices: list[DualTypes], settlement: datetime | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> tuple[DualTypes, ...]: """ Calculate the gross basis of each bond in the basket. @@ -532,7 +535,9 @@ def gross_basis( tuple """ if dirty: - prices_ = tuple( + if isinstance(settlement, NoInput): + raise ValueError("`settlement` must be specified if `dirty` is True.") + prices_: Sequence[DualTypes] = tuple( prices[i] - bond.accrued(settlement) for i, bond in enumerate(self.basket) ) else: @@ -541,14 +546,14 @@ def gross_basis( def net_basis( self, - future_price: float | Dual | Dual2, - prices: list[float, Dual, Dual2], - repo_rate: float | Dual | Dual2 | list | tuple, + future_price: DualTypes, + prices: Sequence[DualTypes], + repo_rate: DualTypes | Sequence[DualTypes], settlement: datetime, delivery: datetime | NoInput = NoInput(0), convention: str | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> tuple[DualTypes, ...]: """ Calculate the net basis of each bond in the basket via the proceeds method of repo. @@ -575,13 +580,10 @@ def net_basis( ------- tuple """ - if delivery is NoInput.blank: - f_settlement = self.delivery[1] - else: - f_settlement = delivery + f_settlement: datetime = _drb(self.delivery[1], delivery) - if not isinstance(repo_rate, list | tuple): - r_ = (repo_rate,) * len(self.basket) + if not isinstance(repo_rate, Sequence): + r_: Sequence[DualTypes] = (repo_rate,) * len(self.basket) else: r_ = repo_rate @@ -616,13 +618,13 @@ def net_basis( def implied_repo( self, - future_price: float | Dual | Dual2, - prices: list[float, Dual, Dual2], + future_price: DualTypes, + prices: Sequence[DualTypes], settlement: datetime, delivery: datetime | NoInput = NoInput(0), convention: str | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> tuple[DualTypes, ...]: """ Calculate the implied repo of each bond in the basket using the proceeds method. @@ -647,12 +649,9 @@ def implied_repo( ------- tuple """ - if delivery is NoInput.blank: - f_settlement = self.delivery[1] - else: - f_settlement = delivery + f_settlement: datetime = _drb(self.delivery[1], delivery) - implied_repos = tuple() + implied_repos: tuple[DualTypes, ...] = tuple() for i, bond in enumerate(self.basket): invoice_price = future_price * self.cfs[i] implied_repos += ( @@ -669,9 +668,9 @@ def implied_repo( def ytm( self, - future_price: float | Dual | Dual2, + future_price: DualTypes, delivery: datetime | NoInput = NoInput(0), - ): + ) -> tuple[DualTypes, ...]: """ Calculate the yield-to-maturity of the bond future. @@ -687,10 +686,7 @@ def ytm( ------- tuple """ - if delivery is NoInput.blank: - settlement = self.delivery[1] - else: - settlement = delivery + settlement: datetime = _drb(self.delivery[1], delivery) adjusted_prices = [future_price * cf for cf in self.cfs] yields = tuple( bond.ytm(adjusted_prices[i], settlement) for i, bond in enumerate(self.basket) @@ -699,10 +695,10 @@ def ytm( def duration( self, - future_price: float, + future_price: DualTypes, metric: str = "risk", delivery: datetime | NoInput = NoInput(0), - ): + ) -> tuple[float, ...]: """ Return the (negated) derivative of ``price`` w.r.t. ``ytm`` . @@ -738,26 +734,24 @@ def duration( future.ytm(112.98) future.ytm(112.98 + risk[0] / 100) """ - if delivery is NoInput.blank: - f_settlement = self.delivery[1] - else: - f_settlement = delivery + f_settlement: datetime = _drb(self.delivery[1], delivery) - _ = () + _: tuple[float, ...] = () for i, bond in enumerate(self.basket): invoice_price = future_price * self.cfs[i] ytm = bond.ytm(invoice_price, f_settlement) if metric == "risk": - _ += (bond.duration(ytm, f_settlement, "risk") / self.cfs[i],) + _ += (_dual_float(bond.duration(ytm, f_settlement, "risk") / self.cfs[i]),) else: - _ += (bond.duration(ytm, f_settlement, metric),) + __ = (bond.duration(ytm, f_settlement, metric),) + _ += __ return _ def convexity( self, - future_price: float, + future_price: DualTypes, delivery: datetime | NoInput = NoInput(0), - ): + ) -> tuple[float, ...]: """ Return the second derivative of ``price`` w.r.t. ``ytm`` . @@ -792,27 +786,25 @@ def convexity( future.duration(112.98) future.duration(112.98 + risk[0] / 100) """ - if delivery is NoInput.blank: - f_settlement = self.delivery[1] - else: - f_settlement = delivery + # TODO: Not AD safe becuase dependent convexity method is not AD safe. Returns float. + f_settlement: datetime = _drb(self.delivery[1], delivery) - _ = () + _: tuple[float, ...] = () for i, bond in enumerate(self.basket): invoice_price = future_price * self.cfs[i] ytm = bond.ytm(invoice_price, f_settlement) - _ += (bond.convexity(ytm, f_settlement) / self.cfs[i],) + _ += (_dual_float(bond.convexity(ytm, f_settlement) / self.cfs[i]),) return _ def ctd_index( self, - future_price: float, - prices: list | tuple, + future_price: DualTypes, + prices: Sequence[DualTypes], settlement: datetime, delivery: datetime | NoInput = NoInput(0), dirty: bool = False, ordered: bool = False, - ): + ) -> int | list[int]: """ Determine the index of the CTD in the basket from implied repo rate. @@ -849,7 +841,7 @@ def ctd_index( ctd_index_ = implied_repo.index(max(implied_repo)) return ctd_index_ else: - _ = dict(zip(range(len(implied_repo)), implied_repo, strict=False)) + _: dict[int, DualTypes] = dict(zip(range(len(implied_repo)), implied_repo, strict=True)) _ = dict(sorted(_.items(), key=lambda item: -item[1])) return list(_.keys()) @@ -857,13 +849,13 @@ def ctd_index( def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), metric: str = "future_price", delivery: datetime | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return various pricing metrics of the security calculated from :class:`~rateslib.curves.Curve` s. @@ -906,30 +898,30 @@ def rate( if metric not in ["future_price", "ytm"]: raise ValueError("`metric` must be in {'future_price', 'ytm'}.") - if delivery is NoInput.blank: + if isinstance(delivery, NoInput): f_settlement = self.delivery[1] else: f_settlement = delivery - prices_ = [ + prices_: list[DualTypes] = [ bond.rate(curves, solver, fx, base, "clean_price", f_settlement) for bond in self.basket ] - future_prices_ = [price / self.cfs[i] for i, price in enumerate(prices_)] - future_price = min(future_prices_) - ctd_index = future_prices_.index(min(future_prices_)) + future_prices_: list[DualTypes] = [price / self.cfs[i] for i, price in enumerate(prices_)] + future_price: DualTypes = min(future_prices_) + ctd_index: int = future_prices_.index(min(future_prices_)) if metric == "future_price": return future_price - elif metric == "ytm": + else: # metric == "ytm": return self.basket[ctd_index].ytm(future_price * self.cfs[ctd_index], f_settlement) def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ): + ) -> DualTypes | dict[str, DualTypes]: """ Determine the monetary value of the bond future position. @@ -941,14 +933,14 @@ def npv( See :meth:`BaseDerivative.npv`. """ future_price = self.rate(curves, solver, fx, base, "future_price") - fx, base = _get_fx_and_base(self.currency, fx, base) + fx_, base_ = _get_fx_and_base(self.currency, fx, base) npv_ = future_price / 100 * -self.notional if local: return {self.currency: npv_} else: - return npv_ * fx + return npv_ * fx_ - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -956,7 +948,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. diff --git a/python/rateslib/instruments/bonds/securities.py b/python/rateslib/instruments/bonds/securities.py index e9cd9663..9902fca7 100644 --- a/python/rateslib/instruments/bonds/securities.py +++ b/python/rateslib/instruments/bonds/securities.py @@ -3,16 +3,19 @@ import abc import warnings from datetime import datetime, timedelta +from typing import TYPE_CHECKING import numpy as np from pandas import DataFrame, Series from rateslib import defaults -from rateslib.calendars import CalInput, add_tenor, dcf +from rateslib.calendars import add_tenor, dcf from rateslib.curves import Curve, IndexCurve, LineCurve, average_rate, index_left from rateslib.default import NoInput, _drb -from rateslib.dual import Dual, Dual2, DualTypes, Number, gradient +from rateslib.dual import Dual, Dual2, gradient, quadratic_eqn +from rateslib.dual.utils import _dual_float, _get_order_of from rateslib.fx import FXForwards, FXRates +from rateslib.instruments.base import BaseMixin from rateslib.instruments.bonds.conventions import ( BILL_MODE_MAP, BOND_MODE_MAP, @@ -20,11 +23,10 @@ BondCalcMode, _get_calc_mode_for_class, ) +from rateslib.instruments.sensitivities import Sensitivities # from scipy.optimize import brentq -from rateslib.instruments.inst_core import ( - BaseMixin, - Sensitivities, +from rateslib.instruments.utils import ( _get, _get_curves_fx_and_base_maybe_from_solver, _push, @@ -40,7 +42,19 @@ _disc_maybe_from_curve, _maybe_local, ) -from rateslib.solver import Solver, quadratic_eqn + +if TYPE_CHECKING: + from rateslib.typing import ( + FX_, + Any, + CalInput, + Callable, + CurveOption, + Curves_, + DualTypes, + Number, + Solver_, + ) class BondMixin: @@ -60,7 +74,7 @@ def _period_index(self, settlement: datetime): def _period_cashflow( self, period: Cashflow | FixedPeriod | FloatPeriod | IndexCashflow | IndexFixedPeriod, - curve: Curve | LineCurve | NoInput, + curve: Curve | NoInput, ): pass # pragma: no cover @@ -78,14 +92,14 @@ def _period_cashflow( # except KeyError: # raise ValueError(f"Cannot calculate for `calc_mode`: {calc_mode}") - def _set_base_index_if_none(self, curve: IndexCurve): - if self._index_base_mixin and self.index_base is NoInput.blank: + def _set_base_index_if_none(self, curve: Curve) -> None: + if self._index_base_mixin and isinstance(self.index_base, NoInput): self.leg1.index_base = curve.index_value( self.leg1.schedule.effective, self.leg1.index_method, ) - def ex_div(self, settlement: datetime): + def ex_div(self, settlement: datetime) -> bool: """ Return a boolean whether the security is ex-div at the given settlement. @@ -141,7 +155,7 @@ def _accrued(self, settlement: datetime, func: callable): def _ytm( self, - price: Number, + price: DualTypes, settlement: datetime, curve: Curve | LineCurve | NoInput, dirty: bool, @@ -171,18 +185,24 @@ def _ytm( """ # noqa: E501 + price_float: float = _dual_float(price) + def root(y): # we set this to work in float arithmetic for efficiency. Dual is added # back below, see PR GH3 - return self._price_from_ytm( - ytm=y, settlement=settlement, calc_mode=self.calc_mode, dirty=dirty, curve=curve - ) - float(price) + return ( + self._price_from_ytm( + ytm=y, settlement=settlement, calc_mode=self.calc_mode, dirty=dirty, curve=curve + ) + - price_float + ) # x = brentq(root, -99, 10000) # remove dependence to scipy.optimize.brentq # x, iters = _brents(root, -99, 10000) # use own local brents code x = _ytm_quadratic_converger2(root, -3.0, 2.0, 12.0) # use special quad interp - if isinstance(price, Dual): + ad_order = _get_order_of(price) + if ad_order == 1: # use the inverse function theorem to express x as a Dual p = self._price_from_ytm( ytm=Dual(x, ["y"], []), @@ -192,7 +212,7 @@ def root(y): curve=NoInput(0), ) return Dual(x, price.vars, 1 / gradient(p, ["y"])[0] * price.dual) - elif isinstance(price, Dual2): + elif ad_order == 2: # use the IFT in 2nd order to express x as a Dual2 p = self._price_from_ytm( ytm=Dual2(x, ["y"], [], []), @@ -215,7 +235,7 @@ def root(y): def _price_from_ytm( self, - ytm: float, + ytm: DualTypes, settlement: datetime, calc_mode: str | BondCalcMode | NoInput, dirty: bool, @@ -298,14 +318,14 @@ def _generic_price_from_ytm( def fwd_from_repo( self, - price: float | Dual | Dual2, + price: DualTypes, settlement: datetime, forward_settlement: datetime, - repo_rate: float | Dual | Dual2, + repo_rate: DualTypes, convention: str | NoInput = NoInput(0), dirty: bool = False, method: str = "proceeds", - ): + ) -> DualTypes: """ Return a forward price implied by a given repo rate. @@ -336,7 +356,7 @@ def fwd_from_repo( Any intermediate (non ex-dividend) cashflows between ``settlement`` and ``forward_settlement`` will also be assumed to accrue at ``repo_rate``. """ - convention = defaults.convention if convention is NoInput.blank else convention + convention = _drb(defaults.convention, convention) dcf_ = dcf(settlement, forward_settlement, convention) if not dirty: d_price = price + self.accrued(settlement) @@ -387,13 +407,13 @@ def fwd_from_repo( def repo_from_fwd( self, - price: float | Dual | Dual2, + price: DualTypes, settlement: datetime, forward_settlement: datetime, - forward_price: float | Dual | Dual2, + forward_price: DualTypes, convention: str | NoInput = NoInput(0), dirty: bool = False, - ): + ) -> DualTypes: """ Return an implied repo rate from a forward price. @@ -422,7 +442,7 @@ def repo_from_fwd( Any intermediate (non ex-dividend) cashflows between ``settlement`` and ``forward_settlement`` will also be assumed to accrue at ``repo_rate``. """ - convention = defaults.convention if convention is NoInput.blank else convention + convention = _drb(defaults.convention, convention) # forward price from repo is linear in repo_rate so reverse calculate with AD if not dirty: p_t = forward_price + self.accrued(forward_settlement) @@ -527,7 +547,7 @@ def _npv_local( # deduct coupon after settlement which is also unpaid npv -= self.leg1.periods[settle_idx].npv(curve, disc_curve, NoInput(0), NoInput(0)) - if projection is NoInput.blank: + if isinstance(projection, NoInput): return npv else: return npv / disc_curve[projection] @@ -535,7 +555,7 @@ def _npv_local( def npv( self, curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + solver: Solver_ = NoInput(0), fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, @@ -635,7 +655,7 @@ def analytic_delta( def cashflows( self, curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + solver: Solver_ = NoInput(0), fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), settlement: datetime | NoInput = NoInput(0), @@ -680,9 +700,9 @@ def cashflows( ) self._set_base_index_if_none(curves[0]) - if settlement is NoInput.blank and curves[1] is NoInput.blank: + if isinstance(settlement, NoInput) and isinstance(curves[1], NoInput): settlement = self.leg1.schedule.effective - elif settlement is NoInput.blank: + elif isinstance(settlement, NoInput): settlement = self.leg1.schedule.calendar.lag( curves[1].node_dates[0], self.kwargs["settle"], @@ -703,7 +723,7 @@ def cashflows( def oaspread( self, curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + solver: Solver_ = NoInput(0), fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), price: DualTypes = NoInput(0), @@ -1129,6 +1149,7 @@ class FixedRateBond(Sensitivities, BondMixin, BaseMixin): """ # noqa: E501 _fixed_rate_mixin = True + fixed_rate: DualTypes def _period_cashflow( self, @@ -1198,8 +1219,10 @@ def __init__( ) self.kwargs = _update_with_defaults(self.kwargs, default_kwargs) - if self.kwargs["frequency"] is NoInput.blank: + if isinstance(self.kwargs["frequency"], NoInput): raise ValueError("`frequency` must be provided for Bond.") + if isinstance(self.kwargs["fixed_rate"], NoInput): + raise ValueError("`fixed_rate` must be provided for Bond.") # elif self.kwargs["frequency"].lower() == "z": # raise ValueError("FixedRateBond `frequency` must be in {M, B, Q, T, S, A}.") @@ -1243,9 +1266,9 @@ def accrued(self, settlement: datetime): def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), metric: str = "clean_price", forward_settlement: datetime | NoInput = NoInput(0), @@ -1291,7 +1314,7 @@ def rate( metric = metric.lower() if metric in ["clean_price", "dirty_price", "ytm"]: - if forward_settlement is NoInput.blank: + if isinstance(forward_settlement, NoInput): settlement = self.leg1.schedule.calendar.lag( curves[1].node_dates[0], self.kwargs["settle"], @@ -1337,7 +1360,7 @@ def rate( # TODO: calculate this par_spread formula. # return (self.notional - self.npv(*args, **kwargs)) / self.analytic_delta(*args, **kwargs) - def ytm(self, price: Number, settlement: datetime, dirty: bool = False) -> Number: + def ytm(self, price: DualTypes, settlement: datetime, dirty: bool = False) -> Number: """ Calculate the yield-to-maturity of the security given its price. @@ -1386,7 +1409,7 @@ def ytm(self, price: Number, settlement: datetime, dirty: bool = False) -> Numbe """ # noqa: E501 return self._ytm(price=price, settlement=settlement, dirty=dirty, curve=NoInput(0)) - def duration(self, ytm: float, settlement: datetime, metric: str = "risk"): + def duration(self, ytm: DualTypes, settlement: datetime, metric: str = "risk") -> float: """ Return the (negated) derivative of ``price`` w.r.t. ``ytm``. @@ -1451,19 +1474,21 @@ def duration(self, ytm: float, settlement: datetime, metric: str = "risk"): gilt.price(4.445, dt(1999, 5, 27)) gilt.price(4.455, dt(1999, 5, 27)) """ + # TODO: this is not AD safe: returns only float + ytm_: float = _dual_float(ytm) if metric == "risk": - _ = -gradient(self.price(Dual(float(ytm), ["y"], []), settlement), ["y"])[0] + _: float = -gradient(self.price(Dual(ytm_, ["y"], []), settlement), ["y"])[0] elif metric == "modified": - price = -self.price(Dual(float(ytm), ["y"], []), settlement, dirty=True) + price = -self.price(Dual(ytm_, ["y"], []), settlement, dirty=True) _ = -gradient(price, ["y"])[0] / float(price) * 100 elif metric == "duration": - price = self.price(Dual(float(ytm), ["y"], []), settlement, dirty=True) + price = self.price(Dual(ytm_, ["y"], []), settlement, dirty=True) f = 12 / defaults.frequency_months[self.kwargs["frequency"].upper()] - v = 1 + float(ytm) / (100 * f) + v = 1 + ytm_ / (100 * f) _ = -gradient(price, ["y"])[0] / float(price) * v * 100 return _ - def convexity(self, ytm: float, settlement: datetime): + def convexity(self, ytm: DualTypes, settlement: datetime) -> float: """ Return the second derivative of ``price`` w.r.t. ``ytm``. @@ -1502,10 +1527,12 @@ def convexity(self, ytm: float, settlement: datetime): gilt.duration(4.445, dt(1999, 5, 27)) gilt.duration(4.455, dt(1999, 5, 27)) """ - _ = self.price(Dual2(float(ytm), ["y"], [], []), settlement) - return gradient(_, ["y"], 2)[0][0] + # TODO: method is not AD safe: returns float + ytm_: float = _dual_float(ytm) + _ = self.price(Dual2(ytm_, ["_ytm__§"], [], []), settlement) + return gradient(_, ["_ytm__§"], 2)[0][0] - def price(self, ytm: float, settlement: datetime, dirty: bool = False): + def price(self, ytm: DualTypes, settlement: datetime, dirty: bool = False): """ Calculate the price of the security per nominal value of 100, given yield-to-maturity. @@ -1707,7 +1734,7 @@ def __init__( ) self.kwargs = _update_with_defaults(self.kwargs, default_kwargs) - if self.kwargs["frequency"] is NoInput.blank: + if isinstance(self.kwargs["frequency"], NoInput): raise ValueError("`frequency` must be provided for Bond.") # elif self.kwargs["frequency"].lower() == "z": # raise ValueError("FixedRateBond `frequency` must be in {M, B, Q, T, S, A}.") @@ -1730,7 +1757,7 @@ def __init__( raise NotImplementedError("`amortization` for IndexFixedRateBond must be zero.") def index_ratio(self, settlement: datetime, curve: IndexCurve | NoInput): - if self.leg1.index_fixings is not NoInput.blank and not isinstance( + if not isinstance(self.leg1.index_fixings, NoInput) and not isinstance( self.leg1.index_fixings, Series, ): @@ -1757,7 +1784,7 @@ def index_ratio(self, settlement: datetime, curve: IndexCurve | NoInput): def rate( self, curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + solver: Solver_ = NoInput(0), fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), metric: str = "clean_price", @@ -1815,7 +1842,7 @@ def rate( "ytm", "index_dirty_price", ]: - if forward_settlement is NoInput.blank: + if isinstance(forward_settlement, NoInput): settlement = self.leg1.schedule.calendar.lag( curves[1].node_dates[0], self.kwargs["settle"], @@ -2036,7 +2063,7 @@ def __init__( ) @property - def dcf(self): + def dcf(self) -> float: # bills will typically have 1 period since they are configured with frequency "z". d = 0.0 for i in range(self.leg1.schedule.n_periods): @@ -2045,12 +2072,12 @@ def dcf(self): def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - metric="price", - ): + metric: str = "price", + ) -> DualTypes: """ Return various pricing metrics of the security calculated from :class:`~rateslib.curves.Curve` s. @@ -2182,12 +2209,12 @@ def price( price_func = getattr(self, f"_price_{self.calc_mode._price_type}") return price_func(rate, settlement) - def _price_discount(self, rate: DualTypes, settlement: datetime): + def _price_discount(self, rate: DualTypes, settlement: datetime) -> DualTypes: acc_frac = self.calc_mode._settle_acc_frac_func(self, settlement, 0) dcf = (1 - acc_frac) * self.dcf return 100 - rate * dcf - def _price_simple(self, rate: DualTypes, settlement: datetime): + def _price_simple(self, rate: DualTypes, settlement: datetime) -> DualTypes: acc_frac = self.calc_mode._settle_acc_frac_func(self, settlement, 0) dcf = (1 - acc_frac) * self.dcf return 100 / (1 + rate * dcf / 100) @@ -2197,7 +2224,7 @@ def ytm( price: DualTypes, settlement: datetime, calc_mode: str | BillCalcMode | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Calculate the yield-to-maturity on an equivalent bond with a coupon of 0%. @@ -2224,7 +2251,7 @@ def ytm( with a regular 0% coupon measured from the termination date of the bill. """ - if calc_mode is NoInput.blank: + if isinstance(calc_mode, NoInput): calc_mode = self.calc_mode # kwargs["frequency"] is populated as the ytm_clone frequency at __init__ freq = self.kwargs["frequency"] @@ -2465,7 +2492,7 @@ def __init__( ) self.kwargs = _update_with_defaults(self.kwargs, default_kwargs) - if self.kwargs["frequency"] is NoInput.blank: + if isinstance(self.kwargs["frequency"], NoInput): raise ValueError("`frequency` must be provided for Bond.") elif self.kwargs["frequency"].lower() == "z": raise ValueError("FloatRateNote `frequency` must be in {M, B, Q, T, S, A}.") @@ -2499,7 +2526,7 @@ def __init__( # self.notional which is currently assumed to be a fixed quantity raise NotImplementedError("`amortization` for FloatRateNote must be zero.") - def _accrual_rate(self, pseudo_period, curve, method_param): + def _accrual_rate(self, pseudo_period: FloatPeriod, curve: CurveOption, method_param: int): """ Take a period and try to forecast the rate which determines the accrual, either from known fixings, a curve or forward filling historical fixings. @@ -2510,7 +2537,7 @@ def _accrual_rate(self, pseudo_period, curve, method_param): if pseudo_period.dcf < 1e-10: return 0.0 # there are no fixings in the period. - if curve is not NoInput.blank: + if not isinstance(curve, NoInput): curve_ = curve else: # Test to see if any missing fixings are required: @@ -2665,15 +2692,16 @@ def accrued( if self.ex_div(settlement): frac = frac - 1 # accrued is negative in ex-div period - if curve is not NoInput.blank: - curve_ = curve - else: + if isinstance(curve, NoInput): curve_ = Curve( { # create a dummy curve. rate() will return the fixing self.leg1.periods[acc_idx].start: 1.0, self.leg1.periods[acc_idx].end: 1.0, }, ) + else: + curve_ = curve + rate = self.leg1.periods[acc_idx].rate(curve_) cashflow = ( @@ -2721,7 +2749,7 @@ def accrued( def rate( self, curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + solver: Solver_ = NoInput(0), fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), metric="clean_price", @@ -2773,7 +2801,7 @@ def rate( metric = metric.lower() if metric in ["clean_price", "dirty_price", "spread", "ytm"]: - if forward_settlement is NoInput.blank: + if isinstance(forward_settlement, NoInput): settlement = self.leg1.schedule.calendar.lag( curves[1].node_dates[0], # discount curve self.kwargs["settle"], @@ -2791,7 +2819,7 @@ def rate( return dirty_price - self.accrued(settlement, curve=curves[0]) elif metric == "spread": _ = self.leg1._spread(-(npv + self.leg1.notional), curves[0], curves[1]) - z = 0.0 if self.float_spread is NoInput.blank else self.float_spread + z = _drb(0.0, self.float_spread) return _ + z elif metric == "ytm": return self.ytm( @@ -2800,7 +2828,7 @@ def rate( raise ValueError("`metric` must be in {'dirty_price', 'clean_price', 'spread', 'ytm'}.") - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -2808,7 +2836,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. @@ -2818,9 +2846,9 @@ def gamma(self, *args, **kwargs): def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -2856,7 +2884,7 @@ def fixings_table( ------- DataFrame """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -2865,7 +2893,7 @@ def fixings_table( self.leg1.currency, ) df = self.leg1.fixings_table( - curve=curves[0], approximate=approximate, disc_curve=curves[1], right=right + curve=curves_[0], approximate=approximate, disc_curve=curves_[1], right=right ) return df @@ -2905,7 +2933,16 @@ def ytm( return self._ytm(price=price, settlement=settlement, dirty=dirty, curve=curve) -def _ytm_quadratic_converger2(f, y0, y1, y2, f0=None, f1=None, f2=None, tol=1e-9): +def _ytm_quadratic_converger2( + f: Callable[[float], float], + y0: float, + y1: float, + y2: float, + f0: float | None = None, + f1: float | None = None, + f2: float | None = None, + tol: float = 1e-9, +) -> float: """ Convert a price from yield function `f` into a quadratic approximation and determine the root, yield, which matches the target price. @@ -2934,7 +2971,8 @@ def _ytm_quadratic_converger2(f, y0, y1, y2, f0=None, f1=None, f2=None, tol=1e-9 # check tolerance from previous recursive estimations for i, f_ in enumerate([f0, f1, f2]): if abs(f_) < tol: - return _b[i, 0] + y: float = _b[i, 0] + return y _A = np.array([[f0**2, f0, 1], [f1**2, f1, 1], [f2**2, f2, 1]]) c = np.linalg.solve(_A, _b) diff --git a/python/rateslib/instruments/credit/__init__.py b/python/rateslib/instruments/credit/__init__.py new file mode 100644 index 00000000..6fe258ff --- /dev/null +++ b/python/rateslib/instruments/credit/__init__.py @@ -0,0 +1,3 @@ +from rateslib.instruments.credit.derivatives import CDS + +__all__ = ["CDS"] diff --git a/python/rateslib/instruments/credit/derivatives.py b/python/rateslib/instruments/credit/derivatives.py new file mode 100644 index 00000000..04b5e390 --- /dev/null +++ b/python/rateslib/instruments/credit/derivatives.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rateslib import defaults +from rateslib.default import NoInput +from rateslib.dual.utils import _dual_float +from rateslib.instruments.base import BaseDerivative +from rateslib.instruments.utils import ( + _get, + _get_curves_fx_and_base_maybe_from_solver, + _update_not_noinput, + _update_with_defaults, +) +from rateslib.legs import CreditPremiumLeg, CreditProtectionLeg + +if TYPE_CHECKING: + from rateslib.typing import FX_, NPV, Any, Curves_, DataFrame, DualTypes, Solver_, datetime + + +class CDS(BaseDerivative): + """ + Create a credit default swap composing a :class:`~rateslib.legs.CreditPremiumLeg` and + a :class:`~rateslib.legs.CreditProtectionLeg`. + + Parameters + ---------- + args : dict + Required positional args to :class:`BaseDerivative`. + fixed_rate : float or None, optional + The rate applied to determine the cashflow on the premium leg. If `None`, can be set later, + typically after a mid-market rate for all periods has been calculated. + Entered in percentage points, e.g. 50bps is 0.50. + premium_accrued : bool, optional + Whether the premium is accrued within the period to default. + recovery_rate : float, Dual, Dual2, optional + The assumed recovery rate on the protection leg that defines payment on + credit default. Set by ``defaults``. + discretization : int, optional + The number of days to discretize the numerical integration over possible credit defaults, + for the protection leg. Set by ``defaults``. + kwargs : dict + Required keyword arguments to :class:`BaseDerivative`. + """ + + _rate_scalar = 1.0 + _fixed_rate_mixin = True + leg1: CreditPremiumLeg + leg2: CreditProtectionLeg + kwargs: dict[str, Any] + + def __init__( + self, + *args: Any, + fixed_rate: float | NoInput = NoInput(0), + premium_accrued: bool | NoInput = NoInput(0), + recovery_rate: DualTypes | NoInput = NoInput(0), + discretization: int | NoInput = NoInput(0), + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + cds_specific: dict[str, Any] = dict( + initial_exchange=False, # CDS have no exchanges + final_exchange=False, + leg2_initial_exchange=False, + leg2_final_exchange=False, + leg2_frequency="Z", # CDS protection is only ever one payoff + fixed_rate=fixed_rate, + premium_accrued=premium_accrued, + leg2_recovery_rate=recovery_rate, + leg2_discretization=discretization, + ) + self.kwargs = _update_not_noinput(self.kwargs, cds_specific) + + # set defaults for missing values + default_kwargs = dict( + premium_accrued=defaults.cds_premium_accrued, + leg2_recovery_rate=defaults.cds_recovery_rate, + leg2_discretization=defaults.cds_protection_discretization, + ) + self.kwargs = _update_with_defaults(self.kwargs, default_kwargs) + + self.leg1 = CreditPremiumLeg(**_get(self.kwargs, leg=1)) + self.leg2 = CreditProtectionLeg(**_get(self.kwargs, leg=2)) + self._fixed_rate = self.kwargs["fixed_rate"] + + def _set_pricing_mid( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + ) -> None: + # the test for an unpriced IRS is that its fixed rate is not set. + if isinstance(self.fixed_rate, NoInput): + # set a rate for the purpose of generic methods NPV will be zero. + mid_market_rate = self.rate(curves, solver) + self.leg1.fixed_rate = _dual_float(mid_market_rate) + + def analytic_delta(self, *args: Any, **kwargs: Any) -> DualTypes: + """ + Return the analytic delta of a leg of the derivative object. + + See :meth:`BaseDerivative.analytic_delta`. + """ + return super().analytic_delta(*args, **kwargs) + + def analytic_rec_risk(self, *args: Any, **kwargs: Any) -> DualTypes: + """ + Return the analytic recovery risk of the derivative object. + + See :meth:`BaseDerivative.analytic_delta`. + """ + return self.leg2.analytic_rec_risk(*args, **kwargs) + + def npv( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + ) -> NPV: + """ + Return the NPV of the derivative by summing legs. + + See :meth:`BaseDerivative.npv`. + """ + self._set_pricing_mid(curves, solver) + return super().npv(curves, solver, fx, base, local) + + def rate( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ) -> DualTypes: + """ + Return the mid-market credit spread of the CDS. + + Parameters + ---------- + curves : Curve, str or list of such + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for floating leg. + - Discounting :class:`~rateslib.curves.Curve` for both legs. + solver : Solver, optional + The numerical :class:`~rateslib.solver.Solver` that + constructs :class:`~rateslib.curves.Curve` from calibrating instruments. + + .. note:: + + The arguments ``fx`` and ``base`` are unused by single currency + derivatives rates calculations. + + Returns + ------- + float, Dual or Dual2 + + Notes + ----- + The arguments ``fx`` and ``base`` are unused by single currency derivatives + rates calculations. + """ + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + leg2_npv: DualTypes = self.leg2.npv(curves_[2], curves_[3], local=False) # type: ignore[assignment] + return self.leg1._spread(-leg2_npv, curves_[0], curves_[1]) * 0.01 + + def cashflows( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ) -> DataFrame: + """ + Return the properties of all legs used in calculating cashflows. + + See :meth:`BaseDerivative.cashflows`. + """ + self._set_pricing_mid(curves, solver) + return super().cashflows(curves, solver, fx, base) + + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International + # Commercial use of this code, and/or copying and redistribution is prohibited. + # Contact rateslib at gmail.com if this code is observed outside its intended sphere. + + def accrued(self, settlement: datetime) -> DualTypes | None: + """ + Calculate the amount of premium accrued until a specific date within the relevant *Period*. + + Parameters + ---------- + settlement: datetime + The date against which accrued is measured. + + Returns + ------- + float or None + + Notes + ------ + If the *CDS* is unpriced, i.e. there is no specified ``fixed_rate`` then None will be + returned. + """ + return self.leg1.accrued(settlement) diff --git a/python/rateslib/instruments/fx_volatility.py b/python/rateslib/instruments/fx_volatility.py index 18129332..0990e8c1 100644 --- a/python/rateslib/instruments/fx_volatility.py +++ b/python/rateslib/instruments/fx_volatility.py @@ -2,18 +2,19 @@ from abc import ABCMeta from datetime import datetime +from typing import TYPE_CHECKING from pandas import DataFrame from rateslib import defaults -from rateslib.calendars import CalInput, _get_fx_expiry_and_delivery, get_calendar +from rateslib.calendars import _get_fx_expiry_and_delivery, get_calendar from rateslib.curves import Curve from rateslib.default import NoInput, _drb, plot -from rateslib.dual import DualTypes, dual_log +from rateslib.dual import dual_log from rateslib.fx import FXForwards, FXRates from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXVolObj, FXVols -from rateslib.instruments.inst_core import ( - Sensitivities, +from rateslib.instruments.sensitivities import Sensitivities +from rateslib.instruments.utils import ( _get_curves_fx_and_base_maybe_from_solver, _get_vol_maybe_from_solver, _push, @@ -23,6 +24,9 @@ from rateslib.solver import Solver from rateslib.splines import evaluate +if TYPE_CHECKING: + from rateslib.typing import CalInput, DualTypes + class FXOption(Sensitivities, metaclass=ABCMeta): """ @@ -590,7 +594,7 @@ def analytic_greeks( def _plot_payoff( self, - range: list[float] | NoInput = NoInput(0), + window: list[float] | NoInput = NoInput(0), curves: Curve | str | list | NoInput = NoInput(0), solver: Solver | NoInput = NoInput(0), fx: FXForwards | NoInput = NoInput(0), @@ -611,12 +615,12 @@ def _plot_payoff( self._set_strike_and_vol(curves, fx, vol) # self._set_premium(curves, fx) - x, y = self.periods[0]._payoff_at_expiry(range) + x, y = self.periods[0]._payoff_at_expiry(window) return x, y def plot_payoff( self, - range: list[float] | NoInput = NoInput(0), + range: list[float] | NoInput = NoInput(0), # noqa: A002 curves: Curve | str | list | NoInput = NoInput(0), solver: Solver | NoInput = NoInput(0), fx: FXForwards | NoInput = NoInput(0), @@ -802,7 +806,7 @@ def npv( def _plot_payoff( self, - range: list[float] | NoInput = NoInput(0), + window: list[float] | NoInput = NoInput(0), curves: Curve | str | list | NoInput = NoInput(0), solver: Solver | NoInput = NoInput(0), fx: FXForwards | NoInput = NoInput(0), @@ -815,7 +819,7 @@ def _plot_payoff( y = None for option, vol_ in zip(self.periods, vol, strict=False): - x, y_ = option._plot_payoff(range, curves, solver, fx, base, local, vol_) + x, y_ = option._plot_payoff(window, curves, solver, fx, base, local, vol_) if y is None: y = y_ else: @@ -1705,7 +1709,7 @@ def analytic_greeks( def _plot_payoff( self, - range: list[float] | NoInput = NoInput(0), + range: list[float] | NoInput = NoInput(0), # noqa: A002 curves: Curve | str | list | NoInput = NoInput(0), solver: Solver | NoInput = NoInput(0), fx: FXForwards | NoInput = NoInput(0), diff --git a/python/rateslib/instruments/generics.py b/python/rateslib/instruments/generics.py index fb663b2d..50cd0bf3 100644 --- a/python/rateslib/instruments/generics.py +++ b/python/rateslib/instruments/generics.py @@ -1,7 +1,9 @@ from __future__ import annotations import warnings +from collections.abc import Sequence from datetime import datetime +from typing import TYPE_CHECKING from pandas import DataFrame, DatetimeIndex, concat @@ -9,18 +11,21 @@ from rateslib.calendars import dcf from rateslib.curves import Curve from rateslib.default import NoInput -from rateslib.dual import DualTypes, dual_log +from rateslib.dual import dual_log from rateslib.fx import FXForwards, FXRates from rateslib.fx_volatility import FXVols -from rateslib.instruments.inst_core import ( - BaseMixin, - Sensitivities, +from rateslib.instruments.base import BaseMixin +from rateslib.instruments.sensitivities import Sensitivities +from rateslib.instruments.utils import ( _composit_fixings_table, _get_curves_fx_and_base_maybe_from_solver, _get_vol_maybe_from_solver, ) from rateslib.solver import Solver +if TYPE_CHECKING: + from rateslib.typing import FX_, NPV, Any, Curves_, DualTypes, Instrument, NoReturn + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # This code cannot be installed or executed on a corporate computer without a paid licence extension @@ -66,8 +71,8 @@ def __init__( effective: datetime, convention: str | NoInput = NoInput(0), metric: str = "curve_value", - curves: list | str | Curve | None = None, - ): + curves: list[str | Curve] | str | Curve | NoInput = NoInput(0), + ) -> None: self.effective = effective self.curves = curves self.convention = defaults.convention if convention is NoInput.blank else convention @@ -75,12 +80,12 @@ def __init__( def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), metric: str | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return a value derived from a *Curve*. @@ -127,13 +132,13 @@ def rate( return _ raise ValueError("`metric`must be in {'curve_value', 'cc_zero_rate', 'index_value'}.") - def npv(self, *args, **kwargs): + def npv(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`Value` instrument has no concept of NPV.") - def cashflows(self, *args, **kwargs): + def cashflows(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`Value` instrument has no concept of cashflows.") - def analytic_delta(self, *args, **kwargs): + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`Value` instrument has no concept of analytic delta.") @@ -245,13 +250,13 @@ def rate( raise ValueError("`metric` must be in {'vol'}.") - def npv(self, *args, **kwargs): + def npv(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`VolValue` instrument has no concept of NPV.") - def cashflows(self, *args, **kwargs): + def cashflows(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`VolValue` instrument has no concept of cashflows.") - def analytic_delta(self, *args, **kwargs): + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`VolValue` instrument has no concept of analytic delta.") @@ -311,7 +316,7 @@ def __init__(self, instrument1, instrument2): def __repr__(self): return f"" - def npv(self, *args, **kwargs): + def npv(self, *args: Any, **kwargs: Any) -> NPV: """ Return the NPV of the composited object by summing instrument NPVs. @@ -353,7 +358,7 @@ def npv(self, *args, **kwargs): # args2 = args # return self.instrument1.npv(*args1) + self.instrument2.npv(*args2) - def rate(self, *args, **kwargs): + def rate(self, *args: Any, **kwargs: Any) -> DualTypes: """ Return the mid-market rate of the composited via the difference of instrument rates. @@ -384,7 +389,7 @@ def rate(self, *args, **kwargs): # args2 = args # return self.instrument2.rate(*args2) - self.instrument1.rate(*args1) - def cashflows(self, *args, **kwargs): + def cashflows(self, *args: Any, **kwargs: Any) -> DataFrame: return concat( [ self.instrument1.cashflows(*args, **kwargs), @@ -393,7 +398,7 @@ def cashflows(self, *args, **kwargs): keys=["instrument1", "instrument2"], ) - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -401,7 +406,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. @@ -411,9 +416,9 @@ def gamma(self, *args, **kwargs): def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -463,15 +468,15 @@ class Fly(Sensitivities): _rate_scalar = 100.0 - def __init__(self, instrument1, instrument2, instrument3): + def __init__(self, instrument1, instrument2, instrument3) -> None: self.instrument1 = instrument1 self.instrument2 = instrument2 self.instrument3 = instrument3 - def __repr__(self): + def __repr__(self) -> str: return f"" - def npv(self, *args, **kwargs): + def npv(self, *args: Any, **kwargs: Any) -> NPV: """ Return the NPV of the composited object by summing instrument NPVs. @@ -499,7 +504,7 @@ def npv(self, *args, **kwargs): else: return leg1_npv + leg2_npv + leg3_npv - def rate(self, *args, **kwargs): + def rate(self, *args: Any, **kwargs: Any) -> DualTypes: """ Return the mid-market rate of the composited via the difference of instrument rates. @@ -522,8 +527,8 @@ def rate(self, *args, **kwargs): leg3_rate = self.instrument3.rate(*args, **kwargs) return (-leg3_rate + 2 * leg2_rate - leg1_rate) * 100.0 - def cashflows(self, *args, **kwargs): - return concat( + def cashflows(self, *args: Any, **kwargs: Any) -> DataFrame: + _: DataFrame = concat( [ self.instrument1.cashflows(*args, **kwargs), self.instrument2.cashflows(*args, **kwargs), @@ -531,8 +536,9 @@ def cashflows(self, *args, **kwargs): ], keys=["instrument1", "instrument2", "instrument3"], ) + return _ - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -540,7 +546,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. @@ -550,13 +556,13 @@ def gamma(self, *args, **kwargs): def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), - ): + ) -> DataFrame: """ Return a DataFrame of fixing exposures on the *Instruments*. @@ -583,7 +589,7 @@ def fixings_table( # Contact info at rateslib.com if this code is observed outside its intended sphere of use. -def _instrument_npv(instrument, *args, **kwargs): # pragma: no cover +def _instrument_npv(instrument, *args: Any, **kwargs: Any) -> NPV: # pragma: no cover # this function is captured by TestPortfolio pooling but is not registered as a parallel process # used for parallel processing with Portfolio.npv return instrument.npv(*args, **kwargs) @@ -609,30 +615,30 @@ class Portfolio(Sensitivities): See examples for :class:`Spread` for similar functionality. """ - def __init__(self, instruments): - if not isinstance(instruments, list): + def __init__(self, instruments: Sequence[Instrument]) -> None: + if not isinstance(instruments, Sequence): raise ValueError("`instruments` should be a list of Instruments.") self.instruments = instruments - def __repr__(self): + def __repr__(self) -> str: return f"" - def npv( + def npv( # type: ignore[override] self, - curves: Curve | str | list | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - **kwargs, - ): + **kwargs: Any, + ) -> NPV: """ Return the NPV of the *Portfolio* by summing instrument NPVs. For arguments see :meth:`BaseDerivative.npv()`. """ # TODO look at legs.npv where args len is used. - if not local and base is NoInput.blank and fx is NoInput.blank: + if not local and isinstance(base, NoInput) and isinstance(fx, NoInput): warnings.warn( "No ``base`` currency is inferred, using ``local`` output. To return a single " "PV specify a ``base`` currency and ensure an ``fx`` or ``solver.fx`` object " @@ -671,7 +677,7 @@ def npv( if local: _ = DataFrame(results).fillna(0.0) _ = _.sum() - ret = _.to_dict() + val1: dict[str, DualTypes] = _.to_dict() # ret = {} # for result in results: @@ -681,36 +687,42 @@ def npv( # else: # ret[ccy] = result[ccy] + ret: NPV = val1 else: - ret = sum(results) + val2: DualTypes = sum(results) # type: ignore[arg-type] + ret = val2 return ret - def _npv_single_core(self, *args, **kwargs): + def _npv_single_core(self, *args: Any, **kwargs: Any) -> NPV: if kwargs.get("local", False): # dicts = [instrument.npv(*args, **kwargs) for instrument in self.instruments] # result = dict(reduce(operator.add, map(Counter, dicts))) - ret = {} + val1: dict[str, DualTypes] = {} for instrument in self.instruments: i_npv = instrument.npv(*args, **kwargs) for ccy in i_npv: - if ccy in ret: - ret[ccy] += i_npv[ccy] + if ccy in val1: + val1[ccy] += i_npv[ccy] else: - ret[ccy] = i_npv[ccy] + val1[ccy] = i_npv[ccy] + ret: DualTypes | dict[str, DualTypes] = val1 else: - _ = (instrument.npv(*args, **kwargs) for instrument in self.instruments) - ret = sum(_) + val2: DualTypes = sum( + instrument.npv(*args, **kwargs) for instrument in self.instruments + ) + ret = val2 return ret - def cashflows(self, *args, **kwargs): - return concat( + def cashflows(self, *args: Any, **kwargs: Any) -> DataFrame: + _: DataFrame = concat( [_.cashflows(*args, **kwargs) for _ in self.instruments], keys=[f"inst{i}" for i in range(len(self.instruments))], ) + return _ - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -718,7 +730,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. @@ -728,13 +740,13 @@ def gamma(self, *args, **kwargs): def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), - ): + ) -> DataFrame: """ Return a DataFrame of fixing exposures on the *Instruments*. diff --git a/python/rateslib/instruments/inst_core.py b/python/rateslib/instruments/inst_core.py deleted file mode 100644 index 368f0f18..00000000 --- a/python/rateslib/instruments/inst_core.py +++ /dev/null @@ -1,1035 +0,0 @@ -from __future__ import annotations - -import abc -import warnings - -from pandas import DataFrame, concat, isna - -from rateslib import defaults -from rateslib.curves import Curve -from rateslib.default import NoInput -from rateslib.dual import Dual, Dual2, DualTypes -from rateslib.fx import FXForwards, FXRates -from rateslib.fx_volatility import FXVols -from rateslib.solver import Solver - - -def _get_curve_from_solver(curve, solver): - if isinstance(curve, dict): - # When supplying a curve as a dictionary of curves (for IBOR stubs) use recursion - return {k: _get_curve_from_solver(v, solver) for k, v in curve.items()} - elif getattr(curve, "_is_proxy", False): - # TODO: (mid) consider also adding CompositeCurves as exceptions under the same rule - # proxy curves exist outside of solvers but still have Dual variables associated - # with curves inside the solver, so can still generate risks to calibrating - # instruments - return curve - elif isinstance(curve, str): - solver._validate_state() - return solver.pre_curves[curve] - elif curve is NoInput.blank or curve is None: - # pass through a None curve. This will either raise errors later or not be needed - return NoInput(0) - else: - try: - # it is a safeguard to load curves from solvers when a solver is - # provided and multiple curves might have the same id - solver._validate_state() - _ = solver.pre_curves[curve.id] - if id(_) != id(curve): # Python id() is a memory id, not a string label id. - raise ValueError( - "A curve has been supplied, as part of ``curves``, which has the same " - f"`id` ('{curve.id}'),\nas one of the curves available as part of the " - "Solver's collection but is not the same object.\n" - "This is ambiguous and cannot price.\n" - "Either refactor the arguments as follows:\n" - "1) remove the conflicting curve: [curves=[..], solver=] -> " - "[curves=None, solver=]\n" - "2) change the `id` of the supplied curve and ensure the rateslib.defaults " - "option 'curve_not_in_solver' is set to 'ignore'.\n" - " This will remove the ability to accurately price risk metrics.", - ) - return _ - except AttributeError: - raise AttributeError( - "`curve` has no attribute `id`, likely it not a valid object, got: " - f"{curve}.\nSince a solver is provided have you missed labelling the `curves` " - f"of the instrument or supplying `curves` directly?", - ) - except KeyError: - if defaults.curve_not_in_solver == "ignore": - return curve - elif defaults.curve_not_in_solver == "warn": - warnings.warn("`curve` not found in `solver`.", UserWarning) - return curve - else: - raise ValueError("`curve` must be in `solver`.") - - -def _get_base_maybe_from_fx( - fx: float | FXRates | FXForwards | NoInput, - base: str | NoInput, - local_ccy: str | NoInput, -) -> str | NoInput: - if isinstance(fx, NoInput | float) and isinstance(base, NoInput): - # base will not be inherited from a 2nd level inherited object, i.e. - # from solver.fx, to preserve single currency instruments being defaulted - # to their local currency. - base_ = local_ccy - elif isinstance(fx, FXRates | FXForwards) and isinstance(base, NoInput): - base_ = fx.base - else: - base_ = base - return base_ - - -def _get_fx_maybe_from_solver( - solver: Solver | NoInput, - fx: float | FXRates | FXForwards | NoInput, -) -> float | FXRates | FXForwards | NoInput: - if isinstance(fx, NoInput): - if isinstance(solver, NoInput): - fx_ = NoInput(0) - # fx_ = 1.0 - else: # solver is not NoInput: - if isinstance(solver.fx, NoInput): - fx_ = NoInput(0) - # fx_ = 1.0 - else: - solver._validate_state() - fx_ = solver.fx - else: - fx_ = fx - if ( - not isinstance(solver, NoInput) - and not isinstance(solver.fx, NoInput) - and id(fx) != id(solver.fx) - ): - warnings.warn( - "Solver contains an `fx` attribute but an `fx` argument has been " - "supplied which will be used but is not the same. This can lead " - "to calculation inconsistencies, mathematically.", - UserWarning, - ) - - return fx_ - - -def _get_curves_maybe_from_solver( - curves_attr: Curve | str | list | NoInput, - solver: Solver | NoInput, - curves: Curve | str | list | NoInput, -) -> tuple: - """ - Attempt to resolve curves as a variety of input types to a 4-tuple consisting of: - (leg1 forecasting, leg1 discounting, leg2 forecasting, leg2 discounting) - """ - if curves is NoInput.blank and curves_attr is NoInput.blank: - # no data is available so consistently return a 4-tuple of no data - return (NoInput(0), NoInput(0), NoInput(0), NoInput(0)) - elif curves is NoInput.blank: - # set the `curves` input as that which is set as attribute at instrument init. - curves = curves_attr - - if not isinstance(curves, list | tuple): - # convert isolated value input to list - curves = [curves] - - if solver is NoInput.blank: - - def check_curve(curve): - if isinstance(curve, str): - raise ValueError("`curves` must contain Curve, not str, if `solver` not given.") - elif curve is None or curve is NoInput(0): - return NoInput(0) - elif isinstance(curve, dict): - return {k: check_curve(v) for k, v in curve.items()} - return curve - - curves_ = tuple(check_curve(curve) for curve in curves) - else: - try: - curves_ = tuple(_get_curve_from_solver(curve, solver) for curve in curves) - except KeyError as e: - raise ValueError( - "`curves` must contain str curve `id` s existing in `solver` " - "(or its associated `pre_solvers`).\n" - f"The sought id was: '{e.args[0]}'.\n" - f"The available ids are {list(solver.pre_curves.keys())}.", - ) - - if len(curves_) == 1: - curves_ *= 4 - elif len(curves_) == 2: - curves_ *= 2 - elif len(curves_) == 3: - curves_ += (curves_[1],) - elif len(curves_) > 4: - raise ValueError("Can only supply a maximum of 4 `curves`.") - - return curves_ - - -def _get_curves_fx_and_base_maybe_from_solver( - curves_attr: Curve | str | list | None, - solver: Solver | None, - curves: Curve | str | list | None, - fx: float | FXRates | FXForwards | None, - base: str | None, - local_ccy: str | None, -) -> tuple: - """ - Parses the ``solver``, ``curves``, ``fx`` and ``base`` arguments in combination. - - Parameters - ---------- - curves_attr - The curves attribute attached to the class. - solver - The solver argument passed in the outer method. - curves - The curves argument passed in the outer method. - fx - The fx argument agrument passed in the outer method. - - Returns - ------- - tuple : (leg1 forecasting, leg1 discounting, leg2 forecasting, leg2 discounting), fx, base - - Notes - ----- - If only one curve is given this is used as all four curves. - - If two curves are given the forecasting curve is used as the forecasting - curve on both legs and the discounting curve is used as the discounting - curve for both legs. - - If three curves are given the single discounting curve is used as the - discounting curve for both legs. - """ - # First process `base`. - base_ = _get_base_maybe_from_fx(fx, base, local_ccy) - # Second process `fx` - fx_ = _get_fx_maybe_from_solver(solver, fx) - # Third process `curves` - curves_ = _get_curves_maybe_from_solver(curves_attr, solver, curves) - return curves_, fx_, base_ - - -def _get_vol_maybe_from_solver( - vol_attr: DualTypes | str | FXVols | NoInput, - vol: DualTypes | str | FXVols | NoInput, - solver: Solver | NoInput, -): - """ - Try to retrieve a general vol input from a solver or the default vol object associated with - instrument. - - Parameters - ---------- - vol_attr: DualTypes, str or FXDeltaVolSmile - The vol attribute associated with the object at initialisation. - vol: DualTypes, str of FXDeltaVolSMile - The specific vol argument supplied at price time. Will take precendence. - solver: Solver, optional - A solver object - - Returns - ------- - DualTypes, FXDeltaVolSmile or NoInput.blank - """ - if vol is None: # capture blank user input and reset - vol = NoInput(0) - - if vol is NoInput.blank and vol_attr is NoInput.blank: - return NoInput(0) - elif vol is NoInput.blank: - vol = vol_attr - - if solver is NoInput.blank: - if isinstance(vol, str): - raise ValueError( - "String `vol` ids require a `solver` to be mapped. No `solver` provided.", - ) - return vol - elif isinstance(vol, float | Dual | Dual2): - return vol - elif isinstance(vol, str): - return solver.pre_curves[vol] - else: # vol is a Smile or Surface - check that it is in the Solver - try: - # it is a safeguard to load curves from solvers when a solver is - # provided and multiple curves might have the same id - _ = solver.pre_curves[vol.id] - if id(_) != id(vol): # Python id() is a memory id, not a string label id. - raise ValueError( - "A ``vol`` object has been supplied which has the same " - f"`id` ('{vol.id}'),\nas one of those available as part of the " - "Solver's collection but is not the same object.\n" - "This is ambiguous and may lead to erroneous prices.\n", - ) - return _ - except AttributeError: - raise AttributeError( - "`vol` has no attribute `id`, likely it not a valid object, got: " - f"{vol}.\nSince a solver is provided have you missed labelling the `vol` " - f"of the instrument or supplying `vol` directly?", - ) - except KeyError: - if defaults.curve_not_in_solver == "ignore": - return vol - elif defaults.curve_not_in_solver == "warn": - warnings.warn("`vol` not found in `solver`.", UserWarning) - return vol - else: - raise ValueError("`vol` must be in `solver`.") - - -class Sensitivities: - """ - Base class to add risk sensitivity calculations to an object with an ``npv()`` - method. - """ - - def delta( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - **kwargs, - ) -> DataFrame: - """ - Calculate delta risk of an *Instrument* against the calibrating instruments in a - :class:`~rateslib.curves.Solver`. - - Parameters - ---------- - curves : Curve, str or list of such, optional - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - solver : Solver, optional - The :class:`~rateslib.solver.Solver` that calibrates - *Curves* from given *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 - :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or - :class:`~rateslib.fx.FXForwards` object. - local : bool, optional - If `True` will ignore ``base`` - this is equivalent to setting ``base`` to *None*. - Included only for argument signature consistent with *npv*. - - Returns - ------- - DataFrame - """ - if solver is NoInput.blank: - raise ValueError("`solver` is required for delta/gamma methods.") - npv = self.npv(curves, solver, fx, base, local=True, **kwargs) - _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - NoInput(0), - solver, - NoInput(0), - fx, - base, - NoInput(0), - ) - if local: - base_ = NoInput(0) - return solver.delta(npv, base_, fx_) - - def exo_delta( - self, - vars: list[str], - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - vars_scalar: list[float] | NoInput = NoInput(0), - vars_labels: list[str] | NoInput = NoInput(0), - **kwargs, - ) -> DataFrame: - """ - Calculate delta risk of an *Instrument* against some exogenous user created *Variables*. - - See :ref:`What are exogenous variables? ` in the cookbook. - - Parameters - ---------- - vars : list[str] - The variable tags which to determine sensitivities for. - curves : Curve, str or list of such, optional - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - - solver : Solver, optional - The :class:`~rateslib.solver.Solver` that calibrates - *Curves* from given *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 - :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or - :class:`~rateslib.fx.FXForwards` object. - local : bool, optional - If `True` will ignore ``base`` - this is equivalent to setting ``base`` to *None*. - Included only for argument signature consistent with *npv*. - vars_scalar : list[float], optional - Scaling factors for each variable, for example converting rates to basis point etc. - Defaults to ones. - vars_labels : list[str], optional - Alternative names to relabel variables in DataFrames. - - Returns - ------- - DataFrame - """ - - if solver is NoInput.blank: - raise ValueError("`solver` is required for delta/gamma methods.") - npv = self.npv(curves, solver, fx, base, local=True, **kwargs) - _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - NoInput(0), - solver, - NoInput(0), - fx, - base, - NoInput(0), - ) - if local: - base_ = NoInput(0) - return solver.exo_delta( - npv=npv, vars=vars, base=base_, fx=fx_, vars_scalar=vars_scalar, vars_labels=vars_labels - ) - - def gamma( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - **kwargs, - ): - """ - Calculate cross-gamma risk of an *Instrument* against the calibrating instruments of a - :class:`~rateslib.curves.Solver`. - - Parameters - ---------- - curves : Curve, str or list of such, optional - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - solver : Solver, optional - The :class:`~rateslib.solver.Solver` that calibrates - *Curves* from given *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 - :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or - :class:`~rateslib.fx.FXForwards` object. - local : bool, optional - If `True` will ignore ``base``. This is equivalent to setting ``base`` to *None*. - Included only for argument signature consistent with *npv*. - - Returns - ------- - DataFrame - """ - if isinstance(solver, NoInput): - raise ValueError("`solver` is required for delta/gamma methods.") - _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - NoInput(0), - solver, - NoInput(0), - fx, - base, - NoInput(0), - ) - if local: - base_ = NoInput(0) - - # store original order - if id(solver.fx) != id(fx_) and not isinstance(fx_, NoInput): - # then the fx_ object is available on solver but that is not being used. - _ad2 = fx_._ad - fx_._set_ad_order(2) - - _ad1 = solver._ad - solver._set_ad_order(2) - - npv = self.npv(curves, solver, fx_, base_, local=True, **kwargs) - grad_s_sT_P = solver.gamma(npv, base_, fx_) - - # reset original order - if id(solver.fx) != id(fx_) and not isinstance(fx_, NoInput): - fx_._set_ad_order(_ad2) - solver._set_ad_order(_ad1) - - return grad_s_sT_P - - def cashflows_table( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - **kwargs, - ): - """ - Aggregate the values derived from a :meth:`~rateslib.instruments.BaseMixin.cashflows` - method on an *Instrument*. - - Parameters - ---------- - curves : CurveType, str or list of such, optional - Argument input to the underlying ``cashflows`` method of the *Instrument*. - solver : Solver, optional - Argument input to the underlying ``cashflows`` method of the *Instrument*. - fx : float, FXRates, FXForwards, optional - Argument input to the underlying ``cashflows`` method of the *Instrument*. - base : str, optional - Argument input to the underlying ``cashflows`` method of the *Instrument*. - kwargs : dict - Additional arguments input the underlying ``cashflows`` method of the *Instrument*. - - Returns - ------- - DataFrame - """ - cashflows = self.cashflows(curves, solver, fx, base, **kwargs) - cashflows = cashflows[ - [ - defaults.headers["currency"], - defaults.headers["collateral"], - defaults.headers["payment"], - defaults.headers["cashflow"], - ] - ] - _ = cashflows.groupby( - [ - defaults.headers["currency"], - defaults.headers["collateral"], - defaults.headers["payment"], - ], - dropna=False, - ) - _ = _.sum().unstack([0, 1]).droplevel(0, axis=1) - _.columns.names = ["local_ccy", "collateral_ccy"] - _.index.names = ["payment"] - _ = _.sort_index(ascending=True, axis=0).fillna(0.0) - return _ - - -class BaseMixin: - _fixed_rate_mixin = False - _float_spread_mixin = False - _leg2_fixed_rate_mixin = False - _leg2_float_spread_mixin = False - _index_base_mixin = False - _leg2_index_base_mixin = False - _rate_scalar = 1.0 - - @property - def fixed_rate(self): - """ - float or None : If set will also set the ``fixed_rate`` of the contained - leg1. - - .. note:: - ``fixed_rate``, ``float_spread``, ``leg2_fixed_rate`` and - ``leg2_float_spread`` are attributes only applicable to certain - ``Instruments``. *AttributeErrors* are raised if calling or setting these - is invalid. - - """ - return self._fixed_rate - - @fixed_rate.setter - def fixed_rate(self, value): - if not self._fixed_rate_mixin: - raise AttributeError("Cannot set `fixed_rate` for this Instrument.") - self._fixed_rate = value - self.leg1.fixed_rate = value - - @property - def leg2_fixed_rate(self): - """ - float or None : If set will also set the ``fixed_rate`` of the contained - leg2. - """ - return self._leg2_fixed_rate - - @leg2_fixed_rate.setter - def leg2_fixed_rate(self, value): - if not self._leg2_fixed_rate_mixin: - raise AttributeError("Cannot set `leg2_fixed_rate` for this Instrument.") - self._leg2_fixed_rate = value - self.leg2.fixed_rate = value - - @property - def float_spread(self): - """ - float or None : If set will also set the ``float_spread`` of contained - leg1. - """ - return self._float_spread - - @float_spread.setter - def float_spread(self, value): - if not self._float_spread_mixin: - raise AttributeError("Cannot set `float_spread` for this Instrument.") - self._float_spread = value - self.leg1.float_spread = value - # if getattr(self, "_float_mixin_leg", None) is NoInput.blank: - # self.leg1.float_spread = value - # else: - # # allows fixed_rate and float_rate to exist simultaneously for diff legs. - # leg = getattr(self, "_float_mixin_leg", None) - # getattr(self, f"leg{leg}").float_spread = value - - @property - def leg2_float_spread(self): - """ - float or None : If set will also set the ``float_spread`` of contained - leg2. - """ - return self._leg2_float_spread - - @leg2_float_spread.setter - def leg2_float_spread(self, value): - if not self._leg2_float_spread_mixin: - raise AttributeError("Cannot set `leg2_float_spread` for this Instrument.") - self._leg2_float_spread = value - self.leg2.float_spread = value - - @property - def index_base(self): - """ - float or None : If set will also set the ``index_base`` of the contained - leg1. - - .. note:: - ``index_base`` and ``leg2_index_base`` are attributes only applicable to certain - ``Instruments``. *AttributeErrors* are raised if calling or setting these - is invalid. - - """ - return self._index_base - - @index_base.setter - def index_base(self, value): - if not self._index_base_mixin: - raise AttributeError("Cannot set `index_base` for this Instrument.") - self._index_base = value - self.leg1.index_base = value - - @property - def leg2_index_base(self): - """ - float or None : If set will also set the ``index_base`` of the contained - leg1. - - .. note:: - ``index_base`` and ``leg2_index_base`` are attributes only applicable to certain - ``Instruments``. *AttributeErrors* are raised if calling or setting these - is invalid. - - """ - return self._leg2_index_base - - @leg2_index_base.setter - def leg2_index_base(self, value): - if not self._leg2_index_base_mixin: - raise AttributeError("Cannot set `leg2_index_base` for this Instrument.") - self._leg2_index_base = value - self.leg2.index_base = value - - @abc.abstractmethod - def analytic_delta(self, *args, leg=1, **kwargs): - """ - Return the analytic delta of a leg of the derivative object. - - Parameters - ---------- - args : - Required positional arguments supplied to - :meth:`BaseLeg.analytic_delta`. - leg : int in [1, 2] - The leg identifier of which to take the analytic delta. - kwargs : - Required Keyword arguments supplied to - :meth:`BaseLeg.analytic_delta()`. - - Returns - ------- - float, Dual, Dual2 - - Examples - -------- - .. ipython:: python - :suppress: - - from rateslib import Curve, FXRates, IRS, dt - - .. ipython:: python - - curve = Curve({dt(2021,1,1): 1.00, dt(2025,1,1): 0.83}, id="SONIA") - fxr = FXRates({"gbpusd": 1.25}, base="usd") - - .. ipython:: python - - irs = IRS( - effective=dt(2022, 1, 1), - termination="6M", - frequency="Q", - currency="gbp", - notional=1e9, - fixed_rate=5.0, - ) - irs.analytic_delta(curve, curve) - irs.analytic_delta(curve, curve, fxr) - irs.analytic_delta(curve, curve, fxr, "gbp") - """ - return getattr(self, f"leg{leg}").analytic_delta(*args, **kwargs) - - @abc.abstractmethod - def cashflows( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the properties of all legs used in calculating cashflows. - - Parameters - ---------- - curves : CurveType, str or list of such, optional - A single :class:`~rateslib.curves.Curve`, - :class:`~rateslib.curves.LineCurve` or id or a - list of such. A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - solver : Solver, optional - The numerical :class:`~rateslib.solver.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 - :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object, - converts from local currency into ``base``. - base : str, optional - The base currency to convert cashflows into (3-digit code). - Only used if ``fx`` is an :class:`~rateslib.fx.FXRates` or - :class:`~rateslib.fx.FXForwards` object. If not given defaults - to ``fx.base``. - - Returns - ------- - DataFrame - - Notes - ----- - If **only one curve** is given this is used as all four curves. - - If **two curves** are given the forecasting curve is used as the forecasting - curve on both legs and the discounting curve is used as the discounting - curve for both legs. - - If **three curves** are given the single discounting curve is used as the - discounting curve for both legs. - - Examples - -------- - .. ipython:: python - - irs.cashflows([curve], fx=fxr) - """ - curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - - df1 = self.leg1.cashflows(curves[0], curves[1], fx_, base_) - df2 = self.leg2.cashflows(curves[2], curves[3], fx_, base_) - # filter empty or all NaN - dfs_filtered = [_ for _ in [df1, df2] if not (_.empty or isna(_).all(axis=None))] - - with warnings.catch_warnings(): - # TODO: pandas 2.1.0 has a FutureWarning for concatenating DataFrames with Null entries - warnings.filterwarnings("ignore", category=FutureWarning) - _ = concat(dfs_filtered, keys=["leg1", "leg2"]) - return _ - - @abc.abstractmethod - def npv( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - ): - """ - Return the NPV of the derivative object by summing legs. - - Parameters - ---------- - curves : Curve, LineCurve, str or list of such - A single :class:`~rateslib.curves.Curve`, - :class:`~rateslib.curves.LineCurve` or id or a - list of such. A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - solver : Solver, optional - The numerical :class:`~rateslib.solver.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 - :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.FXForwards` object, - converts from local currency into ``base``. - base : str, optional - The base currency to convert cashflows into (3-digit code). - Only used if ``fx`` is an :class:`~rateslib.fx.FXRates` or - :class:`~rateslib.fx.FXForwards` object. If not given defaults - to ``fx.base``. - local : bool, optional - If `True` will return a dict identifying NPV by local currencies on each - leg. Useful for multi-currency derivatives and for ensuring risk - sensitivities are allocated to local currencies without conversion. - - Returns - ------- - float, Dual or Dual2, or dict of such. - - Notes - ----- - If **only one curve** is given this is used as all four curves. - - If **two curves** are given the forecasting curve is used as the forecasting - curve on both legs and the discounting curve is used as the discounting - curve for both legs. - - If **three curves** are given the single discounting curve is used as the - discounting curve for both legs. - - Examples - -------- - .. ipython:: python - - irs.npv(curve) - irs.npv([curve], fx=fxr) - irs.npv([curve], fx=fxr, base="gbp") - """ - curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - leg1_npv = self.leg1.npv(curves[0], curves[1], fx_, base_, local) - leg2_npv = self.leg2.npv(curves[2], curves[3], fx_, base_, local) - if local: - return { - k: leg1_npv.get(k, 0) + leg2_npv.get(k, 0) for k in set(leg1_npv) | set(leg2_npv) - } - else: - return leg1_npv + leg2_npv - - @abc.abstractmethod - def rate(self, *args, **kwargs): - """ - Return the `rate` or typical `price` for a derivative instrument. - - Returns - ------- - Dual - - Notes - ----- - This method must be implemented for instruments to function effectively in - :class:`Solver` iterations. - """ - pass # pragma: no cover - - def __repr__(self): - return f"" - - -def _get(kwargs: dict, leg: int = 1, filter=()): - """ - A parser to return kwarg dicts for relevant legs. - Internal structuring only. - Will return kwargs relevant to leg1 OR leg2. - Does not return keys that are specified in the filter. - """ - if leg == 1: - _ = {k: v for k, v in kwargs.items() if "leg2" not in k and k not in filter} - else: - _ = {k[5:]: v for k, v in kwargs.items() if "leg2_" in k and k not in filter} - return _ - - -def _push(spec: str | None, kwargs: dict): - """ - Push user specified kwargs to a default specification. - Values from the `spec` dict will not overwrite specific user values already in `kwargs`. - """ - if spec is NoInput.blank: - return kwargs - else: - try: - spec_kwargs = defaults.spec[spec.lower()] - except KeyError: - raise ValueError(f"Given `spec`, '{spec}', cannot be found in defaults.") - - user = {k: v for k, v in kwargs.items() if not isinstance(v, NoInput)} - return {**kwargs, **spec_kwargs, **user} - - -def _update_not_noinput(base_kwargs, new_kwargs): - """ - Update the `base_kwargs` with `new_kwargs` (user values) unless those new values are NoInput. - """ - updaters = { - k: v for k, v in new_kwargs.items() if k not in base_kwargs or not isinstance(v, NoInput) - } - return {**base_kwargs, **updaters} - - -def _update_with_defaults(base_kwargs, default_kwargs): - """ - Update the `base_kwargs` with `default_kwargs` if the values are NoInput.blank. - """ - updaters = { - k: v - for k, v in default_kwargs.items() - if k in base_kwargs and base_kwargs[k] is NoInput.blank - } - return {**base_kwargs, **updaters} - - -def _inherit_or_negate(kwargs: dict, ignore_blank=False): - """Amend the values of leg2 kwargs if they are defaulted to inherit or negate from leg1.""" - - def _replace(k, v): - # either inherit or negate the value in leg2 from that in leg1 - if "leg2_" in k: - if not isinstance(v, NoInput): - return v # do nothing if the attribute is an input - - try: - leg1_v = kwargs[k[5:]] - except KeyError: - return v - - if leg1_v is NoInput.blank: - if ignore_blank: - return v # this allows an inheritor or negator to be called a second time - else: - return NoInput(0) - - if v is NoInput(-1): - return leg1_v * -1.0 - elif v is NoInput(1): - return leg1_v - return v # do nothing to leg1 attributes - - return {k: _replace(k, v) for k, v in kwargs.items()} - - -def _lower(val: str | NoInput): - if isinstance(val, str): - return val.lower() - return val - - -def _upper(val: str | NoInput): - if isinstance(val, str): - return val.upper() - return val - - -def _composit_fixings_table(df_result, df): - """ - Add a DataFrame to an existing fixings table by extending or adding to relevant columns. - - Parameters - ---------- - df_result: The main DataFrame that will be updated - df: The incoming DataFrame with new data to merge - - Returns - ------- - DataFrame - """ - # reindex the result DataFrame - if df_result.empty: - return df - else: - df_result = df_result.reindex(index=df_result.index.union(df.index)) - - # update existing columns with missing data from the new available data - for c in [c for c in df.columns if c in df_result.columns and c[1] in ["dcf", "rates"]]: - df_result[c] = df_result[c].combine_first(df[c]) - - # merge by addition existing values with missing filled to zero - m = [c for c in df.columns if c in df_result.columns and c[1] in ["notional", "risk"]] - if len(m) > 0: - df_result[m] = df_result[m].add(df[m], fill_value=0.0) - - # append new columns without additional calculation - a = [c for c in df.columns if c not in df_result.columns] - if len(a) > 0: - df_result[a] = df[a] - - # df_result.columns = MultiIndex.from_tuples(df_result.columns) - return df_result diff --git a/python/rateslib/instruments/rates/__init__.py b/python/rateslib/instruments/rates/__init__.py new file mode 100644 index 00000000..78ad1ddc --- /dev/null +++ b/python/rateslib/instruments/rates/__init__.py @@ -0,0 +1,5 @@ +from rateslib.instruments.rates.inflation import IIRS, ZCIS +from rateslib.instruments.rates.multi_currency import XCS, FXExchange, FXSwap +from rateslib.instruments.rates.single_currency import FRA, IRS, SBS, ZCS, STIRFuture + +__all__ = ["ZCIS", "IIRS", "SBS", "FRA", "IRS", "ZCS", "STIRFuture", "XCS", "FXExchange", "FXSwap"] diff --git a/python/rateslib/instruments/rates/inflation.py b/python/rateslib/instruments/rates/inflation.py new file mode 100644 index 00000000..85700e28 --- /dev/null +++ b/python/rateslib/instruments/rates/inflation.py @@ -0,0 +1,676 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rateslib.default import NoInput +from rateslib.instruments.base import BaseDerivative +from rateslib.instruments.utils import ( + _get, + _get_curves_fx_and_base_maybe_from_solver, + _update_not_noinput, +) +from rateslib.legs import FloatLeg, IndexFixedLeg, ZeroFixedLeg, ZeroIndexLeg + +if TYPE_CHECKING: + from rateslib.typing import FX_, Any, Curves, DataFrame, Series, Solver, datetime + + +class ZCIS(BaseDerivative): + """ + Create a zero coupon index swap (ZCIS) composing an + :class:`~rateslib.legs.ZeroFixedLeg` + and a :class:`~rateslib.legs.ZeroIndexLeg`. + + Parameters + ---------- + args : dict + Required positional args to :class:`BaseDerivative`. + fixed_rate : float or None + The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` + will be set to mid-market when curves are provided. + leg2_index_base : float or None, optional + The base index applied to all periods. + leg2_index_fixings : float, or Series, optional + If a float scalar, will be applied as the index fixing for the first + period. + If a list of *n* fixings will be used as the index fixings for the first *n* + periods. + If a datetime indexed ``Series`` will use the fixings that are available in + that object, and derive the rest from the ``curve``. + leg2_index_method : str + Whether the indexing uses a daily measure for settlement or the most recently + monthly data taken from the first day of month. + leg2_index_lag : int, optional + The number of months by which the index value is lagged. Used to ensure + consistency between curves and forecast values. Defined by default. + kwargs : dict + Required keyword arguments to :class:`BaseDerivative`. + + Examples + -------- + Construct a curve to price the example. + + .. ipython:: python + + usd = Curve( + nodes={ + dt(2022, 1, 1): 1.0, + dt(2027, 1, 1): 0.85, + dt(2032, 1, 1): 0.65, + }, + id="usd", + ) + us_cpi = IndexCurve( + nodes={ + dt(2022, 1, 1): 1.0, + dt(2027, 1, 1): 0.85, + dt(2032, 1, 1): 0.70, + }, + id="us_cpi", + index_base=100, + index_lag=3, + ) + + Create the ZCIS, and demonstrate the :meth:`~rateslib.instruments.ZCIS.rate`, + :meth:`~rateslib.instruments.ZCIS.npv`, + :meth:`~rateslib.instruments.ZCIS.analytic_delta`, and + + .. ipython:: python + + zcis = ZCIS( + effective=dt(2022, 1, 1), + termination="10Y", + spec="usd_zcis", + fixed_rate=2.05, + notional=100e6, + leg2_index_base=100.0, + curves=["usd", "usd", "us_cpi", "usd"], + ) + zcis.rate(curves=[us_cpi, usd]) + zcis.npv(curves=[us_cpi, usd]) + zcis.analytic_delta(usd, usd) + + A DataFrame of :meth:`~rateslib.instruments.ZCIS.cashflows`. + + .. ipython:: python + + zcis.cashflows(curves=[us_cpi, usd]) + + For accurate sensitivity calculations; :meth:`~rateslib.instruments.ZCIS.delta` + and :meth:`~rateslib.instruments.ZCIS.gamma`, construct a curve model. + + .. ipython:: python + + instruments = [ + IRS(dt(2022, 1, 1), "5Y", spec="usd_irs", curves="usd"), + IRS(dt(2022, 1, 1), "10Y", spec="usd_irs", curves="usd"), + ZCIS(dt(2022, 1, 1), "5Y", spec="usd_zcis", curves=["us_cpi", "usd"]), + ZCIS(dt(2022, 1, 1), "10Y", spec="usd_zcis", curves=["us_cpi", "usd"]), + ] + solver = Solver( + curves=[usd, us_cpi], + instruments=instruments, + s=[3.40, 3.60, 2.2, 2.05], + instrument_labels=["5Y", "10Y", "5Yi", "10Yi"], + id="us", + ) + zcis.delta(solver=solver) + zcis.gamma(solver=solver) + """ + + _fixed_rate_mixin = True + _leg2_index_base_mixin = True + + def __init__( + self, + *args: Any, + fixed_rate: float | NoInput = NoInput(0), + leg2_index_base: float | Series | NoInput = NoInput(0), + leg2_index_fixings: float | Series | NoInput = NoInput(0), + leg2_index_method: str | NoInput = NoInput(0), + leg2_index_lag: int | NoInput = NoInput(0), + **kwargs, + ): + super().__init__(*args, **kwargs) + user_kwargs = dict( + fixed_rate=fixed_rate, + leg2_index_base=leg2_index_base, + leg2_index_fixings=leg2_index_fixings, + leg2_index_lag=leg2_index_lag, + leg2_index_method=leg2_index_method, + ) + self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + self._fixed_rate = fixed_rate + self._leg2_index_base = leg2_index_base + self.leg1 = ZeroFixedLeg(**_get(self.kwargs, leg=1)) + self.leg2 = ZeroIndexLeg(**_get(self.kwargs, leg=2)) + + def _set_pricing_mid(self, curves, solver): + if self.fixed_rate is NoInput.blank: + # set a fixed rate for the purpose of pricing NPV, which should be zero. + mid_market_rate = self.rate(curves, solver) + self.leg1.fixed_rate = float(mid_market_rate) + + def cashflows( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ): + self._set_pricing_mid(curves, solver) + return super().cashflows(curves, solver, fx, base) + + def npv( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + ): + self._set_pricing_mid(curves, solver) + return super().npv(curves, solver, fx, base, local) + + def rate( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ): + """ + Return the mid-market IRR rate of the ZCIS. + + Parameters + ---------- + curves : Curve, str or list of such + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for floating leg. + - Discounting :class:`~rateslib.curves.Curve` for both legs. + solver : Solver, optional + The numerical :class:`~rateslib.solver.Solver` that + constructs :class:`~rateslib.curves.Curve` from calibrating instruments. + + .. note:: + + The arguments ``fx`` and ``base`` are unused by single currency + derivatives rates calculations. + + Returns + ------- + float, Dual or Dual2 + + Notes + ----- + The arguments ``fx`` and ``base`` are unused by single currency derivatives + rates calculations. + """ + curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + if isinstance(self.leg2_index_base, NoInput): + # must forecast for the leg + forecast_value = curves[2].index_value( + self.leg2.schedule.effective, + self.leg2.index_method, + ) + if abs(forecast_value) < 1e-13: + raise ValueError( + "Forecasting the `index_base` for the ZCIS yielded 0.0, which is infeasible.\n" + "This might occur if the ZCIS starts in the past, or has a 'monthly' " + "`index_method` which uses the 1st day of the effective month, which is in the " + "past.\nA known `index_base` value should be input with the ZCIS " + "specification.", + ) + self.leg2.index_base = forecast_value + leg2_npv = self.leg2.npv(curves[2], curves[3]) + + return self.leg1._spread(-leg2_npv, curves[0], curves[1]) / 100 + + +class IIRS(BaseDerivative): + """ + Create an indexed interest rate swap (IIRS) composing an + :class:`~rateslib.legs.IndexFixedLeg` and a :class:`~rateslib.legs.FloatLeg`. + + Parameters + ---------- + args : dict + Required positional args to :class:`BaseDerivative`. + fixed_rate : float or None + The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` + will be set to mid-market when curves are provided. + index_base : float or None, optional + The base index applied to all periods. + index_fixings : float, or Series, optional + If a float scalar, will be applied as the index fixing for the first + period. + If a list of *n* fixings will be used as the index fixings for the first *n* + periods. + If a datetime indexed ``Series`` will use the fixings that are available in + that object, and derive the rest from the ``curve``. + index_method : str + Whether the indexing uses a daily measure for settlement or the most recently + monthly data taken from the first day of month. + index_lag : int, optional + The number of months by which the index value is lagged. Used to ensure + consistency between curves and forecast values. Defined by default. + notional_exchange : bool, optional + Whether the legs include final notional exchanges and interim + amortization notional exchanges. + kwargs : dict + Required keyword arguments to :class:`BaseDerivative`. + + Examples + -------- + Construct a curve to price the example. + + .. ipython:: python + + usd = Curve( + nodes={ + dt(2022, 1, 1): 1.0, + dt(2027, 1, 1): 0.85, + dt(2032, 1, 1): 0.65, + }, + id="usd", + ) + us_cpi = IndexCurve( + nodes={ + dt(2022, 1, 1): 1.0, + dt(2027, 1, 1): 0.85, + dt(2032, 1, 1): 0.70, + }, + id="us_cpi", + index_base=100, + index_lag=3, + ) + + Create the IIRS, and demonstrate the :meth:`~rateslib.instruments.IIRS.rate`, and + :meth:`~rateslib.instruments.IIRS.npv`. + + .. ipython:: python + + iirs = IIRS( + effective=dt(2022, 1, 1), + termination="4Y", + frequency="A", + calendar="nyc", + currency="usd", + fixed_rate=2.05, + convention="1+", + notional=100e6, + index_base=100.0, + index_method="monthly", + index_lag=3, + notional_exchange=True, + leg2_convention="Act360", + curves=["us_cpi", "usd", "usd", "usd"], + ) + iirs.rate(curves=[us_cpi, usd, usd, usd]) + iirs.npv(curves=[us_cpi, usd, usd, usd]) + + A DataFrame of :meth:`~rateslib.instruments.IIRS.cashflows`. + + .. ipython:: python + + iirs.cashflows(curves=[us_cpi, usd, usd, usd]) + + For accurate sensitivity calculations; :meth:`~rateslib.instruments.IIRS.delta` + and :meth:`~rateslib.instruments.IIRS.gamma`, construct a curve model. + + .. ipython:: python + + sofr_kws = dict( + effective=dt(2022, 1, 1), + frequency="A", + convention="Act360", + calendar="nyc", + currency="usd", + curves=["usd"] + ) + cpi_kws = dict( + effective=dt(2022, 1, 1), + frequency="A", + convention="1+", + calendar="nyc", + leg2_index_method="monthly", + currency="usd", + curves=["usd", "usd", "us_cpi", "usd"] + ) + instruments = [ + IRS(termination="5Y", **sofr_kws), + IRS(termination="10Y", **sofr_kws), + ZCIS(termination="5Y", **cpi_kws), + ZCIS(termination="10Y", **cpi_kws), + ] + solver = Solver( + curves=[usd, us_cpi], + instruments=instruments, + s=[3.40, 3.60, 2.2, 2.05], + instrument_labels=["5Y", "10Y", "5Yi", "10Yi"], + id="us", + ) + iirs.delta(solver=solver) + iirs.gamma(solver=solver) + """ + + _fixed_rate_mixin = True + _index_base_mixin = True + _leg2_float_spread_mixin = True + + def __init__( + self, + *args: Any, + fixed_rate: float | NoInput = NoInput(0), + index_base: float | Series | NoInput = NoInput(0), + index_fixings: float | Series | NoInput = NoInput(0), + index_method: str | NoInput = NoInput(0), + index_lag: int | NoInput = NoInput(0), + notional_exchange: bool | NoInput = False, + payment_lag_exchange: int | NoInput = NoInput(0), + leg2_float_spread: float | NoInput = NoInput(0), + leg2_fixings: float | list | NoInput = NoInput(0), + leg2_fixing_method: str | NoInput = NoInput(0), + leg2_method_param: int | NoInput = NoInput(0), + leg2_spread_compound_method: str | NoInput = NoInput(0), + leg2_payment_lag_exchange: int | NoInput = NoInput(1), + **kwargs, + ): + super().__init__(*args, **kwargs) + if leg2_payment_lag_exchange is NoInput.inherit: + leg2_payment_lag_exchange = payment_lag_exchange + user_kwargs = dict( + fixed_rate=fixed_rate, + index_base=index_base, + index_fixings=index_fixings, + index_method=index_method, + index_lag=index_lag, + initial_exchange=False, + final_exchange=notional_exchange, + payment_lag_exchange=payment_lag_exchange, + leg2_float_spread=leg2_float_spread, + leg2_spread_compound_method=leg2_spread_compound_method, + leg2_fixings=leg2_fixings, + leg2_fixing_method=leg2_fixing_method, + leg2_method_param=leg2_method_param, + leg2_payment_lag_exchange=leg2_payment_lag_exchange, + leg2_initial_exchange=False, + leg2_final_exchange=notional_exchange, + ) + self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + + self._index_base = self.kwargs["index_base"] + self._fixed_rate = self.kwargs["fixed_rate"] + self.leg1 = IndexFixedLeg(**_get(self.kwargs, leg=1)) + self.leg2 = FloatLeg(**_get(self.kwargs, leg=2)) + + def _set_pricing_mid( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + ): + mid_market_rate = self.rate(curves, solver) + self.leg1.fixed_rate = float(mid_market_rate) + + def npv( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + ): + curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + if self.index_base is NoInput.blank: + # must forecast for the leg + self.leg1.index_base = curves[0].index_value( + self.leg1.schedule.effective, + self.leg1.index_method, + ) + if self.fixed_rate is NoInput.blank: + # set a fixed rate for the purpose of pricing NPV, which should be zero. + self._set_pricing_mid(curves, solver) + return super().npv(curves, solver, fx_, base_, local) + + def cashflows( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ): + curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + if self.index_base is NoInput.blank: + # must forecast for the leg + self.leg1.index_base = curves[0].index_value( + self.leg1.schedule.effective, + self.leg1.index_method, + ) + if self.fixed_rate is NoInput.blank: + # set a fixed rate for the purpose of pricing NPV, which should be zero. + self._set_pricing_mid(curves, solver) + return super().cashflows(curves, solver, fx_, base_) + + def rate( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ): + """ + Return the mid-market rate of the IRS. + + Parameters + ---------- + curves : Curve, str or list of such + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for floating leg. + - Discounting :class:`~rateslib.curves.Curve` for both legs. + solver : Solver, optional + The numerical :class:`~rateslib.solver.Solver` that + constructs :class:`~rateslib.curves.Curve` from calibrating instruments. + + .. note:: + + The arguments ``fx`` and ``base`` are unused by single currency + derivatives rates calculations. + + Returns + ------- + float, Dual or Dual2 + + Notes + ----- + The arguments ``fx`` and ``base`` are unused by single currency derivatives + rates calculations. + """ + curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + if self.index_base is NoInput.blank: + # must forecast for the leg + self.leg1.index_base = curves[0].index_value( + self.leg1.schedule.effective, + self.leg1.index_method, + ) + leg2_npv = self.leg2.npv(curves[2], curves[3]) + + if self.fixed_rate is NoInput.blank: + self.leg1.fixed_rate = 0.0 + _existing = self.leg1.fixed_rate + leg1_npv = self.leg1.npv(curves[0], curves[1]) + + _ = self.leg1._spread(-leg2_npv - leg1_npv, curves[0], curves[1]) / 100 + return _ + _existing + + def spread( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + ): + """ + Return the mid-market float spread (bps) required to equate to the fixed rate. + + Parameters + ---------- + curves : Curve, str or list of such + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for floating leg. + - Discounting :class:`~rateslib.curves.Curve` for both legs. + solver : Solver, optional + The numerical :class:`~rateslib.solver.Solver` that constructs + :class:`~rateslib.curves.Curve` from calibrating instruments. + + .. note:: + + The arguments ``fx`` and ``base`` are unused by single currency + derivatives rates calculations. + + Returns + ------- + float, Dual or Dual2 + + Notes + ----- + If the :class:`IRS` is specified without a ``fixed_rate`` this should always + return the current ``leg2_float_spread`` value or zero since the fixed rate used + for calculation is the implied rate including the current ``leg2_float_spread`` + parameter. + + Examples + -------- + For the most common parameters this method will be exact. + + .. ipython:: python + + irs.spread(curves=usd) + irs.leg2_float_spread = -6.948753 + irs.npv(curves=usd) + + When a non-linear spread compound method is used for float RFR legs this is + an approximation, via second order Taylor expansion. + + .. ipython:: python + + irs = IRS( + effective=dt(2022, 2, 15), + termination=dt(2022, 8, 15), + frequency="Q", + convention="30e360", + leg2_convention="Act360", + leg2_fixing_method="rfr_payment_delay", + leg2_spread_compound_method="isda_compounding", + payment_lag=2, + fixed_rate=2.50, + leg2_float_spread=0, + notional=50000000, + currency="usd", + ) + irs.spread(curves=usd) + irs.leg2_float_spread = -111.060143 + irs.npv(curves=usd) + irs.spread(curves=usd) + + The ``leg2_float_spread`` is determined through NPV differences. If the difference + is small since the defined spread is already quite close to the solution the + approximation is much more accurate. This is shown above where the second call + to ``irs.spread`` is different to the previous call, albeit the difference + is 1/10000th of a basis point. + """ + irs_npv = self.npv(curves, solver) + specified_spd = 0 if self.leg2.float_spread is NoInput.blank else self.leg2.float_spread + curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + fx, + base, + self.leg1.currency, + ) + return self.leg2._spread(-irs_npv, curves[2], curves[3]) + specified_spd + + def fixings_table( + self, + curves: Curves = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + approximate: bool = False, + right: datetime | NoInput = NoInput(0), + ) -> DataFrame: + """ + Return a DataFrame of fixing exposures on the :class:`~rateslib.legs.FloatLeg`. + + Parameters + ---------- + curves : Curve, str or list of such + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for floating leg. + - Discounting :class:`~rateslib.curves.Curve` for both legs. + + solver : Solver, optional + The numerical :class:`~rateslib.solver.Solver` that constructs + :class:`~rateslib.curves.Curve` from calibrating instruments. + + .. note:: + + The arguments ``fx`` and ``base`` are unused by single currency + derivatives rates calculations. + + approximate : bool, optional + Perform a calculation that is broadly 10x faster but potentially loses + precision upto 0.1%. + + Returns + ------- + DataFrame + """ + curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + NoInput(0), + NoInput(0), + self.leg2.currency, + ) + df = self.leg2.fixings_table( + curve=curves[2], approximate=approximate, disc_curve=curves[3], right=right + ) + return df diff --git a/python/rateslib/instruments/rates_multi_ccy.py b/python/rateslib/instruments/rates/multi_currency.py similarity index 99% rename from python/rateslib/instruments/rates_multi_ccy.py rename to python/rateslib/instruments/rates/multi_currency.py index c6f4b382..cdac6507 100644 --- a/python/rateslib/instruments/rates_multi_ccy.py +++ b/python/rateslib/instruments/rates/multi_currency.py @@ -2,23 +2,23 @@ import warnings from datetime import datetime +from typing import TYPE_CHECKING from pandas import DataFrame, DatetimeIndex, MultiIndex, Series from rateslib import defaults from rateslib.curves import Curve from rateslib.default import NoInput -from rateslib.dual import Dual, Dual2, DualTypes +from rateslib.dual import Dual, Dual2 from rateslib.fx import FXForwards, FXRates, forward_fx -from rateslib.instruments.inst_core import ( - BaseMixin, - Sensitivities, +from rateslib.instruments.base import BaseDerivative, BaseMixin +from rateslib.instruments.sensitivities import Sensitivities +from rateslib.instruments.utils import ( _composit_fixings_table, _get, _get_curves_fx_and_base_maybe_from_solver, _update_not_noinput, ) -from rateslib.instruments.rates_derivatives import BaseDerivative from rateslib.legs import ( FixedLeg, FixedLegMtm, @@ -35,6 +35,9 @@ # This code cannot be installed or executed on a corporate computer without a paid licence extension # Contact info at rateslib.com if this code is observed outside its intended sphere of use. +if TYPE_CHECKING: + from rateslib.typing import DualTypes + class FXExchange(Sensitivities, BaseMixin): """ diff --git a/python/rateslib/instruments/rates_derivatives.py b/python/rateslib/instruments/rates/single_currency.py similarity index 51% rename from python/rateslib/instruments/rates_derivatives.py rename to python/rateslib/instruments/rates/single_currency.py index 273a1a77..5b5e859e 100644 --- a/python/rateslib/instruments/rates_derivatives.py +++ b/python/rateslib/instruments/rates/single_currency.py @@ -1,36 +1,26 @@ from __future__ import annotations -from abc import ABCMeta, abstractmethod from datetime import datetime +from typing import TYPE_CHECKING -from pandas import DataFrame, Series +from pandas import DataFrame from rateslib import defaults -from rateslib.calendars import CalInput -from rateslib.curves import Curve, LineCurve -from rateslib.default import NoInput -from rateslib.dual import DualTypes -from rateslib.fx import FXForwards, FXRates -from rateslib.instruments.inst_core import ( - BaseMixin, - Sensitivities, +from rateslib.curves import Curve +from rateslib.default import NoInput, _drb +from rateslib.dual.utils import _dual_float +from rateslib.instruments.base import BaseDerivative +from rateslib.instruments.utils import ( _composit_fixings_table, _get, _get_curves_fx_and_base_maybe_from_solver, - _inherit_or_negate, - _push, _update_not_noinput, - _update_with_defaults, ) from rateslib.legs import ( - CreditPremiumLeg, - CreditProtectionLeg, FixedLeg, FloatLeg, - IndexFixedLeg, ZeroFixedLeg, ZeroFloatLeg, - ZeroIndexLeg, ) from rateslib.periods import ( _disc_required_maybe_from_curve, @@ -38,7 +28,21 @@ _maybe_local, _trim_df_by_index, ) -from rateslib.solver import Solver + +if TYPE_CHECKING: + from typing import Any, NoReturn + + from rateslib.typing import ( + FX_, + NPV, + CurveOption_, + Curves_, + DualTypes, + FixedPeriod, + FixingsRates, + FloatPeriod, + Solver_, + ) # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. @@ -46,203 +50,6 @@ # Contact info at rateslib.com if this code is observed outside its intended sphere of use. -class BaseDerivative(Sensitivities, BaseMixin, metaclass=ABCMeta): - """ - Abstract base class with common parameters for many *Derivative* subclasses. - - Parameters - ---------- - effective : datetime - The adjusted or unadjusted effective date. - termination : datetime or str - The adjusted or unadjusted termination date. If a string, then a tenor must be - given expressed in days (`"D"`), months (`"M"`) or years (`"Y"`), e.g. `"48M"`. - frequency : str in {"M", "B", "Q", "T", "S", "A", "Z"}, optional - The frequency of the schedule. - stub : str combining {"SHORT", "LONG"} with {"FRONT", "BACK"}, optional - The stub type to enact on the swap. Can provide two types, for - example "SHORTFRONTLONGBACK". - front_stub : datetime, optional - An adjusted or unadjusted date for the first stub period. - back_stub : datetime, optional - An adjusted or unadjusted date for the back stub period. - See notes for combining ``stub``, ``front_stub`` and ``back_stub`` - and any automatic stub inference. - roll : int in [1, 31] or str in {"eom", "imm", "som"}, optional - The roll day of the schedule. Inferred if not given. - eom : bool, optional - Use an end of month preference rather than regular rolls for inference. Set by - default. Not required if ``roll`` is specified. - modifier : str, optional - The modification rule, in {"F", "MF", "P", "MP"} - calendar : calendar or str, optional - The holiday calendar object to use. If str, looks up named calendar from - static data. - payment_lag : int, optional - The number of business days to lag payments by. - notional : float, optional - The leg notional, which is applied to each period. - amortization: float, optional - The amount by which to adjust the notional each successive period. Should have - sign equal to that of notional if the notional is to reduce towards zero. - convention: str, optional - The day count convention applied to calculations of period accrual dates. - See :meth:`~rateslib.calendars.dcf`. - leg2_kwargs: Any - All ``leg2`` arguments can be similarly input as above, e.g. ``leg2_frequency``. - If **not** given, any ``leg2`` - argument inherits its value from the ``leg1`` arguments, except in the case of - ``notional`` and ``amortization`` where ``leg2`` inherits the negated value. - curves : Curve, LineCurve, str or list of such, optional - A single :class:`~rateslib.curves.Curve`, - :class:`~rateslib.curves.LineCurve` or id or a - list of such. A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg1``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. - - Forecasting :class:`~rateslib.curves.Curve` or - :class:`~rateslib.curves.LineCurve` for ``leg2``. - - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. - spec : str, optional - An identifier to pre-populate many field with conventional values. See - :ref:`here` for more info and available values. - - Attributes - ---------- - effective : datetime - termination : datetime - frequency : str - stub : str - front_stub : datetime - back_stub : datetime - roll : str, int - eom : bool - modifier : str - calendar : Calendar - payment_lag : int - notional : float - amortization : float - convention : str - leg2_effective : datetime - leg2_termination : datetime - leg2_frequency : str - leg2_stub : str - leg2_front_stub : datetime - leg2_back_stub : datetime - leg2_roll : str, int - leg2_eom : bool - leg2_modifier : str - leg2_calendar : Calendar - leg2_payment_lag : int - leg2_notional : float - leg2_amortization : float - leg2_convention : str - """ - - @abstractmethod - def __init__( - self, - effective: datetime | NoInput = NoInput(0), - termination: datetime | str | NoInput = NoInput(0), - frequency: int | NoInput = NoInput(0), - stub: str | NoInput = NoInput(0), - front_stub: datetime | NoInput = NoInput(0), - back_stub: datetime | NoInput = NoInput(0), - roll: str | int | NoInput = NoInput(0), - eom: bool | NoInput = NoInput(0), - modifier: str | NoInput = NoInput(0), - calendar: CalInput = NoInput(0), - payment_lag: int | NoInput = NoInput(0), - notional: float | NoInput = NoInput(0), - currency: str | NoInput = NoInput(0), - amortization: float | NoInput = NoInput(0), - convention: str | NoInput = NoInput(0), - leg2_effective: datetime | NoInput = NoInput(1), - leg2_termination: datetime | str | NoInput = NoInput(1), - leg2_frequency: int | NoInput = NoInput(1), - leg2_stub: str | NoInput = NoInput(1), - leg2_front_stub: datetime | NoInput = NoInput(1), - leg2_back_stub: datetime | NoInput = NoInput(1), - leg2_roll: str | int | NoInput = NoInput(1), - leg2_eom: bool | NoInput = NoInput(1), - leg2_modifier: str | NoInput = NoInput(1), - leg2_calendar: CalInput = NoInput(1), - leg2_payment_lag: int | NoInput = NoInput(1), - leg2_notional: float | NoInput = NoInput(-1), - leg2_currency: str | NoInput = NoInput(1), - leg2_amortization: float | NoInput = NoInput(-1), - leg2_convention: str | NoInput = NoInput(1), - curves: list | str | Curve | NoInput = NoInput(0), - spec: str | NoInput = NoInput(0), - ): - self.kwargs = dict( - effective=effective, - termination=termination, - frequency=frequency, - stub=stub, - front_stub=front_stub, - back_stub=back_stub, - roll=roll, - eom=eom, - modifier=modifier, - calendar=calendar, - payment_lag=payment_lag, - notional=notional, - currency=currency, - amortization=amortization, - convention=convention, - leg2_effective=leg2_effective, - leg2_termination=leg2_termination, - leg2_frequency=leg2_frequency, - leg2_stub=leg2_stub, - leg2_front_stub=leg2_front_stub, - leg2_back_stub=leg2_back_stub, - leg2_roll=leg2_roll, - leg2_eom=leg2_eom, - leg2_modifier=leg2_modifier, - leg2_calendar=leg2_calendar, - leg2_payment_lag=leg2_payment_lag, - leg2_notional=leg2_notional, - leg2_currency=leg2_currency, - leg2_amortization=leg2_amortization, - leg2_convention=leg2_convention, - ) - self.kwargs = _push(spec, self.kwargs) - # set some defaults if missing - self.kwargs["notional"] = ( - defaults.notional - if self.kwargs["notional"] is NoInput.blank - else self.kwargs["notional"] - ) - if self.kwargs["payment_lag"] is NoInput.blank: - self.kwargs["payment_lag"] = defaults.payment_lag_specific[type(self).__name__] - self.kwargs = _inherit_or_negate(self.kwargs) # inherit or negate the complete arg list - - self.curves = curves - self.spec = spec - - @abstractmethod - def _set_pricing_mid(self, *args, **kwargs): # pragma: no cover - pass - - def delta(self, *args, **kwargs): - """ - Calculate the delta of the *Instrument*. - - For arguments see :meth:`Sensitivities.delta()`. - """ - return super().delta(*args, **kwargs) - - def gamma(self, *args, **kwargs): - """ - Calculate the gamma of the *Instrument*. - - For arguments see :meth:`Sensitivities.gamma()`. - """ - return super().gamma(*args, **kwargs) - - class IRS(BaseDerivative): """ Create an interest rate swap composing a :class:`~rateslib.legs.FixedLeg` @@ -363,17 +170,20 @@ class IRS(BaseDerivative): _fixed_rate_mixin = True _leg2_float_spread_mixin = True + leg1: FixedLeg + leg2: FloatLeg + def __init__( self, - *args, - fixed_rate: float | NoInput = NoInput(0), - leg2_float_spread: float | NoInput = NoInput(0), + *args: Any, + fixed_rate: DualTypes | NoInput = NoInput(0), + leg2_float_spread: DualTypes | NoInput = NoInput(0), leg2_spread_compound_method: str | NoInput = NoInput(0), - leg2_fixings: float | list | Series | NoInput = NoInput(0), + leg2_fixings: FixingsRates = NoInput(0), # type: ignore[type-var] leg2_fixing_method: str | NoInput = NoInput(0), leg2_method_param: int | NoInput = NoInput(0), - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(*args, **kwargs) user_kwargs = dict( fixed_rate=fixed_rate, @@ -383,7 +193,7 @@ def __init__( leg2_fixing_method=leg2_fixing_method, leg2_method_param=leg2_method_param, ) - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + self.kwargs: dict[str, Any] = _update_not_noinput(self.kwargs, user_kwargs) self._fixed_rate = fixed_rate self._leg2_float_spread = leg2_float_spread @@ -392,16 +202,16 @@ def __init__( def _set_pricing_mid( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - ): + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + ) -> None: # the test for an unpriced IRS is that its fixed rate is not set. - if self.fixed_rate is NoInput.blank: + if isinstance(self.fixed_rate, NoInput): # set a fixed rate for the purpose of generic methods NPV will be zero. mid_market_rate = self.rate(curves, solver) - self.leg1.fixed_rate = float(mid_market_rate) + self.leg1.fixed_rate = _dual_float(mid_market_rate) - def analytic_delta(self, *args, **kwargs): + def analytic_delta(self, *args: Any, **kwargs: Any) -> DualTypes: """ Return the analytic delta of a leg of the derivative object. @@ -411,12 +221,12 @@ def analytic_delta(self, *args, **kwargs): def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ): + ) -> NPV: """ Return the NPV of the derivative by summing legs. @@ -427,11 +237,11 @@ def npv( def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return the mid-market rate of the IRS. @@ -461,7 +271,7 @@ def rate( The arguments ``fx`` and ``base`` are unused by single currency derivatives rates calculations. """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -469,18 +279,18 @@ def rate( base, self.leg1.currency, ) - leg2_npv = self.leg2.npv(curves[2], curves[3]) - return self.leg1._spread(-leg2_npv, curves[0], curves[1]) / 100 + leg2_npv: DualTypes = self.leg2.npv(curves_[2], curves_[3], local=False) # type: ignore[assignment] + return self.leg1._spread(-leg2_npv, curves_[0], curves_[1]) / 100 # leg1_analytic_delta = self.leg1.analytic_delta(curves[0], curves[1]) # return leg2_npv / (leg1_analytic_delta * 100) def cashflows( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DataFrame: """ Return the properties of all legs used in calculating cashflows. @@ -495,11 +305,11 @@ def cashflows( def spread( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return the mid-market float spread (bps) required to equate to the fixed rate. @@ -571,9 +381,9 @@ def spread( to ``irs.spread`` is different to the previous call, albeit the difference is 1/10000th of a basis point. """ - irs_npv = self.npv(curves, solver) - specified_spd = 0 if self.leg2.float_spread is NoInput(0) else self.leg2.float_spread - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + irs_npv: DualTypes = self.npv(curves, solver, local=False) # type: ignore[assignment] + specified_spd: DualTypes = _drb(0.0, self.leg2.float_spread) + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -581,15 +391,15 @@ def spread( base, self.leg1.currency, ) - return self.leg2._spread(-irs_npv, curves[2], curves[3]) + specified_spd + return self.leg2._spread(-irs_npv, curves_[2], curves_[3]) + specified_spd # leg2_analytic_delta = self.leg2.analytic_delta(curves[2], curves[3]) # return irs_npv / leg2_analytic_delta + specified_spd def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -625,7 +435,7 @@ def fixings_table( ------- DataFrame """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -634,7 +444,7 @@ def fixings_table( self.leg1.currency, ) return self.leg2.fixings_table( - curve=curves[2], approximate=approximate, disc_curve=curves[3], right=right + curve=curves_[2], approximate=approximate, disc_curve=curves_[3], right=right ) @@ -717,33 +527,36 @@ class STIRFuture(IRS): _fixed_rate_mixin = True _leg2_float_spread_mixin = True + leg1: FixedLeg + leg2: FloatLeg + def __init__( self, - *args, + *args: Any, price: float | NoInput = NoInput(0), contracts: int = 1, bp_value: float | NoInput = NoInput(0), nominal: float | NoInput = NoInput(0), leg2_float_spread: float | NoInput = NoInput(0), leg2_spread_compound_method: str | NoInput = NoInput(0), - leg2_fixings: float | list | Series | NoInput = NoInput(0), + leg2_fixings: FixingsRates = NoInput(0), leg2_fixing_method: str | NoInput = NoInput(0), leg2_method_param: int | NoInput = NoInput(0), - **kwargs, - ): - nominal = defaults.notional if nominal is NoInput.blank else nominal + **kwargs: Any, + ) -> None: + nominal_: float = _drb(defaults.notional, nominal) # TODO this overwrite breaks positional arguments - kwargs["notional"] = nominal * contracts * -1.0 + kwargs["notional"] = nominal_ * contracts * -1.0 super(IRS, self).__init__(*args, **kwargs) # call BaseDerivative.__init__() user_kwargs = dict( price=price, - fixed_rate=NoInput(0) if price is NoInput.blank else (100 - price), + fixed_rate=NoInput(0) if isinstance(price, NoInput) else (100 - price), leg2_float_spread=leg2_float_spread, leg2_spread_compound_method=leg2_spread_compound_method, leg2_fixings=leg2_fixings, leg2_fixing_method=leg2_fixing_method, leg2_method_param=leg2_method_param, - nominal=nominal, + nominal=nominal_, bp_value=bp_value, contracts=contracts, ) @@ -752,18 +565,18 @@ def __init__( self._fixed_rate = self.kwargs["fixed_rate"] self._leg2_float_spread = leg2_float_spread self.leg1 = FixedLeg( - **_get(self.kwargs, leg=1, filter=["price", "nominal", "bp_value", "contracts"]), + **_get(self.kwargs, leg=1, filter=("price", "nominal", "bp_value", "contracts")), ) self.leg2 = FloatLeg(**_get(self.kwargs, leg=2)) def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ): + ) -> NPV: """ Return the NPV of the derivative by summing legs. @@ -771,22 +584,24 @@ def npv( """ # the test for an unpriced IRS is that its fixed rate is not set. mid_price = self.rate(curves, solver, fx, base, metric="price") - if self.fixed_rate is NoInput.blank: + if isinstance(self.fixed_rate, NoInput): # set a fixed rate for the purpose of generic methods NPV will be zero. - self.leg1.fixed_rate = float(100 - mid_price) - - traded_price = 100 - self.leg1.fixed_rate + mid_rate = _dual_float(100 - mid_price) + self.leg1.fixed_rate = mid_rate + traded_price: DualTypes = 100.0 - mid_rate + else: + traded_price = 100 - self.fixed_rate _ = (mid_price - traded_price) * 100 * self.kwargs["contracts"] * self.kwargs["bp_value"] return _maybe_local(_, local, self.kwargs["currency"], fx, base) def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), metric: str = "rate", - ): + ) -> DualTypes: """ Return the mid-market rate of the IRS. @@ -818,7 +633,7 @@ def rate( The arguments ``fx`` and ``base`` are unused by single currency derivatives rates calculations. """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -826,39 +641,40 @@ def rate( base, self.leg1.currency, ) - leg2_npv = self.leg2.npv(curves[2], curves[3]) + leg2_npv: DualTypes = self.leg2.npv(curves_[2], curves_[3], local=False) # type: ignore[assignment] - _ = self.leg1._spread(-leg2_npv, curves[0], curves[1]) / 100 + ret: DualTypes = self.leg1._spread(-leg2_npv, curves_[0], curves_[1]) / 100 if metric.lower() == "rate": - return _ + return ret elif metric.lower() == "price": - return 100 - _ + return 100 - ret else: raise ValueError("`metric` must be in {'price', 'rate'}.") - def analytic_delta( + def analytic_delta( # type: ignore[override] self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DualTypes: """ Return the analytic delta of the *STIRFuture*. See :meth:`BasePeriod.analytic_delta()`. For *STIRFuture* this method requires no arguments. """ - fx, base = _get_fx_and_base(self.kwargs["currency"], fx, base) - return fx * (-1.0 * self.kwargs["contracts"] * self.kwargs["bp_value"]) + fx_, base_ = _get_fx_and_base(self.kwargs["currency"], fx, base) + _: DualTypes = fx_ * (-1.0 * self.kwargs["contracts"] * self.kwargs["bp_value"]) + return _ def cashflows( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DataFrame: return DataFrame.from_records( [ { @@ -869,28 +685,28 @@ def cashflows( defaults.headers["a_acc_end"]: self.leg1.schedule.termination, defaults.headers["payment"]: None, defaults.headers["convention"]: "Exchange", - defaults.headers["dcf"]: float(self.leg1.notional) + defaults.headers["dcf"]: _dual_float(self.leg1.notional) / self.kwargs["nominal"] * self.kwargs["bp_value"] / 100.0, - defaults.headers["notional"]: float(self.leg1.notional), + defaults.headers["notional"]: _dual_float(self.leg1.notional), defaults.headers["df"]: 1.0, defaults.headers["collateral"]: self.leg1.currency.lower(), }, ], ) - def spread(self): + def spread(self) -> NoReturn: # type: ignore[override] """ Not implemented for *STIRFuture*. """ - return NotImplementedError() + raise NotImplementedError("`spread` method is not implemented on STIRFuture.") def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -926,7 +742,7 @@ def fixings_table( ------- DataFrame """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -934,11 +750,14 @@ def fixings_table( NoInput(0), self.leg2.currency, ) - risk = -1.0 * self.kwargs["contracts"] * self.kwargs["bp_value"] - df = self.leg2.fixings_table(curve=curves[2], approximate=approximate, disc_curve=curves[3]) + risk: float = -1.0 * self.kwargs["contracts"] * self.kwargs["bp_value"] + df = self.leg2.fixings_table( + curve=curves_[2], approximate=approximate, disc_curve=curves_[3] + ) - total_risk = df[(curves[2].id, "risk")].sum() - df[[(curves[2].id, "notional"), (curves[2].id, "risk")]] *= risk / total_risk + # TODO: handle curves as dict. "id" is not available this is typing mismatch + total_risk = df[(curves_[2].id, "risk")].sum() # type: ignore[union-attr] + df[[(curves_[2].id, "notional"), (curves_[2].id, "risk")]] *= risk / total_risk # type: ignore[union-attr] return _trim_df_by_index(df, NoInput(0), right) @@ -948,38 +767,38 @@ def fixings_table( # Contact info at rateslib.com if this code is observed outside its intended sphere of use. -class IIRS(BaseDerivative): +class ZCS(BaseDerivative): """ - Create an indexed interest rate swap (IIRS) composing an - :class:`~rateslib.legs.IndexFixedLeg` and a :class:`~rateslib.legs.FloatLeg`. + Create a zero coupon swap (ZCS) composing a :class:`~rateslib.legs.ZeroFixedLeg` + and a :class:`~rateslib.legs.ZeroFloatLeg`. Parameters ---------- args : dict - Required positional args to :class:`BaseDerivative`. + Required positional args to :class:`BaseDerivative`. fixed_rate : float or None - The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` - will be set to mid-market when curves are provided. - index_base : float or None, optional - The base index applied to all periods. - index_fixings : float, or Series, optional - If a float scalar, will be applied as the index fixing for the first - period. - If a list of *n* fixings will be used as the index fixings for the first *n* - periods. - If a datetime indexed ``Series`` will use the fixings that are available in - that object, and derive the rest from the ``curve``. - index_method : str - Whether the indexing uses a daily measure for settlement or the most recently - monthly data taken from the first day of month. - index_lag : int, optional - The number of months by which the index value is lagged. Used to ensure - consistency between curves and forecast values. Defined by default. - notional_exchange : bool, optional - Whether the legs include final notional exchanges and interim - amortization notional exchanges. + The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` + will be set to mid-market when curves are provided. + leg2_float_spread : float, optional + The spread applied to the :class:`~rateslib.legs.FloatLeg`. Can be set to + `None` and designated + later, perhaps after a mid-market spread for all periods has been calculated. + leg2_spread_compound_method : str, optional + The method to use for adding a floating spread to compounded rates. Available + options are `{"none_simple", "isda_compounding", "isda_flat_compounding"}`. + leg2_fixings : float, list, or Series optional + If a float scalar, will be applied as the determined fixing for the first + period. If a list of *n* fixings will be used as the fixings for the first *n* + periods. If any sublist of length *m* is given, is used as the first *m* RFR + fixings for that :class:`~rateslib.periods.FloatPeriod`. If a datetime + indexed ``Series`` will use the fixings that are available in that object, + and derive the rest from the ``curve``. + leg2_fixing_method : str, optional + The method by which floating rates are determined, set by default. See notes. + leg2_method_param : int, optional + A parameter that is used for the various ``fixing_method`` s. See notes. kwargs : dict - Required keyword arguments to :class:`BaseDerivative`. + Required keyword arguments to :class:`BaseDerivative`. Examples -------- @@ -987,214 +806,142 @@ class IIRS(BaseDerivative): .. ipython:: python - usd = Curve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2027, 1, 1): 0.85, - dt(2032, 1, 1): 0.65, - }, - id="usd", - ) - us_cpi = IndexCurve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2027, 1, 1): 0.85, - dt(2032, 1, 1): 0.70, - }, - id="us_cpi", - index_base=100, - index_lag=3, - ) - - Create the IIRS, and demonstrate the :meth:`~rateslib.instruments.IIRS.rate`, and - :meth:`~rateslib.instruments.IIRS.npv`. + usd = Curve( + nodes={ + dt(2022, 1, 1): 1.0, + dt(2027, 1, 1): 0.85, + dt(2032, 1, 1): 0.70, + }, + id="usd" + ) + + Create the ZCS, and demonstrate the :meth:`~rateslib.instruments.ZCS.rate`, + :meth:`~rateslib.instruments.ZCS.npv`, + :meth:`~rateslib.instruments.ZCS.analytic_delta`, and .. ipython:: python - iirs = IIRS( - effective=dt(2022, 1, 1), - termination="4Y", - frequency="A", - calendar="nyc", - currency="usd", - fixed_rate=2.05, - convention="1+", - notional=100e6, - index_base=100.0, - index_method="monthly", - index_lag=3, - notional_exchange=True, - leg2_convention="Act360", - curves=["us_cpi", "usd", "usd", "usd"], - ) - iirs.rate(curves=[us_cpi, usd, usd, usd]) - iirs.npv(curves=[us_cpi, usd, usd, usd]) - - A DataFrame of :meth:`~rateslib.instruments.IIRS.cashflows`. + zcs = ZCS( + effective=dt(2022, 1, 1), + termination="10Y", + frequency="Q", + calendar="nyc", + currency="usd", + fixed_rate=4.0, + convention="Act360", + notional=100e6, + curves=["usd"], + ) + zcs.rate(curves=usd) + zcs.npv(curves=usd) + zcs.analytic_delta(curve=usd) + + A DataFrame of :meth:`~rateslib.instruments.ZCS.cashflows`. .. ipython:: python - iirs.cashflows(curves=[us_cpi, usd, usd, usd]) + zcs.cashflows(curves=usd) - For accurate sensitivity calculations; :meth:`~rateslib.instruments.IIRS.delta` - and :meth:`~rateslib.instruments.IIRS.gamma`, construct a curve model. + For accurate sensitivity calculations; :meth:`~rateslib.instruments.ZCS.delta` + and :meth:`~rateslib.instruments.ZCS.gamma`, construct a curve model. .. ipython:: python - sofr_kws = dict( - effective=dt(2022, 1, 1), - frequency="A", - convention="Act360", - calendar="nyc", - currency="usd", - curves=["usd"] - ) - cpi_kws = dict( - effective=dt(2022, 1, 1), - frequency="A", - convention="1+", - calendar="nyc", - leg2_index_method="monthly", - currency="usd", - curves=["usd", "usd", "us_cpi", "usd"] - ) - instruments = [ - IRS(termination="5Y", **sofr_kws), - IRS(termination="10Y", **sofr_kws), - ZCIS(termination="5Y", **cpi_kws), - ZCIS(termination="10Y", **cpi_kws), - ] - solver = Solver( - curves=[usd, us_cpi], - instruments=instruments, - s=[3.40, 3.60, 2.2, 2.05], - instrument_labels=["5Y", "10Y", "5Yi", "10Yi"], - id="us", - ) - iirs.delta(solver=solver) - iirs.gamma(solver=solver) + sofr_kws = dict( + effective=dt(2022, 1, 1), + frequency="A", + convention="Act360", + calendar="nyc", + currency="usd", + curves=["usd"] + ) + instruments = [ + IRS(termination="5Y", **sofr_kws), + IRS(termination="10Y", **sofr_kws), + ] + solver = Solver( + curves=[usd], + instruments=instruments, + s=[3.40, 3.60], + instrument_labels=["5Y", "10Y"], + id="sofr", + ) + zcs.delta(solver=solver) + zcs.gamma(solver=solver) """ _fixed_rate_mixin = True - _index_base_mixin = True _leg2_float_spread_mixin = True + leg1: ZeroFixedLeg + leg2: ZeroFloatLeg + def __init__( self, - *args, + *args: Any, fixed_rate: float | NoInput = NoInput(0), - index_base: float | Series | NoInput = NoInput(0), - index_fixings: float | Series | NoInput = NoInput(0), - index_method: str | NoInput = NoInput(0), - index_lag: int | NoInput = NoInput(0), - notional_exchange: bool | NoInput = False, - payment_lag_exchange: int | NoInput = NoInput(0), leg2_float_spread: float | NoInput = NoInput(0), - leg2_fixings: float | list | NoInput = NoInput(0), + leg2_spread_compound_method: str | NoInput = NoInput(0), + leg2_fixings: FixingsRates = NoInput(0), leg2_fixing_method: str | NoInput = NoInput(0), leg2_method_param: int | NoInput = NoInput(0), - leg2_spread_compound_method: str | NoInput = NoInput(0), - leg2_payment_lag_exchange: int | NoInput = NoInput(1), - **kwargs, + **kwargs: Any, ): super().__init__(*args, **kwargs) - if leg2_payment_lag_exchange is NoInput.inherit: - leg2_payment_lag_exchange = payment_lag_exchange user_kwargs = dict( fixed_rate=fixed_rate, - index_base=index_base, - index_fixings=index_fixings, - index_method=index_method, - index_lag=index_lag, - initial_exchange=False, - final_exchange=notional_exchange, - payment_lag_exchange=payment_lag_exchange, leg2_float_spread=leg2_float_spread, leg2_spread_compound_method=leg2_spread_compound_method, leg2_fixings=leg2_fixings, leg2_fixing_method=leg2_fixing_method, leg2_method_param=leg2_method_param, - leg2_payment_lag_exchange=leg2_payment_lag_exchange, - leg2_initial_exchange=False, - leg2_final_exchange=notional_exchange, ) - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + self.kwargs: dict[str, Any] = _update_not_noinput(self.kwargs, user_kwargs) + self._fixed_rate = fixed_rate + self._leg2_float_spread = leg2_float_spread + self.leg1 = ZeroFixedLeg(**_get(self.kwargs, leg=1)) + self.leg2 = ZeroFloatLeg(**_get(self.kwargs, leg=2)) - self._index_base = self.kwargs["index_base"] - self._fixed_rate = self.kwargs["fixed_rate"] - self.leg1 = IndexFixedLeg(**_get(self.kwargs, leg=1)) - self.leg2 = FloatLeg(**_get(self.kwargs, leg=2)) + def analytic_delta(self, *args: Any, **kwargs: Any) -> DualTypes: + """ + Return the analytic delta of a leg of the derivative object. - def _set_pricing_mid( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - ): - mid_market_rate = self.rate(curves, solver) - self.leg1.fixed_rate = float(mid_market_rate) + See + :meth:`BaseDerivative.analytic_delta`. + """ + return super().analytic_delta(*args, **kwargs) + + def _set_pricing_mid(self, curves: Curves_, solver: Solver_) -> None: + if isinstance(self.fixed_rate, NoInput): + # set a fixed rate for the purpose of pricing NPV, which should be zero. + mid_market_rate = self.rate(curves, solver) + self.leg1.fixed_rate = _dual_float(mid_market_rate) def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ): - curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - if self.index_base is NoInput.blank: - # must forecast for the leg - self.leg1.index_base = curves[0].index_value( - self.leg1.schedule.effective, - self.leg1.index_method, - ) - if self.fixed_rate is NoInput.blank: - # set a fixed rate for the purpose of pricing NPV, which should be zero. - self._set_pricing_mid(curves, solver) - return super().npv(curves, solver, fx_, base_, local) + ) -> NPV: + """ + Return the NPV of the derivative by summing legs. - def cashflows( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - curves, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - if self.index_base is NoInput.blank: - # must forecast for the leg - self.leg1.index_base = curves[0].index_value( - self.leg1.schedule.effective, - self.leg1.index_method, - ) - if self.fixed_rate is NoInput.blank: - # set a fixed rate for the purpose of pricing NPV, which should be zero. - self._set_pricing_mid(curves, solver) - return super().cashflows(curves, solver, fx_, base_) + See :meth:`BaseDerivative.npv`. + """ + self._set_pricing_mid(curves, solver) + return super().npv(curves, solver, fx, base, local) def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DualTypes: """ - Return the mid-market rate of the IRS. + Return the mid-market rate of the ZCS. Parameters ---------- @@ -1221,8 +968,16 @@ def rate( ----- The arguments ``fx`` and ``base`` are unused by single currency derivatives rates calculations. + + The *'irr'* ``fixed_rate`` defines a cashflow by: + + .. math:: + + -notional * ((1 + irr / f)^{f \\times dcf} - 1) + + where :math:`f` is associated with the compounding frequency. """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -1230,31 +985,36 @@ def rate( base, self.leg1.currency, ) - if self.index_base is NoInput.blank: - # must forecast for the leg - self.leg1.index_base = curves[0].index_value( - self.leg1.schedule.effective, - self.leg1.index_method, - ) - leg2_npv = self.leg2.npv(curves[2], curves[3]) - - if self.fixed_rate is NoInput.blank: - self.leg1.fixed_rate = 0.0 - _existing = self.leg1.fixed_rate - leg1_npv = self.leg1.npv(curves[0], curves[1]) - - _ = self.leg1._spread(-leg2_npv - leg1_npv, curves[0], curves[1]) / 100 - return _ + _existing + leg2_npv: DualTypes = self.leg2.npv(curves_[2], curves_[3], local=False) # type: ignore[assignment] + ret: DualTypes = self.leg1._spread(-leg2_npv, curves_[0], curves_[1]) / 100 + return ret - def spread( + def cashflows( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DataFrame: """ - Return the mid-market float spread (bps) required to equate to the fixed rate. + Return the properties of all legs used in calculating cashflows. + + See :meth:`BaseDerivative.cashflows`. + """ + self._set_pricing_mid(curves, solver) + return super().cashflows(curves, solver, fx, base) + + def fixings_table( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + approximate: bool = False, + right: datetime | NoInput = NoInput(0), + ) -> DataFrame: + """ + Return a DataFrame of fixing exposures on the :class:`~rateslib.legs.ZeroFloatLeg`. Parameters ---------- @@ -1264,6 +1024,7 @@ def spread( - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - Discounting :class:`~rateslib.curves.Curve` for both legs. + solver : Solver, optional The numerical :class:`~rateslib.solver.Solver` that constructs :class:`~rateslib.curves.Curve` from calibrating instruments. @@ -1273,633 +1034,33 @@ def spread( The arguments ``fx`` and ``base`` are unused by single currency derivatives rates calculations. + approximate : bool, optional + Perform a calculation that is broadly 10x faster but potentially loses + precision upto 0.1%. + right : datetime, optional + Only calculate fixing exposures upto and including this date. + Returns ------- - float, Dual or Dual2 - - Notes - ----- - If the :class:`IRS` is specified without a ``fixed_rate`` this should always - return the current ``leg2_float_spread`` value or zero since the fixed rate used - for calculation is the implied rate including the current ``leg2_float_spread`` - parameter. + DataFrame + """ + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( + self.curves, + solver, + curves, + NoInput(0), + NoInput(0), + self.leg1.currency, + ) + return self.leg2.fixings_table( + curve=curves_[2], approximate=approximate, disc_curve=curves_[3], right=right + ) - Examples - -------- - For the most common parameters this method will be exact. - .. ipython:: python - - irs.spread(curves=usd) - irs.leg2_float_spread = -6.948753 - irs.npv(curves=usd) - - When a non-linear spread compound method is used for float RFR legs this is - an approximation, via second order Taylor expansion. - - .. ipython:: python - - irs = IRS( - effective=dt(2022, 2, 15), - termination=dt(2022, 8, 15), - frequency="Q", - convention="30e360", - leg2_convention="Act360", - leg2_fixing_method="rfr_payment_delay", - leg2_spread_compound_method="isda_compounding", - payment_lag=2, - fixed_rate=2.50, - leg2_float_spread=0, - notional=50000000, - currency="usd", - ) - irs.spread(curves=usd) - irs.leg2_float_spread = -111.060143 - irs.npv(curves=usd) - irs.spread(curves=usd) - - The ``leg2_float_spread`` is determined through NPV differences. If the difference - is small since the defined spread is already quite close to the solution the - approximation is much more accurate. This is shown above where the second call - to ``irs.spread`` is different to the previous call, albeit the difference - is 1/10000th of a basis point. - """ - irs_npv = self.npv(curves, solver) - specified_spd = 0 if self.leg2.float_spread is NoInput.blank else self.leg2.float_spread - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - return self.leg2._spread(-irs_npv, curves[2], curves[3]) + specified_spd - - def fixings_table( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - approximate: bool = False, - right: datetime | NoInput = NoInput(0), - ) -> DataFrame: - """ - Return a DataFrame of fixing exposures on the :class:`~rateslib.legs.FloatLeg`. - - Parameters - ---------- - curves : Curve, str or list of such - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - - Discounting :class:`~rateslib.curves.Curve` for both legs. - - solver : Solver, optional - The numerical :class:`~rateslib.solver.Solver` that constructs - :class:`~rateslib.curves.Curve` from calibrating instruments. - - .. note:: - - The arguments ``fx`` and ``base`` are unused by single currency - derivatives rates calculations. - - approximate : bool, optional - Perform a calculation that is broadly 10x faster but potentially loses - precision upto 0.1%. - - Returns - ------- - DataFrame - """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - NoInput(0), - NoInput(0), - self.leg2.currency, - ) - df = self.leg2.fixings_table( - curve=curves[2], approximate=approximate, disc_curve=curves[3], right=right - ) - return df - - -class ZCS(BaseDerivative): - """ - Create a zero coupon swap (ZCS) composing a :class:`~rateslib.legs.ZeroFixedLeg` - and a :class:`~rateslib.legs.ZeroFloatLeg`. - - Parameters - ---------- - args : dict - Required positional args to :class:`BaseDerivative`. - fixed_rate : float or None - The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` - will be set to mid-market when curves are provided. - leg2_float_spread : float, optional - The spread applied to the :class:`~rateslib.legs.FloatLeg`. Can be set to - `None` and designated - later, perhaps after a mid-market spread for all periods has been calculated. - leg2_spread_compound_method : str, optional - The method to use for adding a floating spread to compounded rates. Available - options are `{"none_simple", "isda_compounding", "isda_flat_compounding"}`. - leg2_fixings : float, list, or Series optional - If a float scalar, will be applied as the determined fixing for the first - period. If a list of *n* fixings will be used as the fixings for the first *n* - periods. If any sublist of length *m* is given, is used as the first *m* RFR - fixings for that :class:`~rateslib.periods.FloatPeriod`. If a datetime - indexed ``Series`` will use the fixings that are available in that object, - and derive the rest from the ``curve``. - leg2_fixing_method : str, optional - The method by which floating rates are determined, set by default. See notes. - leg2_method_param : int, optional - A parameter that is used for the various ``fixing_method`` s. See notes. - kwargs : dict - Required keyword arguments to :class:`BaseDerivative`. - - Examples - -------- - Construct a curve to price the example. - - .. ipython:: python - - usd = Curve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2027, 1, 1): 0.85, - dt(2032, 1, 1): 0.70, - }, - id="usd" - ) - - Create the ZCS, and demonstrate the :meth:`~rateslib.instruments.ZCS.rate`, - :meth:`~rateslib.instruments.ZCS.npv`, - :meth:`~rateslib.instruments.ZCS.analytic_delta`, and - - .. ipython:: python - - zcs = ZCS( - effective=dt(2022, 1, 1), - termination="10Y", - frequency="Q", - calendar="nyc", - currency="usd", - fixed_rate=4.0, - convention="Act360", - notional=100e6, - curves=["usd"], - ) - zcs.rate(curves=usd) - zcs.npv(curves=usd) - zcs.analytic_delta(curve=usd) - - A DataFrame of :meth:`~rateslib.instruments.ZCS.cashflows`. - - .. ipython:: python - - zcs.cashflows(curves=usd) - - For accurate sensitivity calculations; :meth:`~rateslib.instruments.ZCS.delta` - and :meth:`~rateslib.instruments.ZCS.gamma`, construct a curve model. - - .. ipython:: python - - sofr_kws = dict( - effective=dt(2022, 1, 1), - frequency="A", - convention="Act360", - calendar="nyc", - currency="usd", - curves=["usd"] - ) - instruments = [ - IRS(termination="5Y", **sofr_kws), - IRS(termination="10Y", **sofr_kws), - ] - solver = Solver( - curves=[usd], - instruments=instruments, - s=[3.40, 3.60], - instrument_labels=["5Y", "10Y"], - id="sofr", - ) - zcs.delta(solver=solver) - zcs.gamma(solver=solver) - """ - - _fixed_rate_mixin = True - _leg2_float_spread_mixin = True - - def __init__( - self, - *args, - fixed_rate: float | NoInput = NoInput(0), - leg2_float_spread: float | NoInput = NoInput(0), - leg2_spread_compound_method: str | NoInput = NoInput(0), - leg2_fixings: float | list | Series | NoInput = NoInput(0), - leg2_fixing_method: str | NoInput = NoInput(0), - leg2_method_param: int | NoInput = NoInput(0), - **kwargs, - ): - super().__init__(*args, **kwargs) - user_kwargs = dict( - fixed_rate=fixed_rate, - leg2_float_spread=leg2_float_spread, - leg2_spread_compound_method=leg2_spread_compound_method, - leg2_fixings=leg2_fixings, - leg2_fixing_method=leg2_fixing_method, - leg2_method_param=leg2_method_param, - ) - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) - self._fixed_rate = fixed_rate - self._leg2_float_spread = leg2_float_spread - self.leg1 = ZeroFixedLeg(**_get(self.kwargs, leg=1)) - self.leg2 = ZeroFloatLeg(**_get(self.kwargs, leg=2)) - - def analytic_delta(self, *args, **kwargs): - """ - Return the analytic delta of a leg of the derivative object. - - See - :meth:`BaseDerivative.analytic_delta`. - """ - return super().analytic_delta(*args, **kwargs) - - def _set_pricing_mid(self, curves, solver): - if self.fixed_rate is NoInput.blank: - # set a fixed rate for the purpose of pricing NPV, which should be zero. - mid_market_rate = self.rate(curves, solver) - self.leg1.fixed_rate = float(mid_market_rate) - - def npv( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - ): - """ - Return the NPV of the derivative by summing legs. - - See :meth:`BaseDerivative.npv`. - """ - self._set_pricing_mid(curves, solver) - return super().npv(curves, solver, fx, base, local) - - def rate( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the mid-market rate of the ZCS. - - Parameters - ---------- - curves : Curve, str or list of such - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - - Discounting :class:`~rateslib.curves.Curve` for both legs. - solver : Solver, optional - The numerical :class:`~rateslib.solver.Solver` that - constructs :class:`~rateslib.curves.Curve` from calibrating instruments. - - .. note:: - - The arguments ``fx`` and ``base`` are unused by single currency - derivatives rates calculations. - - Returns - ------- - float, Dual or Dual2 - - Notes - ----- - The arguments ``fx`` and ``base`` are unused by single currency derivatives - rates calculations. - - The *'irr'* ``fixed_rate`` defines a cashflow by: - - .. math:: - - -notional * ((1 + irr / f)^{f \\times dcf} - 1) - - where :math:`f` is associated with the compounding frequency. - """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - leg2_npv = self.leg2.npv(curves[2], curves[3]) - _ = self.leg1._spread(-leg2_npv, curves[0], curves[1]) / 100 - return _ - - def cashflows( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the properties of all legs used in calculating cashflows. - - See :meth:`BaseDerivative.cashflows`. - """ - self._set_pricing_mid(curves, solver) - return super().cashflows(curves, solver, fx, base) - - def fixings_table( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - approximate: bool = False, - right: datetime | NoInput = NoInput(0), - ) -> DataFrame: - """ - Return a DataFrame of fixing exposures on the :class:`~rateslib.legs.ZeroFloatLeg`. - - Parameters - ---------- - curves : Curve, str or list of such - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - - Discounting :class:`~rateslib.curves.Curve` for both legs. - - solver : Solver, optional - The numerical :class:`~rateslib.solver.Solver` that constructs - :class:`~rateslib.curves.Curve` from calibrating instruments. - - .. note:: - - The arguments ``fx`` and ``base`` are unused by single currency - derivatives rates calculations. - - approximate : bool, optional - Perform a calculation that is broadly 10x faster but potentially loses - precision upto 0.1%. - right : datetime, optional - Only calculate fixing exposures upto and including this date. - - Returns - ------- - DataFrame - """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - NoInput(0), - NoInput(0), - self.leg1.currency, - ) - return self.leg2.fixings_table( - curve=curves[2], approximate=approximate, disc_curve=curves[3], right=right - ) - - -class ZCIS(BaseDerivative): - """ - Create a zero coupon index swap (ZCIS) composing an - :class:`~rateslib.legs.ZeroFixedLeg` - and a :class:`~rateslib.legs.ZeroIndexLeg`. - - Parameters - ---------- - args : dict - Required positional args to :class:`BaseDerivative`. - fixed_rate : float or None - The fixed rate applied to the :class:`~rateslib.legs.ZeroFixedLeg`. If `None` - will be set to mid-market when curves are provided. - leg2_index_base : float or None, optional - The base index applied to all periods. - leg2_index_fixings : float, or Series, optional - If a float scalar, will be applied as the index fixing for the first - period. - If a list of *n* fixings will be used as the index fixings for the first *n* - periods. - If a datetime indexed ``Series`` will use the fixings that are available in - that object, and derive the rest from the ``curve``. - leg2_index_method : str - Whether the indexing uses a daily measure for settlement or the most recently - monthly data taken from the first day of month. - leg2_index_lag : int, optional - The number of months by which the index value is lagged. Used to ensure - consistency between curves and forecast values. Defined by default. - kwargs : dict - Required keyword arguments to :class:`BaseDerivative`. - - Examples - -------- - Construct a curve to price the example. - - .. ipython:: python - - usd = Curve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2027, 1, 1): 0.85, - dt(2032, 1, 1): 0.65, - }, - id="usd", - ) - us_cpi = IndexCurve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2027, 1, 1): 0.85, - dt(2032, 1, 1): 0.70, - }, - id="us_cpi", - index_base=100, - index_lag=3, - ) - - Create the ZCIS, and demonstrate the :meth:`~rateslib.instruments.ZCIS.rate`, - :meth:`~rateslib.instruments.ZCIS.npv`, - :meth:`~rateslib.instruments.ZCIS.analytic_delta`, and - - .. ipython:: python - - zcis = ZCIS( - effective=dt(2022, 1, 1), - termination="10Y", - spec="usd_zcis", - fixed_rate=2.05, - notional=100e6, - leg2_index_base=100.0, - curves=["usd", "usd", "us_cpi", "usd"], - ) - zcis.rate(curves=[us_cpi, usd]) - zcis.npv(curves=[us_cpi, usd]) - zcis.analytic_delta(usd, usd) - - A DataFrame of :meth:`~rateslib.instruments.ZCIS.cashflows`. - - .. ipython:: python - - zcis.cashflows(curves=[us_cpi, usd]) - - For accurate sensitivity calculations; :meth:`~rateslib.instruments.ZCIS.delta` - and :meth:`~rateslib.instruments.ZCIS.gamma`, construct a curve model. - - .. ipython:: python - - instruments = [ - IRS(dt(2022, 1, 1), "5Y", spec="usd_irs", curves="usd"), - IRS(dt(2022, 1, 1), "10Y", spec="usd_irs", curves="usd"), - ZCIS(dt(2022, 1, 1), "5Y", spec="usd_zcis", curves=["us_cpi", "usd"]), - ZCIS(dt(2022, 1, 1), "10Y", spec="usd_zcis", curves=["us_cpi", "usd"]), - ] - solver = Solver( - curves=[usd, us_cpi], - instruments=instruments, - s=[3.40, 3.60, 2.2, 2.05], - instrument_labels=["5Y", "10Y", "5Yi", "10Yi"], - id="us", - ) - zcis.delta(solver=solver) - zcis.gamma(solver=solver) - """ - - _fixed_rate_mixin = True - _leg2_index_base_mixin = True - - def __init__( - self, - *args, - fixed_rate: float | NoInput = NoInput(0), - leg2_index_base: float | Series | NoInput = NoInput(0), - leg2_index_fixings: float | Series | NoInput = NoInput(0), - leg2_index_method: str | NoInput = NoInput(0), - leg2_index_lag: int | NoInput = NoInput(0), - **kwargs, - ): - super().__init__(*args, **kwargs) - user_kwargs = dict( - fixed_rate=fixed_rate, - leg2_index_base=leg2_index_base, - leg2_index_fixings=leg2_index_fixings, - leg2_index_lag=leg2_index_lag, - leg2_index_method=leg2_index_method, - ) - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) - self._fixed_rate = fixed_rate - self._leg2_index_base = leg2_index_base - self.leg1 = ZeroFixedLeg(**_get(self.kwargs, leg=1)) - self.leg2 = ZeroIndexLeg(**_get(self.kwargs, leg=2)) - - def _set_pricing_mid(self, curves, solver): - if self.fixed_rate is NoInput.blank: - # set a fixed rate for the purpose of pricing NPV, which should be zero. - mid_market_rate = self.rate(curves, solver) - self.leg1.fixed_rate = float(mid_market_rate) - - def cashflows( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - self._set_pricing_mid(curves, solver) - return super().cashflows(curves, solver, fx, base) - - def npv( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - ): - self._set_pricing_mid(curves, solver) - return super().npv(curves, solver, fx, base, local) - - def rate( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the mid-market IRR rate of the ZCIS. - - Parameters - ---------- - curves : Curve, str or list of such - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - - Discounting :class:`~rateslib.curves.Curve` for both legs. - solver : Solver, optional - The numerical :class:`~rateslib.solver.Solver` that - constructs :class:`~rateslib.curves.Curve` from calibrating instruments. - - .. note:: - - The arguments ``fx`` and ``base`` are unused by single currency - derivatives rates calculations. - - Returns - ------- - float, Dual or Dual2 - - Notes - ----- - The arguments ``fx`` and ``base`` are unused by single currency derivatives - rates calculations. - """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - if isinstance(self.leg2_index_base, NoInput): - # must forecast for the leg - forecast_value = curves[2].index_value( - self.leg2.schedule.effective, - self.leg2.index_method, - ) - if abs(forecast_value) < 1e-13: - raise ValueError( - "Forecasting the `index_base` for the ZCIS yielded 0.0, which is infeasible.\n" - "This might occur if the ZCIS starts in the past, or has a 'monthly' " - "`index_method` which uses the 1st day of the effective month, which is in the " - "past.\nA known `index_base` value should be input with the ZCIS " - "specification.", - ) - self.leg2.index_base = forecast_value - leg2_npv = self.leg2.npv(curves[2], curves[3]) - - return self.leg1._spread(-leg2_npv, curves[0], curves[1]) / 100 - - -# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International -# Commercial use of this code, and/or copying and redistribution is prohibited. -# This code cannot be installed or executed on a corporate computer without a paid licence extension -# Contact info at rateslib.com if this code is observed outside its intended sphere of use. +# Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International +# Commercial use of this code, and/or copying and redistribution is prohibited. +# This code cannot be installed or executed on a corporate computer without a paid licence extension +# Contact info at rateslib.com if this code is observed outside its intended sphere of use. class SBS(BaseDerivative): @@ -2059,21 +1220,24 @@ class SBS(BaseDerivative): _leg2_float_spread_mixin = True _rate_scalar = 100.0 + leg1: FloatLeg + leg2: FloatLeg + def __init__( self, - *args, + *args: Any, float_spread: float | NoInput = NoInput(0), spread_compound_method: str | NoInput = NoInput(0), - fixings: float | list | Series | NoInput = NoInput(0), + fixings: FixingsRates = NoInput(0), fixing_method: str | NoInput = NoInput(0), method_param: int | NoInput = NoInput(0), leg2_float_spread: float | NoInput = NoInput(0), leg2_spread_compound_method: str | NoInput = NoInput(0), - leg2_fixings: float | list | Series | NoInput = NoInput(0), + leg2_fixings: FixingsRates = NoInput(0), leg2_fixing_method: str | NoInput = NoInput(0), leg2_method_param: int | NoInput = NoInput(0), - **kwargs, - ): + **kwargs: Any, + ) -> None: super().__init__(*args, **kwargs) user_kwargs = dict( float_spread=float_spread, @@ -2087,19 +1251,19 @@ def __init__( leg2_fixing_method=leg2_fixing_method, leg2_method_param=leg2_method_param, ) - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + self.kwargs: dict[str, Any] = _update_not_noinput(self.kwargs, user_kwargs) self._float_spread = float_spread self._leg2_float_spread = leg2_float_spread self.leg1 = FloatLeg(**_get(self.kwargs, leg=1)) self.leg2 = FloatLeg(**_get(self.kwargs, leg=2)) - def _set_pricing_mid(self, curves, solver): - if self.float_spread is NoInput.blank and self.leg2_float_spread is NoInput.blank: + def _set_pricing_mid(self, curves: Curves_, solver: Solver_) -> None: + if isinstance(self.float_spread, NoInput) and isinstance(self.leg2_float_spread, NoInput): # set a pricing parameter for the purpose of pricing NPV at zero. rate = self.rate(curves, solver) - self.leg1.float_spread = float(rate) + self.leg1.float_spread = _dual_float(rate) - def analytic_delta(self, *args, **kwargs): + def analytic_delta(self, *args: Any, **kwargs: Any) -> DualTypes: """ Return the analytic delta of a leg of the derivative object. @@ -2109,11 +1273,11 @@ def analytic_delta(self, *args, **kwargs): def cashflows( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), - ): + ) -> DataFrame: """ Return the properties of all legs used in calculating cashflows. @@ -2124,12 +1288,12 @@ def cashflows( def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ): + ) -> NPV: """ Return the NPV of the derivative object by summing legs. @@ -2140,12 +1304,12 @@ def npv( def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), leg: int = 1, - ): + ) -> DualTypes: """ Return the mid-market float spread on the specified leg of the SBS. @@ -2168,8 +1332,8 @@ def rate( ------- float, Dual or Dual2 """ - core_npv = super().npv(curves, solver) - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + core_npv: DualTypes = super().npv(curves, solver, local=False) # type: ignore[assignment] + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -2178,12 +1342,13 @@ def rate( self.leg1.currency, ) if leg == 1: - leg_obj, args = self.leg1, (curves[0], curves[1]) + leg_obj, args = self.leg1, (curves_[0], curves_[1]) else: - leg_obj, args = self.leg2, (curves[2], curves[3]) + leg_obj, args = self.leg2, (curves_[2], curves_[3]) - specified_spd = 0 if leg_obj.float_spread is NoInput.blank else leg_obj.float_spread - return leg_obj._spread(-core_npv, *args) + specified_spd + specified_spd = _drb(0.0, leg_obj.float_spread) + ret: DualTypes = leg_obj._spread(-core_npv, *args) + specified_spd + return ret # irs_npv = self.npv(curves, solver) # curves, _ = self._get_curves_and_fx_maybe_from_solver(solver, curves, None) @@ -2197,7 +1362,7 @@ def rate( # _ = irs_npv / leg_analytic_delta + adjust # return _ - def spread(self, *args, **kwargs): + def spread(self, *args: Any, **kwargs: Any) -> DualTypes: """ Return the mid-market float spread on the specified leg of the SBS. @@ -2207,9 +1372,9 @@ def spread(self, *args, **kwargs): def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -2245,7 +1410,7 @@ def fixings_table( ------- DataFrame """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -2254,10 +1419,10 @@ def fixings_table( self.leg1.currency, ) df1 = self.leg1.fixings_table( - curve=curves[0], approximate=approximate, disc_curve=curves[1], right=right + curve=curves_[0], approximate=approximate, disc_curve=curves_[1], right=right ) df2 = self.leg2.fixings_table( - curve=curves[2], approximate=approximate, disc_curve=curves[3], right=right + curve=curves_[2], approximate=approximate, disc_curve=curves_[3], right=right ) return _composit_fixings_table(df1, df2) @@ -2368,13 +1533,16 @@ class FRA(BaseDerivative): _fixed_rate_mixin = True + leg1: FixedLeg + leg2: FloatLeg + def __init__( self, - *args, - fixed_rate: float | NoInput = NoInput(0), + *args: Any, + fixed_rate: DualTypes | NoInput = NoInput(0), method_param: int | NoInput = NoInput(0), - fixings: float | Series | NoInput = NoInput(0), - **kwargs, + fixings: FixingsRates = NoInput(0), + **kwargs: Any, ) -> None: super().__init__(*args, **kwargs) user_kwargs = { @@ -2385,7 +1553,7 @@ def __init__( "leg2_fixing_method": "ibor", "leg2_float_spread": 0.0, } - self.kwargs = _update_not_noinput(self.kwargs, user_kwargs) + self.kwargs: dict[str, Any] = _update_not_noinput(self.kwargs, user_kwargs) # Build self._fixed_rate = self.kwargs["fixed_rate"] @@ -2404,18 +1572,18 @@ def __init__( def _set_pricing_mid( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), ) -> None: - if self.fixed_rate is NoInput.blank: + if isinstance(self.fixed_rate, NoInput): mid_market_rate = self.rate(curves, solver) self.leg1.fixed_rate = mid_market_rate.real - def analytic_delta( + def analytic_delta( # type: ignore[override] self, - curve: Curve, - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_, + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: """ @@ -2426,17 +1594,18 @@ def analytic_delta( disc_curve_: Curve = _disc_required_maybe_from_curve(curve, disc_curve) fx, base = _get_fx_and_base(self.leg1.currency, fx, base) rate = self.rate([curve]) - _ = self.leg1.notional * self.leg1.periods[0].dcf * disc_curve_[self._payment_date] / 10000 - return fx * _ / (1 + self.leg1.periods[0].dcf * rate / 100) + dcf = self._fixed_period.dcf + _: DualTypes = self.leg1.notional * dcf * disc_curve_[self._payment_date] / 10000 + return fx * _ / (1 + dcf * rate / 100) def npv( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - ) -> DualTypes: + ) -> NPV: """ Return the NPV of the derivative. @@ -2444,7 +1613,7 @@ def npv( """ self._set_pricing_mid(curves, solver) - 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, @@ -2452,18 +1621,19 @@ def npv( base, self.leg1.currency, ) - fx, base = _get_fx_and_base(self.leg1.currency, fx_, base_) - value = self.cashflow(curves[0]) * curves[1][self._payment_date] + fx__, _ = _get_fx_and_base(self.leg1.currency, fx_, base_) + disc_curve_ = _disc_required_maybe_from_curve(curves_[0], curves_[1]) + value = self._cashflow_or_raise(curves_[0]) * disc_curve_[self._payment_date] if local: return {self.leg1.currency: value} else: - return fx * value + return fx__ * value def rate( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: """ @@ -2489,7 +1659,7 @@ def rate( ------- float, Dual or Dual2 """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -2497,9 +1667,9 @@ def rate( base, self.leg1.currency, ) - return self.leg2.periods[0].rate(curves[0]) + return self._float_period.rate(curves_[0]) - def cashflow(self, curve: Curve | LineCurve): + def cashflow(self, curve: CurveOption_) -> DualTypes | None: """ Calculate the local currency cashflow on the FRA from current floating rate and fixed rate. @@ -2513,24 +1683,29 @@ def cashflow(self, curve: Curve | LineCurve): ------- float, Dual or Dual2 """ - cf1 = self.leg1.periods[0].cashflow - rate = self.leg2.periods[0].rate(curve) - cf2 = self.kwargs["notional"] * self.leg2.periods[0].dcf * rate / 100 - if cf1 is not NoInput.blank and cf2 is not NoInput.blank: - cf = cf1 + cf2 + cf1 = self._fixed_period.cashflow + rate = self._float_period.rate(curve) + cf2 = self.kwargs["notional"] * self._float_period.dcf * rate / 100 + if not isinstance(cf1, NoInput) and not isinstance(cf2, NoInput): + cf: DualTypes = cf1 + cf2 else: return None # FRA specification discounts cashflows by the IBOR rate. - cf /= 1 + self.leg2.periods[0].dcf * rate / 100 - + cf /= 1 + self._float_period.dcf * rate / 100 return cf + def _cashflow_or_raise(self, curve: CurveOption_) -> DualTypes: + cf_ = self.cashflow(curve) + if cf_ is None: + raise ValueError("Must supply a `curve` to determine cashflow for FRA.") + return cf_ + def cashflows( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DataFrame: """ @@ -2548,7 +1723,7 @@ def cashflows( DataFrame """ self._set_pricing_mid(curves, solver) - 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, @@ -2556,31 +1731,50 @@ def cashflows( base, self.leg1.currency, ) - fx_, base_ = _get_fx_and_base(self.leg1.currency, fx_, base_) + fx__, base_ = _get_fx_and_base(self.leg1.currency, fx_, base_) + + if isinstance(self.fixed_rate, NoInput): + _fix = None + _cf = None + else: + _fix = -_dual_float(self.fixed_rate) + _cf = _dual_float(self.cashflow(curves_[0])) # type: ignore[arg-type] - cf = float(self.cashflow(curves[0])) - df = float(curves[1][self._payment_date]) - npv_local = cf * df + if isinstance(curves_[1], NoInput): + _df = None + else: + _df = _dual_float(curves_[1][self._payment_date]) + + _spd = self.rate(curves_[0]) + if _spd is not None: + _spd = -_dual_float(_spd) * 100.0 + + if _cf is not None and _df is not None: + _npv_local = _cf * _df + _npv_fx = _npv_local * _dual_float(fx__) + else: + _npv_local = None + _npv_fx = None - _fix = None if self.fixed_rate is NoInput.blank else -float(self.fixed_rate) - _spd = None if curves[1] is NoInput.blank else -float(self.rate(curves[1])) * 100 - cfs = self.leg1.periods[0].cashflows(curves[0], curves[1], fx_, base_) + cfs = self._fixed_period.cashflows(curves_[0], curves_[1], fx__, base_) cfs[defaults.headers["type"]] = "FRA" cfs[defaults.headers["payment"]] = self._payment_date - cfs[defaults.headers["cashflow"]] = cf + cfs[defaults.headers["cashflow"]] = _cf cfs[defaults.headers["rate"]] = _fix cfs[defaults.headers["spread"]] = _spd - cfs[defaults.headers["npv"]] = npv_local - cfs[defaults.headers["df"]] = df - cfs[defaults.headers["fx"]] = float(fx_) - cfs[defaults.headers["npv_fx"]] = npv_local * float(fx_) - return DataFrame.from_records([cfs]) + cfs[defaults.headers["npv"]] = _npv_local + cfs[defaults.headers["df"]] = _df + cfs[defaults.headers["fx"]] = _dual_float(fx__) + cfs[defaults.headers["npv_fx"]] = _npv_fx + + _: DataFrame = DataFrame.from_records([cfs]) + return _ def fixings_table( self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -2616,7 +1810,7 @@ def fixings_table( ------- DataFrame """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( + curves_, _, _ = _get_curves_fx_and_base_maybe_from_solver( self.curves, solver, curves, @@ -2624,15 +1818,20 @@ def fixings_table( NoInput(0), self.leg2.currency, ) - df = self.leg2.fixings_table(curve=curves[2], approximate=approximate, disc_curve=curves[3]) - rate = self.leg2.periods[0].rate(curve=curves[2]) - scalar = curves[3][self._payment_date] / curves[3][self.leg2.periods[0].payment] - scalar *= 1.0 / (1.0 + self.leg2.periods[0].dcf * rate / 100.0) - df[(curves[2].id, "risk")] *= scalar - df[(curves[2].id, "notional")] *= scalar + if isinstance(curves_[2], NoInput) or isinstance(curves_[3], NoInput): + raise ValueError("`curves` are not supplied correctly.") + + df = self.leg2.fixings_table( + curve=curves_[2], approximate=approximate, disc_curve=curves_[3] + ) + rate = self._float_period.rate(curve=curves_[2]) + scalar: DualTypes = curves_[3][self._payment_date] / curves_[3][self._float_period.payment] + scalar *= 1.0 / (1.0 + self._float_period.dcf * rate / 100.0) + df[(curves_[2].id, "risk")] *= scalar # type: ignore[operator, union-attr] + df[(curves_[2].id, "notional")] *= scalar # type: ignore[operator, union-attr] return _trim_df_by_index(df, NoInput(0), right) - def delta(self, *args, **kwargs): + def delta(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the delta of the *Instrument*. @@ -2640,7 +1839,7 @@ def delta(self, *args, **kwargs): """ return super().delta(*args, **kwargs) - def gamma(self, *args, **kwargs): + def gamma(self, *args: Any, **kwargs: Any) -> DataFrame: """ Calculate the gamma of the *Instrument*. @@ -2649,7 +1848,7 @@ def gamma(self, *args, **kwargs): return super().gamma(*args, **kwargs) @property - def _payment_date(self): + def _payment_date(self) -> datetime: """ Get the adjusted payment date for the FRA under regular FRA specifications. @@ -2658,195 +1857,10 @@ def _payment_date(self): """ return self.leg1.schedule.pschedule[0] + @property + def _fixed_period(self) -> FixedPeriod: + return self.leg1.periods[0] # type: ignore[return-value] -class CDS(BaseDerivative): - """ - Create a credit default swap composing a :class:`~rateslib.legs.CreditPremiumLeg` and - a :class:`~rateslib.legs.CreditProtectionLeg`. - - Parameters - ---------- - args : dict - Required positional args to :class:`BaseDerivative`. - fixed_rate : float or None, optional - The rate applied to determine the cashflow on the premium leg. If `None`, can be set later, - typically after a mid-market rate for all periods has been calculated. - Entered in percentage points, e.g. 50bps is 0.50. - premium_accrued : bool, optional - Whether the premium is accrued within the period to default. - recovery_rate : float, Dual, Dual2, optional - The assumed recovery rate on the protection leg that defines payment on - credit default. Set by ``defaults``. - discretization : int, optional - The number of days to discretize the numerical integration over possible credit defaults, - for the protection leg. Set by ``defaults``. - kwargs : dict - Required keyword arguments to :class:`BaseDerivative`. - """ - - _rate_scalar = 1.0 - _fixed_rate_mixin = True - - def __init__( - self, - *args, - fixed_rate: float | NoInput = NoInput(0), - premium_accrued: bool | NoInput = NoInput(0), - recovery_rate: DualTypes | NoInput = NoInput(0), - discretization: int | NoInput = NoInput(0), - **kwargs, - ): - super().__init__(*args, **kwargs) - cds_specific = dict( - initial_exchange=False, # CDS have no exchanges - final_exchange=False, - leg2_initial_exchange=False, - leg2_final_exchange=False, - leg2_frequency="Z", # CDS protection is only ever one payoff - fixed_rate=fixed_rate, - premium_accrued=premium_accrued, - leg2_recovery_rate=recovery_rate, - leg2_discretization=discretization, - ) - self.kwargs = _update_not_noinput(self.kwargs, cds_specific) - - # set defaults for missing values - default_kwargs = dict( - premium_accrued=defaults.cds_premium_accrued, - leg2_recovery_rate=defaults.cds_recovery_rate, - leg2_discretization=defaults.cds_protection_discretization, - ) - self.kwargs = _update_with_defaults(self.kwargs, default_kwargs) - - self.leg1 = CreditPremiumLeg(**_get(self.kwargs, leg=1)) - self.leg2 = CreditProtectionLeg(**_get(self.kwargs, leg=2)) - self._fixed_rate = self.kwargs["fixed_rate"] - - def _set_pricing_mid( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - ): - # the test for an unpriced IRS is that its fixed rate is not set. - if self.fixed_rate is NoInput.blank: - # set a rate for the purpose of generic methods NPV will be zero. - mid_market_rate = self.rate(curves, solver) - self.leg1.fixed_rate = float(mid_market_rate) - - def analytic_delta(self, *args, **kwargs): - """ - Return the analytic delta of a leg of the derivative object. - - See :meth:`BaseDerivative.analytic_delta`. - """ - return super().analytic_delta(*args, **kwargs) - - def analytic_rec_risk(self, *args, **kwargs): - """ - Return the analytic recovery risk of the derivative object. - - See :meth:`BaseDerivative.analytic_delta`. - """ - return self.leg2.analytic_rec_risk(*args, **kwargs) - - def npv( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - local: bool = False, - ): - """ - Return the NPV of the derivative by summing legs. - - See :meth:`BaseDerivative.npv`. - """ - self._set_pricing_mid(curves, solver) - return super().npv(curves, solver, fx, base, local) - - def rate( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the mid-market credit spread of the CDS. - - Parameters - ---------- - curves : Curve, str or list of such - A single :class:`~rateslib.curves.Curve` or id or a list of such. - A list defines the following curves in the order: - - - Forecasting :class:`~rateslib.curves.Curve` for floating leg. - - Discounting :class:`~rateslib.curves.Curve` for both legs. - solver : Solver, optional - The numerical :class:`~rateslib.solver.Solver` that - constructs :class:`~rateslib.curves.Curve` from calibrating instruments. - - .. note:: - - The arguments ``fx`` and ``base`` are unused by single currency - derivatives rates calculations. - - Returns - ------- - float, Dual or Dual2 - - Notes - ----- - The arguments ``fx`` and ``base`` are unused by single currency derivatives - rates calculations. - """ - curves, _, _ = _get_curves_fx_and_base_maybe_from_solver( - self.curves, - solver, - curves, - fx, - base, - self.leg1.currency, - ) - leg2_npv = self.leg2.npv(curves[2], curves[3]) - return self.leg1._spread(-leg2_npv, curves[0], curves[1]) * 0.01 - - def cashflows( - self, - curves: Curve | str | list | NoInput = NoInput(0), - solver: Solver | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), - base: str | NoInput = NoInput(0), - ): - """ - Return the properties of all legs used in calculating cashflows. - - See :meth:`BaseDerivative.cashflows`. - """ - self._set_pricing_mid(curves, solver) - return super().cashflows(curves, solver, fx, base) - - # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International - # Commercial use of this code, and/or copying and redistribution is prohibited. - # Contact rateslib at gmail.com if this code is observed outside its intended sphere. - - def accrued(self, settlement: datetime): - """ - Calculate the amount of premium accrued until a specific date within the relevant *Period*. - - Parameters - ---------- - settlement: datetime - The date against which accrued is measured. - - Returns - ------- - float or None - - Notes - ------ - If the *CDS* is unpriced, i.e. there is no specified ``fixed_rate`` then None will be - returned. - """ - return self.leg1.accrued(settlement) + @property + def _float_period(self) -> FloatPeriod: + return self.leg2.periods[0] # type: ignore[return-value] diff --git a/python/rateslib/instruments/sensitivities.py b/python/rateslib/instruments/sensitivities.py new file mode 100644 index 00000000..9954a3d4 --- /dev/null +++ b/python/rateslib/instruments/sensitivities.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, ParamSpec + +from pandas import DataFrame + +from rateslib import defaults +from rateslib.default import NoInput +from rateslib.fx import FXForwards, FXRates +from rateslib.instruments.utils import ( + _get_curves_fx_and_base_maybe_from_solver, +) +from rateslib.solver import Solver + +if TYPE_CHECKING: + from rateslib.typing import FX_, NPV, Curves_ +P = ParamSpec("P") + + +class Sensitivities: + """ + Base class to add risk sensitivity calculations to an object with an ``npv()`` + method. + """ + + npv: Callable[..., NPV] + cashflows: Callable[..., DataFrame] + + def delta( + self, + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + **kwargs: Any, + ) -> DataFrame: + """ + Calculate delta risk of an *Instrument* against the calibrating instruments in a + :class:`~rateslib.curves.Solver`. + + Parameters + ---------- + curves : Curve, str or list of such, optional + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + solver : Solver, optional + The :class:`~rateslib.solver.Solver` that calibrates + *Curves* from given *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 + :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or + :class:`~rateslib.fx.FXForwards` object. + local : bool, optional + If `True` will ignore ``base`` - this is equivalent to setting ``base`` to *None*. + Included only for argument signature consistent with *npv*. + + Returns + ------- + DataFrame + """ + if isinstance(solver, NoInput): + raise ValueError("`solver` is required for delta/gamma methods.") + npv = self.npv(curves, solver, fx, base, local=True, **kwargs) + _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + NoInput(0), + solver, + NoInput(0), + fx, + base, + NoInput(0), + ) + if local: + base_ = NoInput(0) + return solver.delta(npv, base_, fx_) + + def exo_delta( + self, + vars: list[str], # noqa: A002 + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + vars_scalar: list[float] | NoInput = NoInput(0), + vars_labels: list[str] | NoInput = NoInput(0), + **kwargs: Any, + ) -> DataFrame: + """ + Calculate delta risk of an *Instrument* against some exogenous user created *Variables*. + + See :ref:`What are exogenous variables? ` in the cookbook. + + Parameters + ---------- + vars : list[str] + The variable tags which to determine sensitivities for. + curves : Curve, str or list of such, optional + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + + solver : Solver, optional + The :class:`~rateslib.solver.Solver` that calibrates + *Curves* from given *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 + :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or + :class:`~rateslib.fx.FXForwards` object. + local : bool, optional + If `True` will ignore ``base`` - this is equivalent to setting ``base`` to *None*. + Included only for argument signature consistent with *npv*. + vars_scalar : list[float], optional + Scaling factors for each variable, for example converting rates to basis point etc. + Defaults to ones. + vars_labels : list[str], optional + Alternative names to relabel variables in DataFrames. + + Returns + ------- + DataFrame + """ + if isinstance(solver, NoInput): + raise ValueError("`solver` is required for delta/gamma methods.") + npv = self.npv(curves, solver, fx, base, local=True, **kwargs) + _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + NoInput(0), + solver, + NoInput(0), + fx, + base, + NoInput(0), + ) + if local: + base_ = NoInput(0) + return solver.exo_delta( + npv=npv, vars=vars, base=base_, fx=fx_, vars_scalar=vars_scalar, vars_labels=vars_labels + ) + + def gamma( + self, + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + local: bool = False, + **kwargs: Any, + ) -> DataFrame: + """ + Calculate cross-gamma risk of an *Instrument* against the calibrating instruments of a + :class:`~rateslib.curves.Solver`. + + Parameters + ---------- + curves : Curve, str or list of such, optional + A single :class:`~rateslib.curves.Curve` or id or a list of such. + A list defines the following curves in the order: + + - Forecasting :class:`~rateslib.curves.Curve` for ``leg1``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg1``. + - Forecasting :class:`~rateslib.curves.Curve` for ``leg2``. + - Discounting :class:`~rateslib.curves.Curve` for ``leg2``. + solver : Solver, optional + The :class:`~rateslib.solver.Solver` that calibrates + *Curves* from given *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 + :class:`~rateslib.fx.FXRates` or :class:`~rateslib.fx.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_rate`` is an :class:`~rateslib.fx.FXRates` or + :class:`~rateslib.fx.FXForwards` object. + local : bool, optional + If `True` will ignore ``base``. This is equivalent to setting ``base`` to *None*. + Included only for argument signature consistent with *npv*. + + Returns + ------- + DataFrame + """ + if isinstance(solver, NoInput): + raise ValueError("`solver` is required for delta/gamma methods.") + _, fx_, base_ = _get_curves_fx_and_base_maybe_from_solver( + NoInput(0), + solver, + NoInput(0), + fx, + base, + NoInput(0), + ) + if local: + base_ = NoInput(0) + + # store original order + if id(solver.fx) != id(fx_) and isinstance(fx_, FXRates | FXForwards): + # then the fx_ object is available on solver but that is not being used. + _ad2 = fx_._ad + fx_._set_ad_order(2) + + _ad1 = solver._ad + solver._set_ad_order(2) + + npv = self.npv(curves, solver, fx_, base_, local=True, **kwargs) + grad_s_sT_P: DataFrame = solver.gamma(npv, base_, fx_) + + # reset original order + if id(solver.fx) != id(fx_) and isinstance(fx_, FXRates | FXForwards): + fx_._set_ad_order(_ad2) + solver._set_ad_order(_ad1) + + return grad_s_sT_P + + def cashflows_table( + self, + curves: Curves_ = NoInput(0), + solver: Solver | NoInput = NoInput(0), + fx: FX_ = NoInput(0), + base: str | NoInput = NoInput(0), + **kwargs: Any, + ) -> DataFrame: + """ + Aggregate the values derived from a :meth:`~rateslib.instruments.BaseMixin.cashflows` + method on an *Instrument*. + + Parameters + ---------- + curves : CurveType, str or list of such, optional + Argument input to the underlying ``cashflows`` method of the *Instrument*. + solver : Solver, optional + Argument input to the underlying ``cashflows`` method of the *Instrument*. + fx : float, FXRates, FXForwards, optional + Argument input to the underlying ``cashflows`` method of the *Instrument*. + base : str, optional + Argument input to the underlying ``cashflows`` method of the *Instrument*. + kwargs : dict + Additional arguments input the underlying ``cashflows`` method of the *Instrument*. + + Returns + ------- + DataFrame + """ + cashflows = self.cashflows(curves, solver, fx, base, **kwargs) + cashflows = cashflows[ + [ + defaults.headers["currency"], + defaults.headers["collateral"], + defaults.headers["payment"], + defaults.headers["cashflow"], + ] + ] + _: DataFrame = cashflows.groupby( # type: ignore[assignment] + [ + defaults.headers["currency"], + defaults.headers["collateral"], + defaults.headers["payment"], + ], + dropna=False, + ) + _ = _.sum().unstack([0, 1]).droplevel(0, axis=1) # type: ignore[arg-type] + _.columns.names = ["local_ccy", "collateral_ccy"] + _.index.names = ["payment"] + _ = _.sort_index(ascending=True, axis=0).fillna(0.0) + return _ diff --git a/python/rateslib/instruments/utils.py b/python/rateslib/instruments/utils.py new file mode 100644 index 00000000..55ddb6e9 --- /dev/null +++ b/python/rateslib/instruments/utils.py @@ -0,0 +1,301 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Any + +from pandas import DataFrame + +from rateslib import FXDeltaVolSmile, FXDeltaVolSurface, defaults +from rateslib.curves._parsers import _get_curves_maybe_from_solver +from rateslib.default import NoInput +from rateslib.dual import Dual, Dual2, Variable +from rateslib.fx import FXForwards, FXRates + +if TYPE_CHECKING: + from rateslib.typing import FX_, Curves_, Curves_DiscTuple, Solver_, Vol, Vol_, VolOption, str_ + + +def _get_base_maybe_from_fx(fx: FX_, base: str_, local_ccy: str_) -> str_: + if isinstance(fx, NoInput | float) and isinstance(base, NoInput): + # base will not be inherited from a 2nd level inherited object, i.e. + # from solver.fx, to preserve single currency instruments being defaulted + # to their local currency. + base_ = local_ccy + elif isinstance(fx, FXRates | FXForwards) and isinstance(base, NoInput): + base_ = fx.base + else: + base_ = base + return base_ + + +def _get_fx_maybe_from_solver(solver: Solver_, fx: FX_) -> FX_: + if isinstance(fx, NoInput): + if isinstance(solver, NoInput): + fx_: FX_ = NoInput(0) + # fx_ = 1.0 + else: # solver is not NoInput: + if isinstance(solver.fx, NoInput): + fx_ = NoInput(0) + # fx_ = 1.0 + else: + fx_ = solver._get_fx() + else: + fx_ = fx + if ( + not isinstance(solver, NoInput) + and not isinstance(solver.fx, NoInput) + and id(fx) != id(solver.fx) + ): + warnings.warn( + "Solver contains an `fx` attribute but an `fx` argument has been " + "supplied which will be used but is not the same. This can lead " + "to calculation inconsistencies, mathematically.", + UserWarning, + ) + + return fx_ + + +def _get_curves_fx_and_base_maybe_from_solver( + curves_attr: Curves_, + solver: Solver_, + curves: Curves_, + fx: FX_, + base: str_, + local_ccy: str_, +) -> tuple[Curves_DiscTuple, FX_, str_]: + """ + Parses the ``solver``, ``curves``, ``fx`` and ``base`` arguments in combination. + + Parameters + ---------- + curves_attr + The curves attribute attached to the class. + solver + The solver argument passed in the outer method. + curves + The curves argument passed in the outer method. + fx + The fx argument agrument passed in the outer method. + + Returns + ------- + tuple : (leg1 forecasting, leg1 discounting, leg2 forecasting, leg2 discounting), fx, base + + Notes + ----- + If only one curve is given this is used as all four curves. + + If two curves are given the forecasting curve is used as the forecasting + curve on both legs and the discounting curve is used as the discounting + curve for both legs. + + If three curves are given the single discounting curve is used as the + discounting curve for both legs. + """ + # First process `base`. + base_ = _get_base_maybe_from_fx(fx, base, local_ccy) + # Second process `fx` + fx_ = _get_fx_maybe_from_solver(solver, fx) + # Third process `curves` + curves_ = _get_curves_maybe_from_solver(curves_attr, solver, curves) + return curves_, fx_, base_ + + +def _get_vol_maybe_from_solver(vol_attr: Vol, vol: Vol, solver: Solver_) -> VolOption: + """ + Try to retrieve a general vol input from a solver or the default vol object associated with + instrument. + + Parameters + ---------- + vol_attr: DualTypes, str or FXDeltaVolSmile + The vol attribute associated with the object at initialisation. + vol: DualTypes, str of FXDeltaVolSMile + The specific vol argument supplied at price time. Will take precendence. + solver: Solver, optional + A solver object + + Returns + ------- + DualTypes, FXDeltaVolSmile or NoInput.blank + """ + if vol is None: # capture blank user input and reset + vol = NoInput(0) + + if isinstance(vol, NoInput) and isinstance(vol_attr, NoInput): + return NoInput(0) + elif isinstance(vol, NoInput): + vol = vol_attr + + vol_: Vol_ = vol # type: ignore[assignment] + if isinstance(solver, NoInput): + if isinstance(vol_, str): + raise ValueError( + "String `vol` ids require a `solver` to be mapped. No `solver` provided.", + ) + return vol_ + elif isinstance(vol_, float | Dual | Dual2 | Variable): + return vol_ + elif isinstance(vol_, str): + return solver._get_pre_fxvol(vol_) + else: # vol is a Smile or Surface - check that it is in the Solver + try: + # it is a safeguard to load curves from solvers when a solver is + # provided and multiple curves might have the same id + _: FXDeltaVolSmile | FXDeltaVolSurface = solver._get_pre_fxvol(vol_.id) + if id(_) != id(vol_): + raise ValueError( # ignore: type[union-attr] + "A ``vol`` object has been supplied which has the same " + f"`id` ('{vol_.id}'),\nas one of those available as part of the " + "Solver's collection but is not the same object.\n" + "This is ambiguous and may lead to erroneous prices.\n", + ) + return _ + except AttributeError: + raise AttributeError( + "`vol` has no attribute `id`, likely it not a valid object, got: " + f"{vol_}.\nSince a solver is provided have you missed labelling the `vol` " + f"of the instrument or supplying `vol` directly?", + ) + except KeyError: + if defaults.curve_not_in_solver == "ignore": + return vol_ + elif defaults.curve_not_in_solver == "warn": + warnings.warn("`vol` not found in `solver`.", UserWarning) + return vol_ + else: + raise ValueError("`vol` must be in `solver`.") + + +def _get(kwargs: dict[str, Any], leg: int = 1, filter: tuple[str, ...] = ()) -> dict[str, Any]: # noqa: A002 + """ + A parser to return kwarg dicts for relevant legs. + Internal structuring only. + Will return kwargs relevant to leg1 OR leg2. + Does not return keys that are specified in the filter. + """ + if leg == 1: + _: dict[str, Any] = {k: v for k, v in kwargs.items() if "leg2" not in k and k not in filter} + else: + _ = {k[5:]: v for k, v in kwargs.items() if "leg2_" in k and k not in filter} + return _ + + +def _push(spec: str_, kwargs: dict[str, Any]) -> dict[str, Any]: + """ + Push user specified kwargs to a default specification. + Values from the `spec` dict will not overwrite specific user values already in `kwargs`. + """ + if isinstance(spec, NoInput): + return kwargs + else: + try: + spec_kwargs = defaults.spec[spec.lower()] + except KeyError: + raise ValueError(f"Given `spec`, '{spec}', cannot be found in defaults.") + + user = {k: v for k, v in kwargs.items() if not isinstance(v, NoInput)} + return {**kwargs, **spec_kwargs, **user} + + +def _update_not_noinput(base_kwargs: dict[str, Any], new_kwargs: dict[str, Any]) -> dict[str, Any]: + """ + Update the `base_kwargs` with `new_kwargs` (user values) unless those new values are NoInput. + """ + updaters = { + k: v for k, v in new_kwargs.items() if k not in base_kwargs or not isinstance(v, NoInput) + } + return {**base_kwargs, **updaters} + + +def _update_with_defaults( + base_kwargs: dict[str, Any], default_kwargs: dict[str, Any] +) -> dict[str, Any]: + """ + Update the `base_kwargs` with `default_kwargs` if the values are NoInput.blank. + """ + updaters = { + k: v + for k, v in default_kwargs.items() + if k in base_kwargs and base_kwargs[k] is NoInput.blank + } + return {**base_kwargs, **updaters} + + +def _inherit_or_negate(kwargs: dict[str, Any], ignore_blank: bool = False) -> dict[str, Any]: + """Amend the values of leg2 kwargs if they are defaulted to inherit or negate from leg1.""" + + def _replace(k: str, v: Any) -> Any: + # either inherit or negate the value in leg2 from that in leg1 + if "leg2_" in k: + if not isinstance(v, NoInput): + return v # do nothing if the attribute is an input + + try: + leg1_v = kwargs[k[5:]] + except KeyError: + return v + + if leg1_v is NoInput.blank: + if ignore_blank: + return v # this allows an inheritor or negator to be called a second time + else: + return NoInput(0) + + if v is NoInput(-1): + return leg1_v * -1.0 + elif v is NoInput(1): + return leg1_v + return v # do nothing to leg1 attributes + + return {k: _replace(k, v) for k, v in kwargs.items()} + + +def _lower(val: str_) -> str_: + if isinstance(val, str): + return val.lower() + return val + + +def _upper(val: str_) -> str_: + if isinstance(val, str): + return val.upper() + return val + + +def _composit_fixings_table(df_result: DataFrame, df: DataFrame) -> DataFrame: + """ + Add a DataFrame to an existing fixings table by extending or adding to relevant columns. + + Parameters + ---------- + df_result: The main DataFrame that will be updated + df: The incoming DataFrame with new data to merge + + Returns + ------- + DataFrame + """ + # reindex the result DataFrame + if df_result.empty: + return df + else: + df_result = df_result.reindex(index=df_result.index.union(df.index)) + + # update existing columns with missing data from the new available data + for c in [c for c in df.columns if c in df_result.columns and c[1] in ["dcf", "rates"]]: + df_result[c] = df_result[c].combine_first(df[c]) + + # merge by addition existing values with missing filled to zero + m = [c for c in df.columns if c in df_result.columns and c[1] in ["notional", "risk"]] + if len(m) > 0: + df_result[m] = df_result[m].add(df[m], fill_value=0.0) + + # append new columns without additional calculation + a = [c for c in df.columns if c not in df_result.columns] + if len(a) > 0: + df_result[a] = df[a] + + # df_result.columns = MultiIndex.from_tuples(df_result.columns) + return df_result diff --git a/python/rateslib/legs.py b/python/rateslib/legs.py index 4301a035..5f5c5733 100644 --- a/python/rateslib/legs.py +++ b/python/rateslib/legs.py @@ -1,24 +1,3 @@ -# This is a dependent of instruments.py - -""" -.. ipython:: python - :suppress: - - from rateslib.legs import * - from rateslib.legs import CreditPremiumLeg - from rateslib.curves import Curve - from datetime import datetime as dt - curve = Curve( - nodes={ - dt(2022,1,1): 1.0, - dt(2023,1,1): 0.99, - dt(2024,1,1): 0.965, - dt(2025,1,1): 0.93, - }, - interpolation="log_linear", - ) -""" - from __future__ import annotations import abc @@ -26,17 +5,18 @@ from abc import ABCMeta, abstractmethod from datetime import datetime from math import prod -from typing import Any +from typing import TYPE_CHECKING, Any import pandas as pd from pandas import DataFrame, Series from rateslib import defaults -from rateslib.calendars import CalInput, add_tenor +from rateslib.calendars import add_tenor from rateslib.curves import Curve, index_left from rateslib.default import NoInput, _drb -from rateslib.dual import Dual, Dual2, DualTypes, Variable, _dual_float -from rateslib.fx import FXForwards, FXRates +from rateslib.dual import Dual, Dual2, Variable +from rateslib.dual.utils import _dual_float +from rateslib.fx import FXForwards from rateslib.periods import ( Cashflow, CreditPremiumPeriod, @@ -53,22 +33,14 @@ ) from rateslib.scheduling import Schedule +if TYPE_CHECKING: + from rateslib.typing import FX_, CalInput, CurveOption_, DualTypes, FixingsRates, Period + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. -Period = ( - FixedPeriod - | FloatPeriod - | Cashflow - | IndexFixedPeriod - | IndexCashflow - | CreditPremiumPeriod - | CreditProtectionPeriod -) - - class BaseLeg(metaclass=ABCMeta): """ Abstract base class with common parameters for all ``Leg`` subclasses. @@ -374,9 +346,9 @@ def npv(self, *args: Any, **kwargs: Any) -> DualTypes | dict[str, DualTypes]: def _spread( self, target_npv: DualTypes, - fore_curve: Curve, - disc_curve: Curve, - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fore_curve: CurveOption_, + disc_curve: CurveOption_, + fx: FX_ = NoInput(0), ) -> DualTypes: """ Calculates an adjustment to the ``fixed_rate`` or ``float_spread`` to match @@ -518,6 +490,8 @@ class FixedLeg(_FixedLegMixin, BaseLeg): # type: ignore[misc] fixed_leg_exch.npv(curve) """ # noqa: E501 + periods: list[FixedPeriod | Cashflow] # type: ignore[assignment] + def __init__( self, *args: Any, fixed_rate: DualTypes | NoInput = NoInput(0), **kwargs: Any ) -> None: @@ -609,11 +583,7 @@ def _get_fixings_from_series( def _set_fixings( self, - fixings: Series[DualTypes] # type: ignore[type-var] - | list[DualTypes | list[DualTypes] | Series[DualTypes] | NoInput] - | tuple[DualTypes, Series[DualTypes]] - | DualTypes - | NoInput, + fixings: FixingsRates, # type: ignore[type-var] ) -> None: """ Re-organises the fixings input to list structure for each period. @@ -703,8 +673,8 @@ def _set_fixings( def _spread_isda_approximated_rate( self, target_npv: DualTypes, - fore_curve: Curve, - disc_curve: Curve, + fore_curve: Curve, # TODO: use CurveOption_ and handle dict[str, Curve] + disc_curve: Curve, # TODO: use CurveOption_ and handle dict[str, Curve] ) -> DualTypes: """ Use approximated derivatives through geometric averaged 1day rates to derive the @@ -849,9 +819,9 @@ def _is_linear(self) -> bool: def _spread( self, target_npv: DualTypes, - fore_curve: Curve, - disc_curve: Curve, - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fore_curve: CurveOption_, + disc_curve: CurveOption_, + fx: FX_ = NoInput(0), ) -> DualTypes: """ Calculates an adjustment to the ``fixed_rate`` or ``float_spread`` to match @@ -893,7 +863,7 @@ def _spread( a_delta: DualTypes = self.analytic_delta(fore_curve, disc_curve, fx, self.currency) # type: ignore[attr-defined] return -target_npv / a_delta else: - return self._spread_isda_approximated_rate(target_npv, fore_curve, disc_curve) + return self._spread_isda_approximated_rate(target_npv, fore_curve, disc_curve) # type: ignore[arg-type] # _ = self._spread_isda_dual2(target_npv, fore_curve, disc_curve, fx) @@ -1036,11 +1006,7 @@ def __init__( self, *args: Any, float_spread: DualTypes | NoInput = NoInput(0), - fixings: Series[DualTypes] # type: ignore[type-var] - | list[DualTypes | list[DualTypes] | Series[DualTypes] | NoInput] - | tuple[DualTypes, Series[DualTypes]] - | DualTypes - | NoInput = NoInput(0), + fixings: FixingsRates = NoInput(0), fixing_method: str | NoInput = NoInput(0), method_param: int | NoInput = NoInput(0), spread_compound_method: str | NoInput = NoInput(0), @@ -1086,9 +1052,9 @@ def npv(self, *args: Any, **kwargs: Any) -> DualTypes | dict[str, DualTypes]: def fixings_table( self, - curve: Curve, - disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_, + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -1341,11 +1307,7 @@ def __init__( self, *args: Any, float_spread: DualTypes | NoInput = NoInput(0), - fixings: Series[DualTypes] # type: ignore[type-var] - | list[DualTypes | list[DualTypes] | Series[DualTypes] | NoInput] - | tuple[DualTypes, Series[DualTypes]] - | DualTypes - | NoInput = NoInput(0), + fixings: FixingsRates = NoInput(0), fixing_method: str | NoInput = NoInput(0), method_param: int | NoInput = NoInput(0), spread_compound_method: str | NoInput = NoInput(0), @@ -1398,7 +1360,7 @@ def dcf(self) -> float: _ = [period.dcf for period in self.periods if isinstance(period, FloatPeriod)] return sum(_) - def rate(self, curve: Curve | NoInput) -> DualTypes: + def rate(self, curve: CurveOption_) -> DualTypes: """ Calculate a simple period type floating rate for the zero coupon leg. @@ -1419,9 +1381,9 @@ def rate(self, curve: Curve | NoInput) -> DualTypes: def npv( self, - curve: Curve, - disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_, + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> dict[str, DualTypes] | DualTypes: @@ -1447,9 +1409,9 @@ def npv( def fixings_table( self, - curve: Curve, - disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_, + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -1477,32 +1439,27 @@ def fixings_table( ------- DataFrame """ - if isinstance(disc_curve, NoInput) and isinstance(curve, dict): - raise ValueError("Cannot infer `disc_curve` from a dict of curves.") - elif isinstance(disc_curve, NoInput): - if curve._base_type == "dfs": - disc_curve = curve - else: - raise ValueError("Must supply a discount factor based `disc_curve`.") + disc_curve_: Curve = _disc_required_maybe_from_curve(curve, disc_curve) if self.fixing_method == "ibor": dfs = [] prod = 1 + self.dcf * self.rate(curve) / 100.0 - prod *= -self.notional * disc_curve[self.schedule.pschedule[-1]] + prod *= -self.notional * disc_curve_[self.schedule.pschedule[-1]] for period in self.periods: if not isinstance(period, FloatPeriod): continue scalar = period.dcf / (1 + period.dcf * period.rate(curve) / 100.0) risk = prod * scalar - dfs.append(period._ibor_fixings_table(curve, disc_curve, right, risk)) + dfs.append(period._ibor_fixings_table(curve, disc_curve_, right, risk)) else: dfs = [] prod = 1 + self.dcf * self.rate(curve) / 100.0 for period in [_ for _ in self.periods if isinstance(_, FloatPeriod)]: - df = period.fixings_table(curve, approximate, disc_curve) + # TODO: handle interpolated fixings and curve as dict. + df = period.fixings_table(curve, approximate, disc_curve_) scalar = prod / (1 + period.dcf * period.rate(curve) / 100.0) - df[(curve.id, "risk")] *= scalar # type: ignore[operator] - df[(curve.id, "notional")] *= scalar # type: ignore[operator] + df[(curve.id, "risk")] *= scalar # type: ignore[operator, union-attr] + df[(curve.id, "notional")] *= scalar # type: ignore[operator, union-attr] dfs.append(df) with warnings.catch_warnings(): @@ -1514,7 +1471,7 @@ def analytic_delta( self, curve: Curve | NoInput = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: """ @@ -1540,9 +1497,9 @@ def analytic_delta( def cashflows( self, - curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DataFrame: """ @@ -1715,9 +1672,9 @@ def dcf(self) -> float: def cashflows( self, - curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DataFrame: """ @@ -1766,7 +1723,7 @@ def analytic_delta( self, curve: Curve | NoInput = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: """ @@ -1795,9 +1752,9 @@ def _analytic_delta(self, *args: Any, **kwargs: Any) -> DualTypes: def _spread( self, target_npv: DualTypes, - fore_curve: Curve, - disc_curve: Curve, - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fore_curve: CurveOption_, + disc_curve: CurveOption_, + fx: FX_ = NoInput(0), ) -> DualTypes: """ Overload the _spread calc to use analytic delta based on period rate @@ -2618,7 +2575,7 @@ def fx_fixings( # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. - def _get_fx_fixings(self, fx: DualTypes | FXRates | FXForwards | NoInput) -> list[DualTypes]: + def _get_fx_fixings(self, fx: FX_) -> list[DualTypes]: """ Return the calculated FX fixings. @@ -2676,7 +2633,7 @@ def _get_fx_fixings(self, fx: DualTypes | FXRates | FXForwards | NoInput) -> lis fx_fixings_.extend([fx_fixings_[-1]] * (n_req - n_given)) return fx_fixings_ - def _set_periods(self, fx: DualTypes | FXRates | FXForwards | NoInput) -> None: # type: ignore[override] + def _set_periods(self, fx: FX_) -> None: # type: ignore[override] fx_fixings_: list[DualTypes] = self._get_fx_fixings(fx) self.notional = fx_fixings_[0] * self.alt_notional notionals = [self.alt_notional * fx_fixings_[i] for i in range(len(fx_fixings_))] @@ -2750,7 +2707,7 @@ def npv( self, curve: Curve, disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> DualTypes | dict[str, DualTypes]: @@ -2764,7 +2721,7 @@ def cashflows( self, curve: Curve | NoInput = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DataFrame: if not self._do_not_repeat_set_periods: @@ -2777,7 +2734,7 @@ def analytic_delta( self, curve: Curve | NoInput = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: if not self._do_not_repeat_set_periods: @@ -2910,11 +2867,7 @@ def __init__( self, *args: Any, float_spread: DualTypes | NoInput = NoInput(0), - fixings: Series[DualTypes] # type: ignore[type-var] - | list[DualTypes | list[DualTypes] | Series[DualTypes] | NoInput] - | tuple[DualTypes, Series[DualTypes]] - | DualTypes - | NoInput = NoInput(0), + fixings: FixingsRates = NoInput(0), fixing_method: str | NoInput = NoInput(0), method_param: int | NoInput = NoInput(0), spread_compound_method: str | NoInput = NoInput(0), @@ -2939,7 +2892,7 @@ def fixings_table( self, curve: Curve, disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), approximate: bool = False, right: datetime | NoInput = NoInput(0), @@ -2991,9 +2944,21 @@ class CustomLeg(BaseLeg): """ # noqa: E501 def __init__(self, periods: list[Period]) -> None: - if not all(isinstance(p, Period) for p in periods): + if not all( + isinstance( + p, + FloatPeriod + | FixedPeriod + | IndexFixedPeriod + | Cashflow + | IndexCashflow + | CreditPremiumPeriod + | CreditProtectionPeriod, + ) + for p in periods + ): raise ValueError( - "Each object in `periods` must be of type {FixedPeriod, FloatPeriod, " "Cashflow}.", + "Each object in `periods` must be a specific `Period` type.", ) self._set_periods(periods) diff --git a/python/rateslib/periods.py b/python/rateslib/periods.py index b8a3efa3..c2dce30b 100644 --- a/python/rateslib/periods.py +++ b/python/rateslib/periods.py @@ -25,29 +25,29 @@ from collections.abc import Sequence from datetime import datetime, timedelta from math import comb, log -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np from pandas import NA, DataFrame, Index, MultiIndex, Series, concat, isna, notna from rateslib import defaults -from rateslib.calendars import CalInput, CalTypes, _get_eom, add_tenor, dcf, get_calendar +from rateslib.calendars import _get_eom, add_tenor, dcf, get_calendar from rateslib.curves import Curve, average_rate, index_left from rateslib.default import NoInput, _drb from rateslib.dual import ( Dual, Dual2, - DualTypes, - Number, Variable, - _dual_float, dual_exp, dual_inv_norm_cdf, dual_log, dual_norm_cdf, dual_norm_pdf, gradient, + newton_1dim, + newton_ndim, ) +from rateslib.dual.utils import _dual_float from rateslib.fx import FXForwards, FXRates from rateslib.fx_volatility import ( FXDeltaVolSmile, @@ -58,9 +58,11 @@ _d_plus_min_u, _delta_type_constants, ) -from rateslib.solver import newton_1dim, newton_ndim from rateslib.splines import evaluate +if TYPE_CHECKING: + from rateslib.typing import FX_, CalInput, CalTypes, Curve_, CurveOption_, DualTypes, Number + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. @@ -68,7 +70,7 @@ def _get_fx_and_base( currency: str, - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> tuple[DualTypes, str | NoInput]: """ @@ -140,7 +142,7 @@ def _maybe_local( value: DualTypes, local: bool, currency: str, - fx: float | FXRates | FXForwards | NoInput, + fx: FX_, base: str | NoInput, ) -> dict[str, DualTypes] | DualTypes: """ @@ -155,7 +157,7 @@ def _maybe_local( def _maybe_fx_converted( value: DualTypes, currency: str, - fx: DualTypes | FXRates | FXForwards | NoInput, + fx: FX_, base: str | NoInput, ) -> DualTypes: fx_, _ = _get_fx_and_base(currency, fx, base) @@ -163,7 +165,7 @@ def _maybe_fx_converted( def _disc_maybe_from_curve( - curve: Curve | NoInput | dict[str, Curve], + curve: CurveOption_, disc_curve: Curve | NoInput, ) -> Curve | NoInput: """Return a discount curve, pointed as the `curve` if not provided and if suitable Type.""" @@ -181,10 +183,12 @@ def _disc_maybe_from_curve( def _disc_required_maybe_from_curve( - curve: Curve | NoInput | dict[str, Curve], - disc_curve: Curve | NoInput, + curve: CurveOption_, + disc_curve: CurveOption_, ) -> Curve: """Return a discount curve, pointed as the `curve` if not provided and if suitable Type.""" + if isinstance(disc_curve, dict): + raise NotImplementedError("`disc_curve` cannot currently be inferred from a dict.") _: Curve | NoInput = _disc_maybe_from_curve(curve, disc_curve) if isinstance(_, NoInput): raise TypeError( @@ -287,8 +291,8 @@ def dcf(self) -> float: @abstractmethod def analytic_delta( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: @@ -346,7 +350,7 @@ def analytic_delta( @abstractmethod def cashflows( self, - curve: Curve | dict[str, Curve] | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), disc_curve: Curve | NoInput = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), @@ -408,9 +412,9 @@ def cashflows( @abstractmethod def npv( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> DualTypes | dict[str, DualTypes]: @@ -540,9 +544,9 @@ def cashflow(self) -> DualTypes | None: def npv( self, - curve: Curve | dict[str, Curve] | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> dict[str, DualTypes] | DualTypes: @@ -563,9 +567,9 @@ def npv( def cashflows( self, - curve: Curve | dict[str, Curve] | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: Curve_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> dict[str, Any]: """ @@ -635,7 +639,7 @@ def _validate_float_args( ) elif fixing_method_ == "rfr_lockout" and method_param_ < 1: raise ValueError( - f'`method_param` must be >0 for "rfr_lockout" `fixing_method`, ' f"got {method_param_}", + f'`method_param` must be >0 for "rfr_lockout" `fixing_method`, got {method_param_}', ) spread_compound_method_: str = _drb( @@ -928,8 +932,8 @@ def __init__( def analytic_delta( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: @@ -958,9 +962,9 @@ def analytic_delta( def cashflows( self, - curve: Curve | dict[str, Curve] | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: Curve_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), ) -> dict[str, Any]: """ @@ -1000,9 +1004,9 @@ def cashflows( def npv( self, - curve: Curve | dict[str, Curve] | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> dict[str, DualTypes] | DualTypes: @@ -1011,9 +1015,7 @@ def npv( See :meth:`BasePeriod.npv()` """ - disc_curve_: Curve | NoInput = _disc_maybe_from_curve(curve, disc_curve) - if not isinstance(disc_curve_, Curve): - raise TypeError("`curves` have not been supplied correctly.") + disc_curve_: Curve = _disc_required_maybe_from_curve(curve, disc_curve) if self.payment < disc_curve_.node_dates[0]: if local: return {self.currency: 0.0} @@ -1023,7 +1025,7 @@ def npv( return _maybe_local(value, local, self.currency, fx, base) - def cashflow(self, curve: Curve | dict[str, Curve] | NoInput = NoInput(0)) -> DualTypes | None: + def cashflow(self, curve: CurveOption_ = NoInput(0)) -> DualTypes | None: """ Forecast the *Period* cashflow based on a *Curve* providing index rates. @@ -1047,9 +1049,7 @@ def cashflow(self, curve: Curve | dict[str, Curve] | NoInput = NoInput(0)) -> Du # probably "needs a `curve` to forecast rate return None - def _maybe_get_cal_and_conv_from_curve( - self, curve: Curve | dict[str, Curve] | NoInput - ) -> tuple[CalTypes, str]: + def _maybe_get_cal_and_conv_from_curve(self, curve: CurveOption_) -> tuple[CalTypes, str]: if isinstance(curve, NoInput): cal_: CalTypes = get_calendar(self.calendar) conv_: str = self.convention @@ -1071,7 +1071,7 @@ def _maybe_get_cal_and_conv_from_curve( conv_ = curve.convention return cal_, conv_ - def rate(self, curve: Curve | dict[str, Curve] | NoInput = NoInput(0)) -> DualTypes: + def rate(self, curve: CurveOption_ = NoInput(0)) -> DualTypes: """ Calculating the floating rate for the period. @@ -1278,7 +1278,7 @@ def _rate_rfr_avg_with_spread( # dcf_vals = dcf_vals.set_axis(rates.index) if self.spread_compound_method != "none_simple": raise ValueError( - "`spread_compound` method must be 'none_simple' in an RFR averaging " "period.", + "`spread_compound` method must be 'none_simple' in an RFR averaging period.", ) else: _: DualTypes = (dcf_vals * rates).sum() / dcf_vals.sum() + self.float_spread / 100 @@ -1364,8 +1364,7 @@ def _rfr_get_series_with_populated_fixings( elif isinstance(self.fixings, Series): if not self.fixings.index.is_monotonic_increasing: # type: ignore[attr-defined] raise ValueError( - "`fixings` as a Series must have a monotonically increasing " - "datetimeindex.", + "`fixings` as a Series must have a monotonically increasing datetimeindex.", ) # [-2] is used because the last rfr fixing is 1 day before the end fixing_rates = self.fixings.loc[obs_dates.iloc[0] : obs_dates.iloc[-2]] # type: ignore[attr-defined, misc] @@ -1465,9 +1464,9 @@ def _rfr_get_individual_fixings_data( def fixings_table( self, - curve: Curve | dict[str, Curve], + curve: CurveOption_, approximate: bool = False, - disc_curve: Curve | NoInput = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), right: datetime | NoInput = NoInput(0), ) -> DataFrame: """ @@ -1601,16 +1600,7 @@ def fixings_table( ) period.fixings_table({"1m": ibor_1m, "3m": ibor_3m}, disc_curve=ibor_1m) """ - if isinstance(disc_curve, NoInput): - if isinstance(curve, dict): - raise ValueError("Cannot infer `disc_curve` from a dict of curves.") - else: # not isinstance(curve, dict): - if curve._base_type == "dfs": - disc_curve_: Curve = curve - else: - raise ValueError("Must supply a discount factor based `disc_curve`.") - else: - disc_curve_ = disc_curve + disc_curve_ = _disc_required_maybe_from_curve(curve, disc_curve) if approximate: if not isinstance(self.fixings, NoInput): @@ -1693,7 +1683,7 @@ def fixings_table( return self._ibor_fixings_table(curve, disc_curve_, right) def _fixings_table_fast( - self, curve: Curve | dict[str, Curve], disc_curve: Curve, right: NoInput | datetime + self, curve: CurveOption_, disc_curve: Curve, right: NoInput | datetime ) -> DataFrame: """ Return a DataFrame of **approximate** fixing exposures. @@ -1798,7 +1788,7 @@ def _fixings_table_fast( def _ibor_fixings_table( self, - curve: Curve | dict[str, Curve], + curve: CurveOption_, disc_curve: Curve, right: datetime | NoInput, risk: DualTypes | NoInput = NoInput(0), @@ -1825,6 +1815,8 @@ def _ibor_fixings_table( else: # not self.stub: # then extract the one relevant curve from dict curve_: Curve = _get_ibor_curve_from_dict(self.freq_months, curve) + elif isinstance(curve, NoInput): + raise ValueError("`curve` must be supplied as Curve or dict for `ibor_fiixngs_table`.") else: curve_ = curve @@ -2312,9 +2304,9 @@ def accrued(self, settlement: datetime) -> DualTypes | None: def npv( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> DualTypes | dict[str, DualTypes]: @@ -2322,26 +2314,24 @@ def npv( Return the NPV of the *CreditPremiumPeriod*. See :meth:`BasePeriod.npv()` """ - if not isinstance(disc_curve, Curve) and isinstance(disc_curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") - if not isinstance(curve, Curve) and isinstance(curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") + curve_, disc_curve_ = _validate_credit_curves(curve, disc_curve) + if isinstance(self.fixed_rate, NoInput): raise ValueError("`fixed_rate` must be set as a value to return a valid NPV.") - v_payment = disc_curve[self.payment] - q_end = curve[self.end] + v_payment = disc_curve_[self.payment] + q_end = curve_[self.end] _ = 0.0 if self.premium_accrued: - v_end = disc_curve[self.end] + v_end = disc_curve_[self.end] n = _dual_float((self.end - self.start).days) - if self.start < curve.node_dates[0]: + if self.start < curve_.node_dates[0]: # then mid-period valuation - r: float = _dual_float((curve.node_dates[0] - self.start).days) + r: float = _dual_float((curve_.node_dates[0] - self.start).days) q_start: DualTypes = 1.0 _v_start: DualTypes = 1.0 else: - r, q_start, _v_start = 0.0, curve[self.start], disc_curve[self.start] + r, q_start, _v_start = 0.0, curve_[self.start], disc_curve_[self.start] # method 1: _ = 0.5 * (1 + r / n) @@ -2360,8 +2350,8 @@ def npv( def analytic_delta( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: @@ -2370,27 +2360,24 @@ def analytic_delta( See :meth:`BasePeriod.analytic_delta()` """ - if not isinstance(disc_curve, Curve) and isinstance(disc_curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") - if not isinstance(curve, Curve) and isinstance(curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") + curve_, disc_curve_ = _validate_credit_curves(curve, disc_curve) - v_payment = disc_curve[self.payment] - q_end = curve[self.end] + v_payment = disc_curve_[self.payment] + q_end = curve_[self.end] _ = 0.0 if self.premium_accrued: - v_end = disc_curve[self.end] + v_end = disc_curve_[self.end] n = _dual_float((self.end - self.start).days) - if self.start < curve.node_dates[0]: + if self.start < curve_.node_dates[0]: # then mid-period valuation - r: float = _dual_float((curve.node_dates[0] - self.start).days) + r: float = _dual_float((curve_.node_dates[0] - self.start).days) q_start: DualTypes = 1.0 _v_start: DualTypes = 1.0 else: r = 0.0 - q_start = curve[self.start] - _v_start = disc_curve[self.start] + q_start = curve_[self.start] + _v_start = disc_curve_[self.start] # method 1: _ = 0.5 * (1 + r / n) @@ -2508,9 +2495,9 @@ def cashflow(self) -> DualTypes: def npv( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), - fx: float | FXRates | FXForwards | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), + fx: FX_ = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, ) -> DualTypes | dict[str, DualTypes]: @@ -2518,25 +2505,22 @@ def npv( Return the NPV of the *CreditProtectionPeriod*. See :meth:`BasePeriod.npv()` """ - if not isinstance(disc_curve, Curve) and isinstance(disc_curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") - if not isinstance(curve, Curve) and isinstance(curve, NoInput): - raise TypeError("`curves` have not been supplied correctly.") + curve_, disc_curve_ = _validate_credit_curves(curve, disc_curve) - if self.start < curve.node_dates[0]: - s2 = curve.node_dates[0] + if self.start < curve_.node_dates[0]: + s2 = curve_.node_dates[0] else: s2 = self.start value: DualTypes = 0.0 - q2: DualTypes = curve[s2] - v2: DualTypes = disc_curve[s2] + q2: DualTypes = curve_[s2] + v2: DualTypes = disc_curve_[s2] while s2 < self.end: q1, v1 = q2, v2 s2 = s2 + timedelta(days=self.discretization) if s2 > self.end: s2 = self.end - q2, v2 = curve[s2], disc_curve[s2] + q2, v2 = curve_[s2], disc_curve_[s2] value += 0.5 * (v1 + v2) * (q1 - q2) # value += v2 * (q1 - q2) @@ -2545,8 +2529,8 @@ def npv( def analytic_delta( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: @@ -2812,7 +2796,7 @@ class IndexMixin(metaclass=ABCMeta): def real_cashflow(self) -> DualTypes | None: pass # pragma: no cover - def cashflow(self, curve: Curve | NoInput = NoInput(0)) -> DualTypes | None: + def cashflow(self, curve: CurveOption_ = NoInput(0)) -> DualTypes | None: """ float, Dual or Dual2 : The calculated value from rate, dcf and notional, adjusted for the index. @@ -2832,7 +2816,7 @@ def cashflow(self, curve: Curve | NoInput = NoInput(0)) -> DualTypes | None: return ret def index_ratio( - self, curve: Curve | NoInput = NoInput(0) + self, curve: CurveOption_ = NoInput(0) ) -> tuple[DualTypes | None, DualTypes | None, DualTypes | None]: """ Calculate the index ratio for the end date of the *IndexPeriod*. @@ -2893,7 +2877,7 @@ def _index_value_from_curve( def _index_value( i_fixings: DualTypes | Series[DualTypes] | NoInput, # type: ignore[type-var] i_date: datetime | NoInput, - i_curve: Curve | NoInput, + i_curve: CurveOption_, i_lag: int, i_method: str, ) -> DualTypes | None: @@ -2911,6 +2895,11 @@ def _index_value( ------- float, Dual, Dual2, Variable or None """ + if isinstance(i_curve, dict): + raise NotImplementedError( + "`i_curve` cannot currently be supplied as dict. Use a Curve type or NoInput(0)." + ) + if isinstance(i_date, NoInput): if not isinstance(i_fixings, Series | NoInput): # i_fixings is a given value, probably aligned with an ``index_base`` @@ -3076,8 +3065,8 @@ def __init__( def analytic_delta( self, - curve: Curve | NoInput = NoInput(0), - disc_curve: Curve | NoInput = NoInput(0), + curve: CurveOption_ = NoInput(0), + disc_curve: CurveOption_ = NoInput(0), fx: DualTypes | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), ) -> DualTypes: @@ -3504,7 +3493,7 @@ def rate( fx: float | FXRates | FXForwards | NoInput = NoInput(0), base: str | NoInput = NoInput(0), local: bool = False, - vol: float | NoInput = NoInput(0), + vol: DualTypes | FXVols | NoInput = NoInput(0), metric: str | NoInput = NoInput(0), ) -> DualTypes: """ @@ -3591,6 +3580,9 @@ def implied_vol( ------- float, Dual or Dual2 """ + if isinstance(self.strike, NoInput): + raise ValueError("FXOption must set a `strike` for valuation.") + # This function uses newton_1d and is AD safe. # convert the premium to a standardised immediate pips value. @@ -3624,7 +3616,7 @@ def analytic_greeks( fx: FXForwards, base: str | NoInput = NoInput(0), local: bool = False, - vol: float | NoInput = NoInput(0), + vol: DualTypes | FXVols | NoInput = NoInput(0), premium: DualTypes | NoInput = NoInput(0), # expressed in the payment currency ) -> dict[str, Any]: r""" @@ -3724,10 +3716,17 @@ def analytic_greeks( u = self.strike / f_d sqrt_t = self._t_to_expiry(disc_curve.node_dates[0]) ** 0.5 - if isinstance(vol, FXVolObj): - delta_idx, vol_, __ = vol.get_from_strike(self.strike, f_d, w_deli, w_spot, self.expiry) + if isinstance(vol, NoInput): + raise ValueError("`vol` must be a number quantity or FXDeltaVolSmile or Surface.") + elif isinstance(vol, FXVolObj): + res: tuple[DualTypes, DualTypes, DualTypes] = vol.get_from_strike( + self.strike, f_d, w_deli, w_spot, self.expiry + ) + delta_idx: DualTypes | None = res[0] + vol_: DualTypes = res[1] else: - delta_idx, vol_ = None, vol + delta_idx = None + vol_ = vol vol_ /= 100.0 vol_sqrt_t = vol_ * sqrt_t eta, z_w, z_u = _delta_type_constants(self.delta_type, w_deli / w_spot, u) @@ -4635,6 +4634,21 @@ def _get_vol_delta_type(vol: DualTypes | FXVols, delta_type: str) -> str: return vol.delta_type +def _validate_credit_curves(curve: CurveOption_, disc_curve: CurveOption_) -> tuple[Curve, Curve]: + # used by Credit type Periods to narrow inputs + if not isinstance(curve, Curve): + raise TypeError( + "`curves` have not been supplied correctly.\n" + "`curve`for a CreditPremiumPeriod must be supplied as a Curve type." + ) + if not isinstance(disc_curve, Curve): + raise TypeError( + "`curves` have not been supplied correctly.\n" + "`disc_curve` for a CreditPremiumPeriod must be supplied as a Curve type." + ) + return curve, disc_curve + + # def _validate_broad_delta_bounds(phi, delta, delta_type): # if phi < 0 and "_pa" in delta_type: # assert delta <= 0.0 diff --git a/python/rateslib/rs.pyi b/python/rateslib/rs.pyi index 85c06597..45aa5c71 100644 --- a/python/rateslib/rs.pyi +++ b/python/rateslib/rs.pyi @@ -1,13 +1,11 @@ from collections.abc import Sequence from datetime import datetime -from typing import Any +from typing import TYPE_CHECKING, Any from typing_extensions import Self -from rateslib.calendars import CalTypes -from rateslib.curves.rs import CurveInterpolator -from rateslib.dual import DualTypes, Number -from rateslib.dual.variable import Arr1dF64, Arr2dF64 +if TYPE_CHECKING: + from rateslib.typing import Arr1dF64, Arr2dF64, CalTypes, CurveInterpolator, DualTypes, Number class ADOrder: Zero: ADOrder @@ -136,17 +134,21 @@ class _DualOps: def to_json(self) -> str: ... def ptr_eq(self, other: Self) -> bool: ... def __repr__(self) -> str: ... - def grad1(self, vars: list[str]) -> Arr1dF64: ... - def grad2(self, vars: list[str]) -> Arr2dF64: ... + def grad1(self, vars: list[str]) -> Arr1dF64: ... # noqa: A002 + def grad2(self, vars: list[str]) -> Arr2dF64: ... # noqa: A002 class Dual(_DualOps): - def __init__(self, real: float, vars: Sequence[str], dual: Sequence[float] | Arr1dF64): ... + def __init__(self, real: float, vars: Sequence[str], dual: Sequence[float] | Arr1dF64): ... # noqa: A002 real: float = ... vars: list[str] = ... dual: Arr1dF64 = ... @classmethod def vars_from( - cls, other: Dual, real: float, vars: Sequence[str], dual: Sequence[float] | Arr1dF64 + cls, + other: Dual, + real: float, + vars: Sequence[str], # noqa: A002 + dual: Sequence[float] | Arr1dF64, ) -> Dual: ... def to_dual2(self) -> Dual2: ... @@ -154,7 +156,7 @@ class Dual2(_DualOps): def __init__( self, real: float, - vars: Sequence[str], + vars: Sequence[str], # noqa: A002 dual: Sequence[float] | Arr1dF64, dual2: Sequence[float], ): ... @@ -167,11 +169,11 @@ class Dual2(_DualOps): cls, other: Dual2, real: float, - vars: list[str], + vars: list[str], # noqa: A002 dual: list[float] | Arr1dF64, dual2: list[float] | Arr1dF64, ) -> Dual2: ... - def grad1_manifold(self, vars: Sequence[str]) -> list[Dual2]: ... + def grad1_manifold(self, vars: Sequence[str]) -> list[Dual2]: ... # noqa: A002 def to_dual(self) -> Dual: ... def _dsolve1(a: list[Any], b: list[Any], allow_lsq: bool) -> list[Dual]: ... @@ -287,7 +289,7 @@ class Curve: nodes: dict[datetime, Number], interpolator: CurveInterpolator, ad: ADOrder, - id: str, + id: str, # noqa: A002 convention: Convention, modifier: Modifier, calendar: CalTypes, @@ -301,4 +303,4 @@ class Curve: def _get_convention_str(convention: Convention) -> str: ... def _get_modifier_str(modifier: Modifier) -> str: ... -def index_left_f64(list_input: list[float], value: float, left_count: int | None) -> int: ... +def index_left_f64(list_input: list[float], value: float, left_count: int | None = None) -> int: ... diff --git a/python/rateslib/scheduling.py b/python/rateslib/scheduling.py index 4fbef4ce..15e74014 100644 --- a/python/rateslib/scheduling.py +++ b/python/rateslib/scheduling.py @@ -1,17 +1,16 @@ from __future__ import annotations import calendar as calendar_mod -from collections.abc import Callable, Iterator +from collections.abc import Iterator from datetime import datetime, timedelta from itertools import product -from typing import NamedTuple +from typing import TYPE_CHECKING, NamedTuple from pandas import DataFrame from rateslib import defaults from rateslib.calendars import ( # type: ignore[attr-defined] - CalInput, - CalTypes, + _IS_ROLL, _adjust_date, _get_modifier, _get_roll, @@ -19,13 +18,14 @@ _is_day_type_tenor, _is_eom, _is_eom_cal, - _is_imm, - _is_som, add_tenor, get_calendar, ) from rateslib.default import NoInput, _drb +if TYPE_CHECKING: + from rateslib.typing import CalInput, CalTypes + # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. @@ -520,11 +520,11 @@ def __repr__(self) -> str: return f"" def __str__(self) -> str: - str = ( + str_ = ( f"freq: {self.frequency}, stub: {self.stub}, roll: {self.roll}" f", pay lag: {self.payment_lag}, modifier: {self.modifier}\n" ) - return str + self.table.__repr__() + return str_ + self.table.__repr__() @property def table(self) -> DataFrame: @@ -755,39 +755,38 @@ def _check_unadjusted_regular_swap( if not freq_check: return _InvalidSchedule("Months date separation not aligned with frequency.") - roll = "eom" if roll == 31 else roll - iter_: list[tuple[str, Callable[..., bool]]] = [ - ("eom", _is_eom), - ("imm", _is_imm), - ("som", _is_som), - ] - for roll_, _is_roll in iter_: - if str(roll).lower() == roll_: - if not _is_roll(ueffective): - return _InvalidSchedule(f"Non-{roll_} effective date with {roll_} rolls.") - if not _is_roll(utermination): - return _InvalidSchedule(f"Non-{roll_} termination date with {roll_} rolls.") - - if isinstance(roll, int): - if roll in [29, 30]: - if ueffective.day != roll and not (ueffective.month == 2 and _is_eom(ueffective)): - return _InvalidSchedule(f"Effective date not aligned with {roll} rolls.") - if utermination.day != roll and not (utermination.month == 2 and _is_eom(utermination)): - return _InvalidSchedule(f"Termination date not aligned with {roll} rolls.") - else: - if ueffective.day != roll: - return _InvalidSchedule(f"Termination date not aligned with {roll} rolls.") - if utermination.day != roll: - return _InvalidSchedule(f"Termination date not aligned with {roll} rolls.") - if isinstance(roll, NoInput): roll = _get_unadjusted_roll(ueffective, utermination, eom) if roll == 0: return _InvalidSchedule("Roll day could not be inferred from given dates.") + else: + ueff_ret: _InvalidSchedule | None = None + uter_ret: _InvalidSchedule | None = None + else: + ueff_ret = _validate_date_and_roll(roll, ueffective) + uter_ret = _validate_date_and_roll(roll, utermination) + if isinstance(ueff_ret, _InvalidSchedule): + return ueff_ret + elif isinstance(uter_ret, _InvalidSchedule): + return uter_ret return _ValidSchedule(ueffective, utermination, NoInput(0), NoInput(0), frequency, roll, eom) +def _validate_date_and_roll(roll: int | str, date: datetime) -> _InvalidSchedule | None: + roll = "eom" if roll == 31 else roll + if isinstance(roll, str) and not _IS_ROLL[roll.lower()](date): + return _InvalidSchedule(f"Non-{roll} effective date with {roll} rolls.") + elif isinstance(roll, int): + if roll in [29, 30]: + if date.day != roll and not (date.month == 2 and _is_eom(date)): + return _InvalidSchedule(f"Effective date not aligned with {roll} rolls.") + else: + if date.day != roll: + return _InvalidSchedule(f"Termination date not aligned with {roll} rolls.") + return None + + def _check_regular_swap( effective: datetime, termination: datetime, diff --git a/python/rateslib/solver.py b/python/rateslib/solver.py index a9372d14..37160f4f 100644 --- a/python/rateslib/solver.py +++ b/python/rateslib/solver.py @@ -1,7 +1,7 @@ from __future__ import annotations import warnings -from collections.abc import Callable, Sequence +from collections.abc import Callable from itertools import combinations from time import time from typing import Any, ParamSpec @@ -12,14 +12,16 @@ from pandas.errors import PerformanceWarning from rateslib import defaults -from rateslib.curves import CompositeCurve, MultiCsaCurve, ProxyCurve +from rateslib.curves import CompositeCurve, Curve, MultiCsaCurve, ProxyCurve from rateslib.default import NoInput, _validate_states, _WithState -from rateslib.dual import Dual, Dual2, DualTypes, dual_log, dual_solve, gradient +from rateslib.dual import Dual, Dual2, dual_log, dual_solve, gradient +from rateslib.dual.newton import _solver_result from rateslib.fx import FXForwards, FXRates # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. +from rateslib.fx_volatility import FXVols P = ParamSpec("P") @@ -68,7 +70,7 @@ def J2(self): if self._J2 is None: if self._ad != 2: raise ValueError( - "Cannot perform second derivative calculations when ad mode is " f"{self._ad}.", + f"Cannot perform second derivative calculations when ad mode is {self._ad}.", ) rates = np.array([_[0].rate(*_[1], **_[2]) for _ in self.instruments]) @@ -235,7 +237,7 @@ def J2_pre(self): if self._J2_pre is None: if self._ad != 2: raise ValueError( - "Cannot perform second derivative calculations when ad mode is " f"{self._ad}.", + f"Cannot perform second derivative calculations when ad mode is {self._ad}.", ) J2 = np.zeros(shape=(self.pre_n, self.pre_n, self.pre_m)) @@ -921,7 +923,7 @@ def __init__( algorithm: str | NoInput = NoInput(0), fx: FXForwards | FXRates | NoInput = NoInput(0), instrument_labels: tuple[str] | list[str] | NoInput = NoInput(0), - id: str | NoInput = NoInput(0), + id: str | NoInput = NoInput(0), # noqa: A002 pre_solvers: tuple[Solver] | list[Solver] = (), max_iter: int = 100, func_tol: float = 1e-11, @@ -950,8 +952,7 @@ def __init__( # validate `s` and `instruments` with a naive length comparison if len(s) != len(instruments): raise ValueError( - f"`s: {len(s)}` (rates) must be same length as " - f"`instruments: {len(instruments)}`." + f"`s: {len(s)}` (rates) must be same length as `instruments: {len(instruments)}`." ) self.s = np.asarray(s) @@ -992,7 +993,7 @@ def __init__( self.n = len(self.variables) # aggregate and organise variables and labels including pre_solvers - self.pre_curves = {} + self.pre_curves: dict[str, Curve] = {} self.pre_variables = () self.pre_instrument_labels = () self.pre_instruments = () @@ -1059,6 +1060,33 @@ def _set_new_state(self): _ = hash(self._state_fx + self._state_curves + self._state_pre_curves) self._state = _ + def _validate_state(self) -> None: + _objects_state = self._get_composited_state() + if self._state != _objects_state: + # then something has been mutated + if not isinstance(self.fx, NoInput) and self._state_fx != self.fx._state: + warnings.warn( + "The `fx` object associated with `solver` has been updated without " + "the `solver` performing additional iterations.\nCalculations can still be " + "performed but, dependent upon those updates, errors may be negligible " + "or significant.", + UserWarning, + ) + if self._state_curves != self._get_composited_curves_state(): + raise ValueError( + "The `curves` associated with `solver` have been updated without the " + "`solver` performing additional iterations.\nCalculations are prevented in " + "this state because they will likely be erroneous or a consequence of a bad " + "design pattern." + ) + if self._state_pre_curves != self._get_composited_pre_curves_state(): + raise ValueError( + "The `curves` associated with the `pre_solvers` have been updated without the " + "`solver` performing additional iterations.\nCalculations are prevented in " + "this state because they will likely be erroneous or a consequence of a " + "bad design pattern." + ) + def _get_composited_fx_state(self) -> int: if isinstance(self.fx, NoInput): return 0 @@ -1172,6 +1200,32 @@ def _reset_properties_(self, dual2_only=False): # self._grad_v_v_f = None # self._Jkm = None # keep manifold originally used for exploring J2 calc method + @_validate_states + def _get_pre_curve(self, obj: str) -> Curve: + _: Curve | FXVols = self.pre_curves[obj] + if isinstance(_, Curve): + return _ + else: + raise ValueError( + f"A type of `Curve` object was sought with id:'{obj}' from Solver but another " + f"type object was returned:'{type(_)}'." + ) + + @_validate_states + def _get_pre_fxvol(self, obj: str) -> FXVols: + _: Curve | FXVols = self.pre_curves[obj] + if isinstance(_, FXVols): + return _ + else: + raise ValueError( + f"A type of `FXVol` object was sought with id:'{obj}' from Solver but another " + f"type object was returned:'{type(_)}'." + ) + + @_validate_states + def _get_fx(self) -> FXRates | FXForwards | NoInput: + return self.fx + @property def result(self): """ @@ -1400,14 +1454,14 @@ def _update_curves_with_parameters(self, v_new): for curve in self.curves.values(): # this was amended in PR126 as performance improvement to keep consistent `vars` # and was restructured in PR## to decouple methods to accomodate vol surfaces - vars = curve.n - curve._ini_solve - curve._set_node_vector(v_new[var_counter : var_counter + vars], self._ad) - var_counter += vars + n_vars = curve.n - curve._ini_solve + curve._set_node_vector(v_new[var_counter : var_counter + n_vars], self._ad) + var_counter += n_vars self._update_fx() self._reset_properties_() - def _set_ad_order(self, order): + def _set_ad_order(self, order: int) -> None: """Defines the node DF in terms of float, Dual or Dual2 for AD order calcs.""" for pre_solver in self.pre_solvers: pre_solver._set_ad_order(order=order) @@ -1959,7 +2013,7 @@ def jacobian(self, solver: Solver): def exo_delta( self, npv, - vars: list[str], + vars: list[str], # noqa: A002 vars_scalar: list[float] | NoInput = NoInput(0), vars_labels: list[str] | NoInput = NoInput(0), base: str | NoInput = NoInput(0), @@ -2037,430 +2091,12 @@ def exo_delta( sorted_cols = df.columns.sort_values() return df.loc[:, sorted_cols].astype("float64") - def _validate_state(self): - _objects_state = self._get_composited_state() - if self._state != _objects_state: - # then something has been mutated - if not isinstance(self.fx, NoInput) and self._state_fx != self.fx._state: - warnings.warn( - "The `fx` object associated with `solver` has been updated without " - "the `solver` performing additional iterations.\nCalculations can still be " - "performed but, dependent upon those updates, errors may be negligible " - "or significant.", - UserWarning, - ) - if self._state_curves != self._get_composited_curves_state(): - raise ValueError( - "The `curves` associated with `solver` have been updated without the " - "`solver` performing additional iterations.\nCalculations are prevented in " - "this state because they will likely be erroneous or a consequence of a bad " - "design pattern." - ) - if self._state_pre_curves != self._get_composited_pre_curves_state(): - raise ValueError( - "The `curves` associated with the `pre_solvers` have been updated without the " - "`solver` performing additional iterations.\nCalculations are prevented in " - "this state because they will likely be erroneous or a consequence of a " - "bad design pattern." - ) - # Licence: Creative Commons - Attribution-NonCommercial-NoDerivatives 4.0 International # Commercial use of this code, and/or copying and redistribution is prohibited. # Contact rateslib at gmail.com if this code is observed outside its intended sphere. -def _float_if_not_string(x): - if not isinstance(x, str): - return float(x) - return x - - -def newton_1dim( - f: Callable[P, tuple[Any, Any]], - g0: DualTypes, - max_iter: int = 50, - func_tol: float = 1e-14, - conv_tol: float = 1e-9, - args: tuple[Any, ...] = (), - pre_args: tuple[Any, ...] = (), - final_args: tuple[Any, ...] = (), - raise_on_fail: bool = True, -) -> dict[str, Any]: - """ - Use the Newton-Raphson algorithm to determine the root of a function searching **one** variable. - - Parameters - ---------- - f: callable - The function, *f*, to find the root of. Of the signature: `f(g, *args)`. - Must return a tuple where the second value is the derivative of *f* with respect to *g*. - g0: DualTypes - Initial guess of the root. Should be reasonable to avoid failure. - max_iter: int - The maximum number of iterations to try before exiting. - func_tol: float, optional - The absolute function tolerance to reach before exiting. - conv_tol: float, optional - The convergence tolerance for subsequent iterations of *g*. - args: tuple of float, Dual or Dual2 - Additional arguments passed to ``f``. - pre_args: tuple - Additional arguments passed to ``f`` used only in the float solve section of - the algorithm. - Functions are called with the signature `f(g, *(*args[as float], *pre_args))`. - final_args: tuple of float, Dual, Dual2 - Additional arguments passed to ``f`` in the final iteration of the algorithm - to capture AD sensitivities. - Functions are called with the signature `f(g, *(*args, *final_args))`. - raise_on_fail: bool, optional - If *False* will return a solver result dict with state and message indicating failure. - - Returns - ------- - dict - - Notes - ------ - Solves the root equation :math:`f(g; s_i)=0` for *g*. This method is AD-safe, meaning the - iteratively determined solution will preserve AD sensitivities, if the functions are suitable. - Functions which are not AD suitable, such as discontinuous functions or functions with - no derivative at given points, may yield spurious derivative results. - - This method works by first solving in the domain of floats (which is typically faster - for most complex functions), and then performing final iterations in higher AD modes to - capture derivative sensitivities. - - For special cases arguments can be passed separately to each of these modes using the - ``pre_args`` and ``final_args`` arguments, rather than generically supplying it to ``args``. - - Examples - -------- - Iteratively solve the equation: :math:`f(g, s) = g^2 - s = 0`. This has solution - :math:`g=\\pm \\sqrt{s}` and :math:`\\frac{dg}{ds} = \\frac{1}{2 \\sqrt{s}}`. - Thus for :math:`s=2` we expect the solution :code:`g=Dual(1.41.., ["s"], [0.35..])`. - - .. ipython:: python - - from rateslib.solver import newton_1dim - - def f(g, s): - f0 = g**2 - s # Function value - f1 = 2*g # Analytical derivative is required - return f0, f1 - - s = Dual(2.0, ["s"], []) - newton_1dim(f, g0=1.0, args=(s,)) - """ - t0 = time() - i = 0 - - # First attempt solution using faster float calculations - float_args = tuple(_float_if_not_string(_) for _ in args) - g0 = float(g0) - state = -1 - - while i < max_iter: - f0, f1 = f(g0, *(*float_args, *pre_args)) - i += 1 - g1 = g0 - f0 / f1 - if abs(f0) < func_tol: - state = 2 - break - elif abs(g1 - g0) < conv_tol: - state = 1 - break - g0 = g1 - - if i == max_iter: - if raise_on_fail: - raise ValueError(f"`max_iter`: {max_iter} exceeded in 'newton_1dim' algorithm'.") - else: - return _solver_result(-1, i, g1, time() - t0, log=True, algo="newton_1dim") - - # # Final iteration method to preserve AD - f0, f1 = f(g1, *(*args, *final_args)) - if isinstance(f0, Dual | Dual2) or isinstance(f1, Dual | Dual2): - i += 1 - g1 = g1 - f0 / f1 - if isinstance(f0, Dual2) or isinstance(f1, Dual2): - f0, f1 = f(g1, *(*args, *final_args)) - i += 1 - g1 = g1 - f0 / f1 - - # # Analytical approach to capture AD sensitivities - # f0, f1 = f(g1, *(*args, *final_args)) - # if isinstance(f0, Dual): - # g1 = Dual.vars_from(f0, float(g1), f0.vars, float(f1) ** -1 * -gradient(f0)) - # if isinstance(f0, Dual2): - # g1 = Dual2.vars_from(f0, float(g1), f0.vars, float(f1) ** -1 * -gradient(f0), []) - # f02, f1 = f(g1, *(*args, *final_args)) - # - # #f0_beta = gradient(f0, order=1, vars=f0.vars, keep_manifold=True) - # - # f0_gamma = gradient(f02, order=2) - # f0_beta = gradient(f0, order=1) - # # f1 = set_order_convert(g1, tag=[], order=2) - # f1_gamma = gradient(f1, f0.vars, order=2) - # f1_beta = gradient(f1, f0.vars, order=1) - # - # g1_beta = -float(f1) ** -1 * f0_beta - # g1_gamma = ( - # -float(f1)**-1 * f0_gamma + - # float(f1)**-2 * ( - # np.matmul(f0_beta[:, None], f1_beta[None, :]) + - # np.matmul(f1_beta[:, None], f0_beta[None, :]) + - # float(f0) * f1_gamma - # ) - - # 2 * float(f1)**-3 * float(f0) * np.matmul(f1_beta[:, None], f1_beta[None, :]) - # ) - # g1 = Dual2.vars_from(f0, float(g1), f0.vars, g1_beta, g1_gamma.flatten()) - - return _solver_result(state, i, g1, time() - t0, log=False, algo="newton_1dim") - - -def newton_ndim( - f: Callable[P, tuple[Any, Any]], - g0: Sequence[DualTypes], - max_iter: int = 50, - func_tol: float = 1e-14, - conv_tol: float = 1e-9, - args: tuple[Any, ...] = (), - pre_args: tuple[Any, ...] = (), - final_args: tuple[Any, ...] = (), - raise_on_fail: bool = True, -) -> dict[str, Any]: - r""" - Use the Newton-Raphson algorithm to determine a function root searching **many** variables. - - Solves the *n* root equations :math:`f_i(g_1, \hdots, g_n; s_k)=0` for each :math:`g_j`. - - Parameters - ---------- - f: callable - The function, *f*, to find the root of. Of the signature: `f([g_1, .., g_n], *args)`. - Must return a tuple where the second value is the Jacobian of *f* with respect to *g*. - g0: Sequence of DualTypes - Initial guess of the root values. Should be reasonable to avoid failure. - max_iter: int - The maximum number of iterations to try before exiting. - func_tol: float, optional - The absolute function tolerance to reach before exiting. - conv_tol: float, optional - The convergence tolerance for subsequent iterations of *g*. - args: tuple of float, Dual or Dual2 - Additional arguments passed to ``f``. - pre_args: tuple - Additional arguments passed to ``f`` only in the float solve section - of the algorithm. - Functions are called with the signature `f(g, *(*args[as float], *pre_args))`. - final_args: tuple of float, Dual, Dual2 - Additional arguments passed to ``f`` in the final iteration of the algorithm - to capture AD sensitivities. - Functions are called with the signature `f(g, *(*args, *final_args))`. - raise_on_fail: bool, optional - If *False* will return a solver result dict with state and message indicating failure. - - Returns - ------- - dict - - Examples - -------- - Iteratively solve the equation system: - - - :math:`f_0(\mathbf{g}, s) = g_1^2 + g_2^2 + s = 0`. - - :math:`f_1(\mathbf{g}, s) = g_1^2 - 2g_2^2 + s = 0`. - - .. ipython:: python - - from rateslib.solver import newton_ndim - - def f(g, s): - # Function value - f0 = g[0] ** 2 + g[1] ** 2 + s - f1 = g[0] ** 2 - 2 * g[1]**2 - s - # Analytical derivative as Jacobian matrix is required - f00 = 2 * g[0] - f01 = 2 * g[1] - f10 = 2 * g[0] - f11 = -4 * g[1] - return [f0, f1], [[f00, f01], [f10, f11]] - - s = Dual(-2.0, ["s"], []) - newton_ndim(f, g0=[1.0, 1.0], args=(s,)) - """ - t0 = time() - i = 0 - n = len(g0) - - # First attempt solution using faster float calculations - float_args = tuple(_float_if_not_string(_) for _ in args) - g0 = np.array([float(_) for _ in g0]) - state = -1 - - while i < max_iter: - f0, f1 = f(g0, *(*float_args, *pre_args)) - f0 = np.array(f0)[:, np.newaxis] - f1 = np.array(f1) - - i += 1 - g1 = g0 - np.matmul(np.linalg.inv(f1), f0)[:, 0] - if all(abs(_) < func_tol for _ in f0[:, 0]): - state = 2 - break - elif all(abs(g1[_] - g0[_]) < conv_tol for _ in range(n)): - state = 1 - break - g0 = g1 - - if i == max_iter: - if raise_on_fail: - raise ValueError(f"`max_iter`: {max_iter} exceeded in 'newton_ndim' algorithm'.") - else: - return _solver_result(-1, i, g1, time() - t0, log=True, algo="newton_ndim") - - # Final iteration method to preserve AD - f0, f1 = f(g1, *(*args, *final_args)) - f1, f0 = np.array(f1), np.array(f0) - - # get AD type - ad = 0 - if _is_any_dual(f0) or _is_any_dual(f1): - ad, DualType = 1, Dual - elif _is_any_dual2(f0) or _is_any_dual2(f1): - ad, DualType = 2, Dual2 - - if ad > 0: - i += 1 - g1 = g0 - dual_solve(f1, f0[:, None], allow_lsq=False, types=(DualType, DualType))[:, 0] - if ad == 2: - f0, f1 = f(g1, *(*args, *final_args)) - f1, f0 = np.array(f1), np.array(f0) - i += 1 - g1 = g1 - dual_solve(f1, f0[:, None], allow_lsq=False, types=(DualType, DualType))[:, 0] - - return _solver_result(state, i, g1, time() - t0, log=False, algo="newton_ndim") - - -STATE_MAP = { - 1: ["SUCCESS", "`conv_tol` reached"], - 2: ["SUCCESS", "`func_tol` reached"], - 3: ["SUCCESS", "closed form valid"], - -1: ["FAILURE", "`max_iter` breached"], -} - - -def _solver_result( - state: int, i: int, func_val: float, time: float, log: bool, algo: str -) -> dict[str, Any]: - if log: - print( - f"{STATE_MAP[state][0]}: {STATE_MAP[state][1]} after {i} iterations " - f"({algo}), `f_val`: {func_val}, " - f"`time`: {time:.4f}s", - ) - return { - "status": STATE_MAP[state][0], - "state": state, - "g": func_val, - "iterations": i, - "time": time, - } - - -def _is_any_dual(arr): - return any(isinstance(_, Dual) for _ in arr.flatten()) - - -def _is_any_dual2(arr): - return any(isinstance(_, Dual2) for _ in arr.flatten()) - - -def quadratic_eqn(a, b, c, x0, raise_on_fail=True): - """ - Solve the quadratic equation, :math:`ax^2 + bx +c = 0`, with error reporting. - - Parameters - ---------- - a: float, Dual Dual2 - The *a* coefficient value. - b: float, Dual Dual2 - The *b* coefficient value. - c: float, Dual Dual2 - The *c* coefficient value. - x0: float - The expected solution to discriminate between two possible solutions. - raise_on_fail: bool, optional - Whether to raise if unsolved or return a solver result in failed state. - - Returns - ------- - dict - - Notes - ----- - If ``a`` is evaluated to be less that 1e-15 in absolute terms then it is treated as zero and the - equation is solved as a linear equation in ``b`` and ``c`` only. - - Examples - -------- - .. ipython:: python - - from rateslib.solver import quadratic_eqn - - quadratic_eqn(a=1.0, b=1.0, c=Dual(-6.0, ["c"], []), x0=-2.9) - - """ - discriminant = b**2 - 4 * a * c - if discriminant < 0.0: - if raise_on_fail: - raise ValueError("`quadratic_eqn` has failed to solve: discriminant is less than zero.") - else: - return _solver_result( - state=-1, - i=0, - func_val=1e308, - time=0.0, - log=True, - algo="quadratic_eqn", - ) - - if abs(a) > 1e-15: # machine tolerance on normal float64 is 2.22e-16 - sqrt_d = discriminant**0.5 - _1 = (-b + sqrt_d) / (2 * a) - _2 = (-b - sqrt_d) / (2 * a) - if abs(x0 - _1) < abs(x0 - _2): - return _solver_result( - state=3, - i=1, - func_val=_1, - time=0.0, - log=False, - algo="quadratic_eqn", - ) - else: - return _solver_result( - state=3, - i=1, - func_val=_2, - time=0.0, - log=False, - algo="quadratic_eqn", - ) - else: - # 'a' is considered too close to zero for the quadratic eqn, solve the linear eqn - # to avoid division by zero errors - return _solver_result( - state=3, - i=1, - func_val=-c / b, - time=0.0, - log=False, - algo="quadratic_eqn->linear_eqn", - ) - - # def _brents(f, x0, x1, max_iter=50, tolerance=1e-9): # """ # Alternative root solver. Used for solving premium adjutsed option strikes from delta values. diff --git a/python/rateslib/splines.py b/python/rateslib/splines.py index b54dbf81..4b66263b 100644 --- a/python/rateslib/splines.py +++ b/python/rateslib/splines.py @@ -1,10 +1,15 @@ from __future__ import annotations +from typing import TYPE_CHECKING + from rateslib import defaults -from rateslib.dual import Dual, Dual2, DualTypes, Variable +from rateslib.dual import Dual, Dual2, Variable from rateslib.rs import PPSplineDual, PPSplineDual2, PPSplineF64, bspldnev_single, bsplev_single from rateslib.rs import PPSplineF64 as PPSpline +if TYPE_CHECKING: + from rateslib.typing import DualTypes, Number + # for legacy reasons allow a PPSpline class which allows only f64 datatypes. # TODO: (depr) remove this for version 2.0 @@ -19,7 +24,7 @@ def evaluate( spline: PPSplineF64 | PPSplineDual | PPSplineDual2, x: DualTypes, m: int = 0, -) -> float | Dual | Dual2: +) -> Number: """ Evaluate a single x-axis data point, or a derivative value, on a *Spline*. diff --git a/python/rateslib/typing.py b/python/rateslib/typing.py new file mode 100644 index 00000000..0d69a836 --- /dev/null +++ b/python/rateslib/typing.py @@ -0,0 +1,137 @@ +# This module is reserved only for typing purposes. +# 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 Sequence as Sequence +from datetime import datetime as datetime +from typing import Any as Any +from typing import NoReturn as NoReturn +from typing import TypeAlias + +import numpy as np +from pandas import DataFrame as DataFrame +from pandas import Series as Series + +from rateslib.default import NoInput as NoInput +from rateslib.dual.variable import Variable as Variable +from rateslib.fx import FXForwards as FXForwards +from rateslib.fx import FXRates as FXRates +from rateslib.fx_volatility import FXDeltaVolSmile as FXDeltaVolSmile +from rateslib.fx_volatility import FXDeltaVolSurface as FXDeltaVolSurface +from rateslib.instruments import CDS as CDS +from rateslib.instruments import FRA as FRA +from rateslib.instruments import IIRS as IIRS +from rateslib.instruments import IRS as IRS +from rateslib.instruments import SBS as SBS +from rateslib.instruments import XCS as XCS +from rateslib.instruments import ZCIS as ZCIS +from rateslib.instruments import ZCS as ZCS +from rateslib.instruments import Bill as Bill +from rateslib.instruments import FixedRateBond as FixedRateBond +from rateslib.instruments import FloatRateNote as FloatRateNote +from rateslib.instruments import FXBrokerFly as FXBrokerFly +from rateslib.instruments import FXCall as FXCall +from rateslib.instruments import FXExchange as FXExchange +from rateslib.instruments import FXOptionStrat as FXOptionStrat +from rateslib.instruments import FXPut as FXPut +from rateslib.instruments import FXRiskReversal as FXRiskReversal +from rateslib.instruments import FXStraddle as FXStraddle +from rateslib.instruments import FXStrangle as FXStrangle +from rateslib.instruments import FXSwap as FXSwap +from rateslib.instruments import IndexFixedRateBond as IndexFixedRateBond +from rateslib.instruments import STIRFuture as STIRFuture +from rateslib.legs import CreditPremiumLeg as CreditPremiumLeg +from rateslib.legs import CreditProtectionLeg as CreditProtectionLeg +from rateslib.legs import FixedLeg as FixedLeg +from rateslib.legs import FloatLeg as FloatLeg +from rateslib.legs import IndexFixedLeg as IndexFixedLeg +from rateslib.legs import ZeroFixedLeg as ZeroFixedLeg +from rateslib.legs import ZeroFloatLeg as ZeroFloatLeg +from rateslib.legs import ZeroIndexLeg as ZeroIndexLeg +from rateslib.periods import Cashflow as Cashflow +from rateslib.periods import CreditPremiumPeriod as CreditPremiumPeriod +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 IndexCashflow as IndexCashflow +from rateslib.periods import IndexFixedPeriod as IndexFixedPeriod +from rateslib.rs import ( + Cal, + Dual, + Dual2, + FlatBackwardInterpolator, + FlatForwardInterpolator, + LinearInterpolator, + LinearZeroRateInterpolator, + LogLinearInterpolator, + NamedCal, + NullInterpolator, + UnionCal, +) +from rateslib.solver import Solver as Solver + +Solver_: TypeAlias = "Solver | NoInput" + +CalTypes: TypeAlias = "Cal | UnionCal | NamedCal" +CalInput: TypeAlias = "CalTypes | str | NoInput" + +DualTypes: TypeAlias = "float | Dual | Dual2 | Variable" +Number: TypeAlias = "float | Dual | Dual2" + +# https://stackoverflow.com/questions/68916893/ +Arr1dF64: TypeAlias = "np.ndarray[tuple[int], np.dtype[np.float64]]" +Arr2dF64: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.float64]]" +Arr1dObj: TypeAlias = "np.ndarray[tuple[int], np.dtype[np.object_]]" +Arr2dObj: TypeAlias = "np.ndarray[tuple[int, int], np.dtype[np.object_]]" + +FixingsRates: TypeAlias = "Series[DualTypes] | list[DualTypes | list[DualTypes] | Series[DualTypes] | NoInput] | tuple[DualTypes, Series[DualTypes]] | DualTypes | NoInput" + +str_: TypeAlias = "str | NoInput" + +from rateslib.curves import Curve as Curve # noqa: E402 + +Curve_: TypeAlias = "Curve | NoInput" +CurveDict: TypeAlias = "dict[str, Curve | str] | dict[str, Curve] | dict[str, str]" + +CurveOrId: TypeAlias = "Curve | str" +CurveOrId_: TypeAlias = "CurveOrId | NoInput" + +CurveInput: TypeAlias = "CurveOrId | CurveDict" +CurveInput_: TypeAlias = "CurveInput | NoInput" + +CurveOption: TypeAlias = "Curve | dict[str, Curve]" +CurveOption_: TypeAlias = "CurveOption | NoInput" + +Curves: TypeAlias = "CurveOrId | CurveDict | list[CurveOrId | CurveDict]" +Curves_: TypeAlias = "CurveOrId_ | CurveDict | list[CurveOrId_ | CurveDict]" + +Curves_Tuple: TypeAlias = "tuple[CurveOption_, CurveOption_, CurveOption_, CurveOption_]" +Curves_DiscTuple: TypeAlias = "tuple[CurveOption_, Curve_, CurveOption_, Curve_]" + +Vol_: TypeAlias = "DualTypes | FXDeltaVolSmile | FXDeltaVolSurface | str" +Vol: TypeAlias = "Vol_ | NoInput" + +VolInput_: TypeAlias = "str | FXDeltaVolSmile | FXDeltaVolSurface" +VolInput: TypeAlias = "VolInput_ | NoInput" + +VolOption_: TypeAlias = "FXDeltaVolSmile | DualTypes | FXDeltaVolSurface" +VolOption: TypeAlias = "VolOption_ | NoInput" + +FX: TypeAlias = "DualTypes | FXRates | FXForwards" +FX_: TypeAlias = "FX | NoInput" + +NPV: TypeAlias = "DualTypes | dict[str, DualTypes]" + +CurveInterpolator: TypeAlias = "FlatBackwardInterpolator | FlatForwardInterpolator | LinearInterpolator | LogLinearInterpolator | LinearZeroRateInterpolator | NullInterpolator" +Leg: TypeAlias = "FixedLeg | FloatLeg | IndexFixedLeg | ZeroFloatLeg | ZeroFixedLeg | ZeroIndexLeg | CreditPremiumLeg | CreditProtectionLeg" +Period: TypeAlias = "FixedPeriod | FloatPeriod | Cashflow | IndexFixedPeriod | IndexCashflow | CreditPremiumPeriod | CreditProtectionPeriod" + +Security: TypeAlias = "FixedRateBond | FloatRateNote | Bill | IndexFixedRateBond" +FXOptionTypes: TypeAlias = ( + "FXCall | FXPut | FXRiskReversal | FXStraddle | FXStrangle | FXBrokerFly | FXOptionStrat" +) +RatesDerivative: TypeAlias = "IRS | SBS | FRA | ZCS | STIRFuture" +IndexDerivative: TypeAlias = "IIRS | ZCIS" +CurrencyDerivative: TypeAlias = "XCS | FXSwap | FXExchange" + +Instrument: TypeAlias = "Security | FXOptionTypes | RatesDerivative | CDS | CurrencyDerivative" diff --git a/python/tests/test_calendars.py b/python/tests/test_calendars.py index d0fbce8f..d111cf0e 100644 --- a/python/tests/test_calendars.py +++ b/python/tests/test_calendars.py @@ -549,7 +549,7 @@ def test_add_and_get_custom_calendar() -> None: @pytest.mark.parametrize( - ("eval", "delivery", "expiry", "expected_expiry"), + ("evald", "delivery", "expiry", "expected_expiry"), [ (dt(2024, 5, 2), 2, "2m", dt(2024, 7, 4)), (dt(2024, 4, 30), 2, "2m", dt(2024, 7, 1)), @@ -557,9 +557,9 @@ def test_add_and_get_custom_calendar() -> None: (dt(2024, 5, 31), 2, "2w", dt(2024, 6, 14)), ], ) -def test_expiries_delivery(eval, delivery, expiry, expected_expiry) -> None: +def test_expiries_delivery(evald, delivery, expiry, expected_expiry) -> None: result_expiry, _ = _get_fx_expiry_and_delivery( - eval, + evald, expiry, delivery, "tgt|fed", diff --git a/python/tests/test_curvesrs.py b/python/tests/test_curvesrs.py index f186162b..a1acb03d 100644 --- a/python/tests/test_curvesrs.py +++ b/python/tests/test_curvesrs.py @@ -15,7 +15,8 @@ _get_convention_str, _get_interpolator, ) -from rateslib.dual import ADOrder, Dual2, _get_adorder +from rateslib.dual import Dual2 +from rateslib.dual.utils import ADOrder, _get_adorder from rateslib.json import from_json from rateslib.rs import Convention @@ -117,8 +118,8 @@ def test_pickle_interpolator(name) -> None: import pickle obj = _get_interpolator(name) - bytes = pickle.dumps(obj) - pickle.loads(bytes) + bytes_ = pickle.dumps(obj) + pickle.loads(bytes_) def test_get_interpolation(curve) -> None: diff --git a/python/tests/test_dual.py b/python/tests/test_dual.py index 97ece8a5..f2f4b969 100644 --- a/python/tests/test_dual.py +++ b/python/tests/test_dual.py @@ -9,7 +9,6 @@ Dual, Dual2, Variable, - _abs_float, dual_exp, dual_inv_norm_cdf, dual_log, @@ -19,6 +18,7 @@ gradient, set_order, ) +from rateslib.dual.utils import _abs_float DUAL_CORE_PY = False @@ -128,29 +128,29 @@ def test_dual_str(x_1, y_2) -> None: @pytest.mark.parametrize( - ("vars", "expected"), + ("vars_", "expected"), [ (["v0"], 1.00), (["v1", "v0"], np.array([2.0, 1.0])), ], ) -def test_gradient_method(vars, expected, x_1, y_2) -> None: - result = gradient(x_1, vars) +def test_gradient_method(vars_, expected, x_1, y_2) -> None: + result = gradient(x_1, vars_) assert np.all(result == expected) - result = gradient(y_2, vars) + result = gradient(y_2, vars_) assert np.all(result == expected) @pytest.mark.parametrize( - ("vars", "expected"), + ("vars_", "expected"), [ (["v0"], 2.00), (["v1", "v0"], np.array([[2.0, 2.0], [2.0, 2.0]])), ], ) -def test_gradient_method2(vars, expected, y_2) -> None: - result = gradient(y_2, vars, 2) +def test_gradient_method2(vars_, expected, y_2) -> None: + result = gradient(y_2, vars_, 2) assert np.all(result == expected) @@ -250,6 +250,18 @@ def test_dual_raises(x_1) -> None: x_1.dual2 +def test_dual_is_not_iterable(x_1, y_1): + # do not want isinstance checks for Dual to identify them as a Sequence kind + assert getattr(x_1, "__iter__", None) is None + assert getattr(y_1, "__iter__", None) is None + + +def test_dual_has_no_len(x_1, y_1): + # do not want isinstance checks for Dual to identify them as a Sequence kind + assert getattr(x_1, "__len__", None) is None + assert getattr(y_1, "__len__", None) is None + + @pytest.mark.parametrize( ("op", "expected"), [ diff --git a/python/tests/test_dualpy.py b/python/tests/test_dualpy.py index 8777d030..76c6776c 100644 --- a/python/tests/test_dualpy.py +++ b/python/tests/test_dualpy.py @@ -7,7 +7,6 @@ from rateslib.dual import ( Dual, Dual2, - _abs_float, dual_exp, dual_inv_norm_cdf, dual_log, @@ -16,6 +15,7 @@ gradient, set_order, ) +from rateslib.dual.utils import _abs_float @pytest.fixture diff --git a/python/tests/test_dualrs.py b/python/tests/test_dualrs.py index a55d0eea..65b9cb13 100644 --- a/python/tests/test_dualrs.py +++ b/python/tests/test_dualrs.py @@ -69,14 +69,14 @@ def test_dual_str(x_1) -> None: @pytest.mark.skipif(DUAL_CORE_PY, reason="Gradient comparison cannot compare Py and Rs Duals.") @pytest.mark.parametrize( - ("vars", "expected"), + ("vars_", "expected"), [ (["v1"], 2.00), (["v1", "v0"], np.array([2.0, 1.0])), ], ) -def test_gradient_method(vars, expected, x_1) -> None: - result = gradient(x_1, vars) +def test_gradient_method(vars_, expected, x_1) -> None: + result = gradient(x_1, vars_) assert np.all(result == expected) diff --git a/python/tests/test_fx_volatility.py b/python/tests/test_fx_volatility.py index 8faedcf3..ac600bc7 100644 --- a/python/tests/test_fx_volatility.py +++ b/python/tests/test_fx_volatility.py @@ -341,6 +341,19 @@ def test_iter_raises(self) -> None: with pytest.raises(TypeError, match="`FXDeltaVolSmile` is not iterable."): fxvs.__iter__() + def test_update_node(self): + fxvs = FXDeltaVolSmile( + nodes={0.5: 1.0}, + delta_type="forward", + eval_date=dt(2023, 3, 16), + expiry=dt(2023, 6, 16), + ) + with pytest.raises(KeyError, match=r"`key` is not in Curve ``nodes``"): + fxvs.update_node(0.4, 10.0) + + fxvs.update_node(0.5, 12.0) + assert fxvs[0.5] == 12.0 + class TestFXDeltaVolSurface: def test_expiry_before_eval(self) -> None: @@ -604,7 +617,7 @@ def test_cache_clear_and_defaults(self): fxvs.get_smile(dt(2024, 7, 1)) assert dt(2024, 7, 1) in fxvs._cache - fxvs.clear_cache() + fxvs._clear_cache() assert dt(2024, 7, 1) not in fxvs._cache with default_context("curve_caching", False): diff --git a/python/tests/test_instruments.py b/python/tests/test_instruments.py index b29ce8a4..677557cb 100644 --- a/python/tests/test_instruments.py +++ b/python/tests/test_instruments.py @@ -7,6 +7,7 @@ from rateslib import default_context from rateslib.calendars import add_tenor from rateslib.curves import CompositeCurve, Curve, IndexCurve, LineCurve, MultiCsaCurve +from rateslib.curves._parsers import _map_curve_from_solver from rateslib.default import NoInput from rateslib.dual import Dual, Dual2, dual_exp, gradient from rateslib.fx import FXForwards, FXRates @@ -39,8 +40,7 @@ Value, VolValue, ) -from rateslib.instruments.inst_core import ( - _get_curve_from_solver, +from rateslib.instruments.utils import ( _get_curves_fx_and_base_maybe_from_solver, ) from rateslib.solver import Solver @@ -169,30 +169,30 @@ def test_get_curve_from_solver(self) -> None: inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] solver = Solver([curve], [], inst, [0.975]) - result = _get_curve_from_solver("tagged", solver) + result = _map_curve_from_solver("tagged", solver) assert result == curve - result = _get_curve_from_solver(curve, solver) + result = _map_curve_from_solver(curve, solver) assert result == curve no_curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="not in solver") with default_context("curve_not_in_solver", "ignore"): - result = _get_curve_from_solver(no_curve, solver) + result = _map_curve_from_solver(no_curve, solver) assert result == no_curve with pytest.warns(), default_context("curve_not_in_solver", "warn"): - result = _get_curve_from_solver(no_curve, solver) + result = _map_curve_from_solver(no_curve, solver) assert result == no_curve with ( pytest.raises(ValueError, match="`curve` must be in `solver`"), default_context("curve_not_in_solver", "raise"), ): - _get_curve_from_solver(no_curve, solver) + _map_curve_from_solver(no_curve, solver) with pytest.raises(AttributeError, match="`curve` has no attribute `id`, likely it not"): - _get_curve_from_solver(100.0, solver) + _map_curve_from_solver(100.0, solver) @pytest.mark.parametrize("solver", [True, False]) @pytest.mark.parametrize("fxf", [True, False]) @@ -340,7 +340,6 @@ def test_get_curves_from_solver_multiply(self, num) -> None: assert result == (curve, curve, curve, curve) def test_get_proxy_curve_from_solver(self, usdusd, usdeur, eureur) -> None: - # TODO: check whether curves in fxf but not is solver should be allowed??? curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] fxf = FXForwards( @@ -367,6 +366,21 @@ def test_ambiguous_curve_in_out_id_solver_raises(self) -> None: with pytest.raises(ValueError, match="A curve has been supplied, as part of ``curves``,"): irs.npv(curves=curve, solver=solver) + def test_get_multicsa_curve_from_solver(self, usdusd, usdeur, eureur) -> None: + curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="tagged") + inst = [(Value(dt(2023, 1, 1)), ("tagged",), {})] + fxf = FXForwards( + FXRates({"eurusd": 1.05}, settlement=dt(2022, 1, 3)), + {"usdusd": usdusd, "usdeur": usdeur, "eureur": eureur}, + ) + solver = Solver([curve], [], inst, [0.975], fx=fxf) + curve = fxf.curve("eur", ("usd", "eur")) + irs = IRS(dt(2022, 1, 1), "3m", "Q") + + # test the curve will return even though it is not included within the solver + # because it is a proxy curve. + irs.npv(curves=curve, solver=solver) + class TestSolverFXandBase: """ @@ -4015,7 +4029,7 @@ def test_xcs(self) -> None: ( FRA(dt(2022, 1, 15), "3M", "Q", currency="eur", curves=["eureur", "eureur"]), DataFrame( - [0.0], + [0], index=Index([dt(2022, 1, 15)], name="payment"), columns=MultiIndex.from_tuples( [("EUR", "eur")], @@ -4677,7 +4691,7 @@ def test_metric_and_period_metric_compatible(self) -> None: assert abs(result - expected) < 1e-6 @pytest.mark.parametrize( - ("eval", "eom", "expected"), + ("evald", "eom", "expected"), [ ( dt(2024, 4, 26), @@ -4691,11 +4705,11 @@ def test_metric_and_period_metric_compatible(self) -> None: ), # 2bd before 30th May (rolled from 30th April) ], ) - def test_expiry_delivery_tenor_eom(self, eval, eom, expected) -> None: + def test_expiry_delivery_tenor_eom(self, evald, eom, expected) -> None: fxo = FXCall( pair="eurusd", expiry="1m", - eval_date=eval, + eval_date=evald, eom=eom, calendar="tgt|fed", modifier="mf", diff --git a/python/tests/test_instruments_bonds.py b/python/tests/test_instruments_bonds.py index 266cef86..cf05dc76 100644 --- a/python/tests/test_instruments_bonds.py +++ b/python/tests/test_instruments_bonds.py @@ -504,7 +504,7 @@ def test_cadgb_price3(self) -> None: ## German gov bonds comparison with BBG and official bundesbank publications. @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("sett", "price", "exp_ytm", "exp_acc"), [ (dt(2024, 1, 10), 105.0, 1.208836, 0.321311), # BBG BXT ticket data ( @@ -525,7 +525,7 @@ def test_cadgb_price3(self) -> None: (dt(2028, 11, 15), 97.5, 4.717949, 0.0), # YAS ], ) - def test_de_gb(self, set, price, exp_ytm, exp_acc) -> None: + def test_de_gb(self, sett, price, exp_ytm, exp_acc) -> None: frb = FixedRateBond( # ISIN DE0001102622 effective=dt(2022, 10, 20), termination=dt(2029, 11, 15), @@ -533,14 +533,14 @@ def test_de_gb(self, set, price, exp_ytm, exp_acc) -> None: fixed_rate=2.1, spec="de_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=sett) assert abs(result - exp_acc) < 1e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=sett) assert abs(result - exp_ytm) < 1e-6 @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("sett", "price", "exp_ytm", "exp_acc"), [ ( dt(2024, 6, 12), @@ -550,7 +550,7 @@ def test_de_gb(self, set, price, exp_ytm, exp_acc) -> None: ), # https://www.bundesbank.de/en/service/federal-securities/prices-and-yields ], ) - def test_de_gb_mm(self, set, price, exp_ytm, exp_acc) -> None: + def test_de_gb_mm(self, sett, price, exp_ytm, exp_acc) -> None: # tests the MoneyMarket simple yield for the final period. frb = FixedRateBond( # ISIN DE0001102366 effective=dt(2014, 8, 15), @@ -558,39 +558,39 @@ def test_de_gb_mm(self, set, price, exp_ytm, exp_acc) -> None: fixed_rate=1.0, spec="de_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=sett) assert abs(result - exp_acc) < 1e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=sett) assert abs(result - exp_ytm) < 1e-6 ## French OAT @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("sett", "price", "exp_ytm", "exp_acc"), [ (dt(2024, 6, 14), 101.0, 2.886581, 1.655738), # BBG BXT ticket data (dt(2033, 11, 25), 99.75, 3.258145, 0.0), # YAS (dt(2034, 6, 13), 101.0, 0.769200, 1.643836), # BBG BXT ticket data ], ) - def test_fr_gb(self, set, price, exp_ytm, exp_acc) -> None: + def test_fr_gb(self, sett, price, exp_ytm, exp_acc) -> None: frb = FixedRateBond( # ISIN FR001400QMF9 effective=dt(2023, 11, 25), termination=dt(2034, 11, 25), fixed_rate=3.0, spec="fr_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=sett) assert abs(result - exp_acc) < 1e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=sett) assert abs(result - exp_ytm) < 1e-6 ## Italian BTP @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("sett", "price", "exp_ytm", "exp_acc"), [ (dt(2024, 6, 14), 98.0, 4.73006, 0.526090), # BBG BXT ticket data (dt(2033, 3, 15), 99.65, 7.006149, 1.628730), # BBG YAS Yield - Last coupon simple rate @@ -599,7 +599,7 @@ def test_fr_gb(self, set, price, exp_ytm, exp_acc) -> None: (dt(2033, 4, 29), 99.97, 9.623617, 2.175690), # Test accrual upto adjusted payment date ], ) - def test_it_gb(self, set, price, exp_ytm, exp_acc) -> None: + def test_it_gb(self, sett, price, exp_ytm, exp_acc) -> None: # TODO: it is unclear how date modifications affect the pricing of BTPs require offical # source docs. frb = FixedRateBond( # ISIN IT0005518128 @@ -608,16 +608,16 @@ def test_it_gb(self, set, price, exp_ytm, exp_acc) -> None: fixed_rate=4.4, spec="it_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=sett) assert abs(result - exp_acc) < 5e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=sett) assert abs(result - exp_ytm) < 3e-3 ## Norwegian @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("set_", "price", "exp_ytm", "exp_acc"), [ (dt(2026, 4, 13), 99.3, 3.727804, 0.0), # YAS Coupon aligned (dt(2033, 4, 13), 99.9, 3.728729, 0.0), # Last period @@ -631,23 +631,23 @@ def test_it_gb(self, set, price, exp_ytm, exp_acc) -> None: ), # Mid stub period: BBG YAS does not price cashflows correctly ], ) - def test_no_gb(self, set, price, exp_ytm, exp_acc) -> None: + def test_no_gb(self, set_, price, exp_ytm, exp_acc) -> None: frb = FixedRateBond( # ISIN NO0013148338 effective=dt(2024, 2, 13), termination=dt(2034, 4, 13), fixed_rate=3.625, spec="no_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=set_) assert abs(result - exp_acc) < 5e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=set_) assert abs(result - exp_ytm) < 1e-5 ## Dutch @pytest.mark.parametrize( - ("set", "price", "exp_ytm", "exp_acc"), + ("set_", "price", "exp_ytm", "exp_acc"), [ (dt(2025, 6, 10), 98.0, 2.751162, 2.260274), # YAS Coupon aligned (dt(2033, 7, 15), 99.8, 2.705411, 0.0), # Last period @@ -656,17 +656,17 @@ def test_no_gb(self, set, price, exp_ytm, exp_acc) -> None: (dt(2024, 3, 13), 99.0, 2.612194, 0.232240), # Mid stub period ], ) - def test_nl_gb(self, set, price, exp_ytm, exp_acc) -> None: + def test_nl_gb(self, set_, price, exp_ytm, exp_acc) -> None: frb = FixedRateBond( # ISIN NL0015001XZ6 effective=dt(2024, 2, 8), termination=dt(2034, 7, 15), fixed_rate=2.5, spec="nl_gb", ) - result = frb.accrued(settlement=set) + result = frb.accrued(settlement=set_) assert abs(result - exp_acc) < 5e-6 - result = frb.ytm(price=price, settlement=set) + result = frb.ytm(price=price, settlement=set_) assert abs(result - exp_ytm) < 1e-5 # General Method Coverage @@ -1148,6 +1148,18 @@ def test_custom_calc_mode(self): assert bond.price(3.0, dt(2002, 3, 4)) == bond2.price(3.0, dt(2002, 3, 4)) assert bond.accrued(dt(2002, 3, 4)) == bond2.accrued(dt(2002, 3, 4)) + def test_must_have_fixed_rate(self): + with pytest.raises(ValueError, match="`fixed_rate` must be provided for Bond."): + FixedRateBond( + effective=dt(2001, 1, 1), + termination="10y", + frequency="s", + calendar="ldn", + convention="ActActICMA", + modifier="none", + settle=1, + ) + class TestIndexFixedRateBond: def test_fixed_rate_bond_price(self) -> None: diff --git a/python/tests/test_periods.py b/python/tests/test_periods.py index a2de8fda..3946159e 100644 --- a/python/tests/test_periods.py +++ b/python/tests/test_periods.py @@ -84,7 +84,7 @@ def line_curve(): @pytest.mark.parametrize( - "object", + "obj", [ FixedPeriod(dt(2000, 1, 1), dt(2000, 2, 1), dt(2000, 2, 1), frequency="m", fixed_rate=2.0), Cashflow(notional=1e6, payment=dt(2022, 1, 1), currency="usd"), @@ -107,9 +107,9 @@ def line_curve(): ), ], ) -def test_repr(object): - result = object.__repr__() - expected = f"" +def test_repr(obj): + result = obj.__repr__() + expected = f"" assert result == expected @@ -324,7 +324,7 @@ def test_rfr_avg_method_raises(self, curve) -> None: fixing_method="rfr_payment_delay_avg", spread_compound_method="isda_compounding", ) - msg = "`spread_compound` method must be 'none_simple' in an RFR averaging " "period." + msg = "`spread_compound` method must be 'none_simple' in an RFR averaging period." with pytest.raises(ValueError, match=msg): period.rate(curve) @@ -746,10 +746,10 @@ def test_rfr_fixings_array_raises2(self, line_curve) -> None: convention="act365f", notional=-1000000, ) - with pytest.raises(ValueError, match="Must supply a discount factor based `disc_curve`."): + with pytest.raises(ValueError, match="`disc_curve` cannot be inferred from a non-DF"): period.fixings_table(curve=line_curve) - with pytest.raises(ValueError, match="Cannot infer `disc_curve` from a dict of curves."): + with pytest.raises(ValueError, match="`disc_curve` cannot be inferred from a dictionary"): period.fixings_table(curve={"1m": line_curve, "2m": line_curve}) @pytest.mark.parametrize( diff --git a/python/tests/test_solver.py b/python/tests/test_solver.py index 7526bd89..7e5c5a4a 100644 --- a/python/tests/test_solver.py +++ b/python/tests/test_solver.py @@ -9,7 +9,7 @@ from rateslib import default_context from rateslib.curves import CompositeCurve, Curve, LineCurve, index_left from rateslib.default import NoInput -from rateslib.dual import Dual, Dual2, gradient +from rateslib.dual import Dual, Dual2, gradient, newton_1dim, newton_ndim from rateslib.fx import FXForwards, FXRates from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface from rateslib.instruments import ( @@ -25,7 +25,7 @@ Portfolio, Value, ) -from rateslib.solver import Gradients, Solver, newton_1dim, newton_ndim +from rateslib.solver import Gradients, Solver class TestGradients: diff --git a/rust/calendars/named/fed_script.py b/rust/calendars/named/fed_script.py index dba07f93..1d62365d 100644 --- a/rust/calendars/named/fed_script.py +++ b/rust/calendars/named/fed_script.py @@ -19,7 +19,7 @@ day=1, offset=DateOffset(weekday=MO(3)), ), - Holiday("US President" "s Day", month=2, day=1, offset=DateOffset(weekday=MO(3))), + Holiday("US Presidents Day", month=2, day=1, offset=DateOffset(weekday=MO(3))), # Holiday("Good Friday", month=1, day=1, offset=[Easter(), Day(-2)]), Holiday("US Memorial Day", month=5, day=31, offset=DateOffset(weekday=MO(-1))), Holiday( diff --git a/rust/calendars/named/nyc_script.py b/rust/calendars/named/nyc_script.py index 2640d6d7..74e7c797 100644 --- a/rust/calendars/named/nyc_script.py +++ b/rust/calendars/named/nyc_script.py @@ -19,7 +19,7 @@ day=1, offset=DateOffset(weekday=MO(3)), ), - Holiday("US President" "s Day", month=2, day=1, offset=DateOffset(weekday=MO(3))), + Holiday("US Presidents Day", month=2, day=1, offset=DateOffset(weekday=MO(3))), Holiday("Good Friday", month=1, day=1, offset=[Easter(), Day(-2)]), Holiday("US Memorial Day", month=5, day=31, offset=DateOffset(weekday=MO(-1))), Holiday(