diff --git a/pyproject.toml b/pyproject.toml index e579aa37..dcc5cbe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,19 +153,19 @@ exclude = [ "/instruments/components/fx_options", # "/instruments/components/protocols", # "/instruments/components/cds.py", - "/instruments/components/fly.py", + # "/instruments/components/fly.py", # "/instruments/components/fra.py", # "/instruments/components/fx_forward.py", # "/instruments/components/fx_swap.py", - "/instruments/components/fx_vol_value.py", + # "/instruments/components/fx_vol_value.py", # "/instruments/components/iirs.py", # "/instruments/components/irs.py", # "/instruments/components/ndf.py", - "/instruments/components/portfolio.py", + # "/instruments/components/portfolio.py", # "/instruments/components/sbs.py", - "/instruments/components/spread.py", - "/instruments/components/stir_future.py", - "/instruments/components/value.py", + # "/instruments/components/spread.py", + # "/instruments/components/stir_future.py", + # "/instruments/components/value.py", # "/instruments/components/xcs.py", # "/instruments/components/zcis.py", # "/instruments/components/zcs.py", diff --git a/python/rateslib/instruments/components/fly.py b/python/rateslib/instruments/components/fly.py index e15dc334..5ced6e91 100644 --- a/python/rateslib/instruments/components/fly.py +++ b/python/rateslib/instruments/components/fly.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from rateslib.typing import ( Any, - Curves_, + CurvesT_, DualTypes, FXForwards_, FXVolOption_, @@ -64,16 +64,54 @@ def _composit_fixings_table(df_result: DataFrame, df: DataFrame) -> DataFrame: class Fly(_BaseInstrument): """ - Create a butterfly of *Instruments*. + A *Butterfly* of :class:`~rateslib.instruments.components.protocols._BaseInstrument`. + + .. rubric:: Examples + + The following initialises a *Butterfly* of *IRSs*. + + .. ipython:: python + :suppress: + + from rateslib.instruments.components import Fly, IRS + from datetime import datetime as dt + + .. ipython:: python + + fly = Fly( + instrument1=IRS(dt(2000, 1, 1), "1y", notional=10e6, spec="eur_irs", curves=["estr"]), + instrument2=IRS(dt(2000, 1, 1), "2y", notional=-5e6, spec="eur_irs", curves=["estr"]), + instrument3=IRS(dt(2000, 1, 1), "3y", notional=1.75e6, spec="eur_irs", curves=["estr"]), + ) + fly.cashflows() + + .. rubric:: Pricing + + Each :class:`~rateslib.instruments.components.protocols._BaseInstrument` should have + its own ``curves`` and ``vol`` objects set at its initialisation, according to the + documentation for that *Instrument*. For the pricing methods ``curves`` and ``vol`` objects, + these can be universally passed to each *Instrument* but in many cases that would be + technically impossible since each *Instrument* might require difference pricing objects, e.g. + if the *Instruments* have difference currencies. For a *Fly* + of three *IRS* in the same currency this would be possible, however. Parameters ---------- instrument1 : _BaseInstrument - An *Instrument* with the shortest maturity. + The *Instrument* with the shortest maturity. instrument2 : _BaseInstrument - The *Instrument* of the body of the *Fly*. + The *Instrument* with the intermediate maturity. instrument3 : _BaseInstrument - An *Instrument* with the longest maturity. + The *Instrument* with the longest maturity. + + Notes + ----- + A *Fly* is just a container for three + :class:`~rateslib.instruments.components.protocols._BaseInstrument`, with an overload + for the :meth:`~rateslib.instruments.components.Spread.rate` method to calculate twice the + belly rate minus the wings (whatever metric is in use for each *Instrument*), which allows + it to offer a lot of flexibility in *pseudo Instrument* creation. + """ _instruments: Sequence[_BaseInstrument] @@ -94,7 +132,7 @@ def __init__( def npv( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -125,7 +163,7 @@ def npv( def local_analytic_rate_fixings( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -182,7 +220,7 @@ def local_analytic_rate_fixings( def cashflows( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -200,73 +238,10 @@ def cashflows( base=base, ) - 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) - - def exo_delta(self, *args: Any, **kwargs: Any) -> DataFrame: - """ - Calculate the delta of the *Instrument* measured - against user defined :class:`~rateslib.dual.Variable`. - - For arguments see - :meth:`Sensitivities.exo_delta()`. - """ - return super().exo_delta(*args, **kwargs) - - def fixings_table( - self, - curves: Curves_ = NoInput(0), - solver: Solver_ = NoInput(0), - fx: FX_ = NoInput(0), - base: str_ = NoInput(0), - approximate: bool = False, - right: datetime_ = NoInput(0), - ) -> DataFrame: - """ - Return a DataFrame of fixing exposures on the *Instruments*. - - For arguments see :meth:`XCS.fixings_table()`, - and/or :meth:`IRS.fixings_table()` - - Returns - ------- - DataFrame - """ - df_result = DataFrame( - index=DatetimeIndex([], name="obs_dates"), - ) - for inst in self.instruments: - try: - df = inst.fixings_table( # type: ignore[attr-defined] - curves=curves, - solver=solver, - fx=fx, - base=base, - approximate=approximate, - right=right, - ) - except AttributeError: - continue - df_result = _composit_fixings_table(df_result, df) - return df_result - def rate( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), diff --git a/python/rateslib/instruments/components/fra.py b/python/rateslib/instruments/components/fra.py index b38f7f99..db935f07 100644 --- a/python/rateslib/instruments/components/fra.py +++ b/python/rateslib/instruments/components/fra.py @@ -12,6 +12,7 @@ _Curves, _maybe_get_curve_maybe_from_solver, _maybe_get_curve_or_dict_maybe_from_solver, + _maybe_get_curve_or_dict_object_maybe_from_solver, ) from rateslib.legs.components import FixedLeg, FloatLeg from rateslib.scheduling import Adjuster @@ -555,7 +556,7 @@ def cashflows( _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] - leg2_rate_curve = _maybe_get_curve_or_dict_maybe_from_solver( + leg2_rate_curve = _maybe_get_curve_or_dict_object_maybe_from_solver( solver=solver, curves_meta=_curves_meta, curves=_curves, name="leg2_rate_curve" ) scalar = self._try_fra_rate_scalar(leg2_rate_curve=leg2_rate_curve) diff --git a/python/rateslib/instruments/components/portfolio.py b/python/rateslib/instruments/components/portfolio.py index e099239a..c1d60c7e 100644 --- a/python/rateslib/instruments/components/portfolio.py +++ b/python/rateslib/instruments/components/portfolio.py @@ -16,7 +16,7 @@ if TYPE_CHECKING: from rateslib.typing import ( Any, - Curves_, + CurvesT_, DualTypes, FXForwards_, FXVolOption_, @@ -34,61 +34,50 @@ def _instrument_npv( return instrument.npv(*args, **kwargs) -def _composit_fixings_table(df_result: DataFrame, df: DataFrame) -> DataFrame: +class Portfolio(_BaseInstrument): """ - Add a DataFrame to an existing fixings table by extending or adding to relevant columns. + A collection of :class:`~rateslib.instruments.components.protocols._BaseInstrument`. - Parameters - ---------- - df_result: The main DataFrame that will be updated - df: The incoming DataFrame with new data to merge + .. rubric:: Examples - 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)) + The following initialises a *Portfolio* of *IRSs*. - # # 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]) + .. ipython:: python + :suppress: - # merge by addition existing values with missing filled to zero - m = [c for c in df.columns if c in df_result.columns] - if len(m) > 0: - df_result[m] = df_result[m].add(df[m], fill_value=0.0) + from rateslib.instruments.components import Portfolio, IRS + from datetime import datetime as dt - # 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] + .. ipython:: python - # df_result.columns = MultiIndex.from_tuples(df_result.columns) - return df_result + pf = Portfolio(instruments=[ + IRS(dt(2000, 1, 1), "1y", notional=10e3, spec="eur_irs", curves=["estr"]), + IRS(dt(2000, 1, 1), "2y", notional=10e3, spec="eur_irs", curves=["estr"]), + IRS(dt(2000, 1, 1), "3y", notional=10e3, spec="eur_irs", curves=["estr"]), + ]) + pf.cashflows() + .. rubric:: Pricing -class Portfolio(_BaseInstrument): - """ - Create a collection of *Instruments* to group metrics + Each :class:`~rateslib.instruments.components.protocols._BaseInstrument` should have + its own ``curves`` and ``vol`` objects set at its initialisation, according to the + documentation for that *Instrument*. For the pricing methods ``curves`` and ``vol`` objects, + these can be universally passed to each *Instrument* but in many cases that would be + technically impossible since each *Instrument* might require difference pricing objects, e.g. + if the *Instruments* have difference currencies. For a *Portfolio* + of three *IRS* in the same currency this would be possible, however. Parameters ---------- - instruments : list - This should be a list of *Instruments*. + instruments : list of _BaseInstrument + The collection of *Instruments*. Notes ----- - When using a :class:`Portfolio` each *Instrument* must either have pricing parameters - pre-defined using the appropriate :ref:`pricing mechanisms` or share - common pricing parameters defined at price time. + A *Portfolio* is just a container for multiple + :class:`~rateslib.instruments.components.protocols._BaseInstrument`. + There is no concept of a :meth:`~rateslib.instruments.components.Portfolio.rate`. - Examples - -------- - See examples for :class:`Spread` for similar functionality. """ _instruments: Sequence[_BaseInstrument] @@ -106,7 +95,7 @@ def __init__(self, instruments: Sequence[_BaseInstrument]) -> None: def npv( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -177,7 +166,7 @@ def npv( def local_analytic_rate_fixings( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -196,7 +185,7 @@ def local_analytic_rate_fixings( def cashflows( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -214,32 +203,6 @@ def cashflows( base=base, ) - 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) - - def exo_delta(self, *args: Any, **kwargs: Any) -> DataFrame: - """ - Calculate the delta of the *Instrument* measured - against user defined :class:`~rateslib.dual.Variable`. - - For arguments see - :meth:`Sensitivities.exo_delta()`. - """ - return super().exo_delta(*args, **kwargs) - def rate(self, *args: Any, **kwargs: Any) -> NoReturn: raise NotImplementedError("`rate` is not defined for Portfolio.") diff --git a/python/rateslib/instruments/components/protocols/cashflows.py b/python/rateslib/instruments/components/protocols/cashflows.py index 24791823..70c0f985 100644 --- a/python/rateslib/instruments/components/protocols/cashflows.py +++ b/python/rateslib/instruments/components/protocols/cashflows.py @@ -10,7 +10,8 @@ from rateslib.instruments.components.protocols.kwargs import _KWArgs from rateslib.instruments.components.protocols.pricing import ( _get_fx_maybe_from_solver, - _maybe_get_curve_or_dict_maybe_from_solver, + _maybe_get_curve_object_maybe_from_solver, + _maybe_get_curve_or_dict_object_maybe_from_solver, _WithPricingObjs, ) @@ -105,13 +106,13 @@ def _cashflows_from_legs( legs_df = [ self.legs[0].cashflows( - rate_curve=_maybe_get_curve_or_dict_maybe_from_solver( + rate_curve=_maybe_get_curve_or_dict_object_maybe_from_solver( _curves_meta, _curves, "rate_curve", solver ), - disc_curve=_maybe_get_curve_or_dict_maybe_from_solver( + disc_curve=_maybe_get_curve_object_maybe_from_solver( _curves_meta, _curves, "disc_curve", solver ), - index_curve=_maybe_get_curve_or_dict_maybe_from_solver( + index_curve=_maybe_get_curve_object_maybe_from_solver( _curves_meta, _curves, "index_curve", solver ), fx=_fx_maybe_from_solver, @@ -125,13 +126,13 @@ def _cashflows_from_legs( if len(self.legs) > 1: legs_df.append( self.legs[1].cashflows( - rate_curve=_maybe_get_curve_or_dict_maybe_from_solver( + rate_curve=_maybe_get_curve_or_dict_object_maybe_from_solver( _curves_meta, _curves, "leg2_rate_curve", solver ), - disc_curve=_maybe_get_curve_or_dict_maybe_from_solver( + disc_curve=_maybe_get_curve_object_maybe_from_solver( _curves_meta, _curves, "leg2_disc_curve", solver ), - index_curve=_maybe_get_curve_or_dict_maybe_from_solver( + index_curve=_maybe_get_curve_object_maybe_from_solver( _curves_meta, _curves, "leg2_index_curve", solver ), fx=_fx_maybe_from_solver, @@ -157,10 +158,13 @@ def _cashflows_from_instruments(self, *args: Any, **kwargs: Any) -> DataFrame: # specific to that instrument assert hasattr(self, "instruments") # noqa: S101 - _: DataFrame = concat( - [_.cashflows(*args, **kwargs) for _ in self.instruments], - keys=[f"inst{i}" for i in range(len(self.instruments))], - ) + 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( + [_.cashflows(*args, **kwargs) for _ in self.instruments], + keys=[f"inst{i}" for i in range(len(self.instruments))], + ) return _ def cashflows_table( diff --git a/python/rateslib/instruments/components/protocols/pricing.py b/python/rateslib/instruments/components/protocols/pricing.py index c1c730d9..b0d00b37 100644 --- a/python/rateslib/instruments/components/protocols/pricing.py +++ b/python/rateslib/instruments/components/protocols/pricing.py @@ -147,10 +147,14 @@ def _maybe_get_curve_or_dict_maybe_from_solver( solver: Solver_, ) -> _BaseCurveOrDict_: """ - Fetch a curve name from either: + This function is used by many pricing methods to lookup a particular requested curve and + return it, either directly from the provided input or via its string id and a Solver mapping. - - the direct given curve input, if it is a valid curve object. - - from a provided Solver if it is string based and needs a solver mapping. + When a string id is provided and a Solver is missing this function will raise, which is the + preferred method for performing calulations, e.g. `npv` or `rate`. + + This function can return a dict of curves, e.g. {1m: curve, 3m: curve2} for use with + IBOR stub pricing as a *rate curve*. """ curve: _BaseCurveOrIdOrIdDict_ = _drb(getattr(curves_meta, name), getattr(curves, name)) @@ -172,10 +176,14 @@ def _maybe_get_curve_maybe_from_solver( solver: Solver_, ) -> _BaseCurve_: """ - Fetch a curve name from either: + This function is used by many pricing methods to lookup a particular requested curve and + return it, either directly from the provided input or via its string id and a Solver mapping. + + When a string id is provided and a Solver is missing this function will raise, which is the + preferred method for performing calulations, e.g. `npv` or `rate`. - - the direct given curve input, if it is a valid curve object. - - from a provided Solver if it is string based and needs a solver mapping. + This function should not return a dict of curves so is best suited for determining discount + factor, index, or credit type single curves. """ curve: _BaseCurveOrId_ = _drb(getattr(curves_meta, name), getattr(curves, name)) if isinstance(curve, NoInput): @@ -210,6 +218,81 @@ def _validate_base_curve_is_not_id(curve: _BaseCurveOrId) -> _BaseCurve: return curve +def _maybe_get_curve_or_dict_object_maybe_from_solver( + curves_meta: _Curves, + curves: _Curves, + name: str, + solver: Solver_, +) -> _BaseCurveOrDict_: + """ + This function is used by many pricing methods to lookup a particular requested curve and + return it, either directly from the provided input or via its string id and a Solver mapping. + + When a string id is provided and a Solver is missing this function will convert that to + a NoInput, which is the preferred method for use with `cashflow` generators which are + allowed to fail and return null values. + + This function can also return a dict of values. + """ + + curve: _BaseCurveOrIdOrIdDict_ = _drb(getattr(curves_meta, name), getattr(curves, name)) + if isinstance(curve, NoInput): + return curve + elif isinstance(solver, NoInput): + return _convert_curve_id_to_no_input(curve=curve) + else: + return _get_curve_from_solver( + curve=curve, + solver=solver, + ) + + +def _maybe_get_curve_object_maybe_from_solver( + curves_meta: _Curves, + curves: _Curves, + name: str, + solver: Solver_, +) -> _BaseCurve_: + """ + This function is used by many pricing methods to lookup a particular requested curve and + return it, either directly from the provided input or via its string id and a Solver mapping. + + When a string id is provided and a Solver is missing this function will convert that to + a NoInput, which is the preferred method for use with `cashflow` generators which are + allowed to fail and return null values. + + This function should not return a dict of curves. + """ + curve: _BaseCurveOrId_ = _drb(getattr(curves_meta, name), getattr(curves, name)) + if isinstance(curve, NoInput): + return curve + elif isinstance(solver, NoInput): + return _convert_base_curve_id_to_no_input(curve=curve) + else: + # TODO: use overloads typing on '_get_curve_from_solver' + return _get_curve_from_solver( # type: ignore[return-value] # cannot return a dict + curve=curve, + solver=solver, + ) + + +def _convert_curve_id_to_no_input(curve: _BaseCurveOrIdOrIdDict) -> _BaseCurveOrDict_: + if isinstance(curve, dict): + # may return {str: NoInput} not understood by typing + return {k: _convert_base_curve_id_to_no_input(v) for k, v in curve.items()} # type: ignore[misc] + elif isinstance(curve, NoInput) or curve is None: + return NoInput(0) + else: + return _convert_base_curve_id_to_no_input(curve) + + +def _convert_base_curve_id_to_no_input(curve: _BaseCurveOrId) -> _BaseCurve_: + # used by cashflow methods to return NoInput curves when they are not available + if isinstance(curve, str): + return NoInput(0) + return curve + + def _get_curve_from_solver(curve: _BaseCurveOrIdOrIdDict, solver: Solver) -> _BaseCurveOrDict: """ Maps a "Curve | str | dict[str, Curve | str]" to a "Curve | dict[str, Curve]" via a Solver. diff --git a/python/rateslib/instruments/components/spread.py b/python/rateslib/instruments/components/spread.py index 27c636a0..b15db8d1 100644 --- a/python/rateslib/instruments/components/spread.py +++ b/python/rateslib/instruments/components/spread.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: from rateslib.typing import ( Any, - Curves_, + CurvesT_, DualTypes, FXForwards_, FXVolOption_, @@ -27,15 +27,52 @@ class Spread(_BaseInstrument): """ - Create a *Spread* of *Instruments*. + A *Spread* of :class:`~rateslib.instruments.components.protocols._BaseInstrument`. + + .. rubric:: Examples + + The following initialises a purchased bond asset swap *Instrument* whose *rate* is + the difference between the *IRS* rate and the *fixed rate bond* YTM. + + .. ipython:: python + :suppress: + + from rateslib.instruments.components import Spread, IRS, FixedRateBond + from datetime import datetime as dt + + .. ipython:: python + + irs = IRS(dt(2025, 12, 1), dt(2030, 12, 7), notional=1e6, spec="gbp_irs", curves=["uk_sonia"]) + ukt = FixedRateBond(dt(2024, 12, 7), dt(2030, 12, 7), notional=-1e6, fixed_rate=4.75, spec="uk_gb", metric="ytm", curves=["uk_gb"]) + asw = Spread(ukt, irs) + asw.cashflows() + + .. rubric:: Pricing + + Each :class:`~rateslib.instruments.components.protocols._BaseInstrument` should have + its own ``curves`` and ``vol`` objects set at its initialisation, according to the + documentation for that *Instrument*. For the pricing methods ``curves`` and ``vol`` objects, + these can be universally passed to each *Instrument* but in many cases that would be + technically impossible since each *Instrument* might require difference pricing objects. + In the above example a bond *Curve* and a swap *Curve* are required separately. For a *Spread* + of two *IRS* in the same currency this would be possible, however. Parameters ---------- instrument1 : _BaseInstrument - An *Instrument* with the shortest maturity. + The *Instrument* with the shortest maturity. instrument2 : _BaseInstrument The *Instrument* with the longest maturity. - """ + + Notes + ----- + A *Spread* is just a container for two + :class:`~rateslib.instruments.components.protocols._BaseInstrument`, with an overload + for the :meth:`~rateslib.instruments.components.Spread.rate` method to calculate the + longer rate minus the shorter (whatever metric is in use for each *Instrument*), which allows + it to offer a lot of flexibility in *pseudo Instrument* creation. + + """ # noqa: E501 _instruments: Sequence[_BaseInstrument] @@ -54,7 +91,7 @@ def __init__( def npv( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -89,7 +126,7 @@ def npv( def local_analytic_rate_fixings( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -108,7 +145,7 @@ def local_analytic_rate_fixings( def cashflows( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -126,36 +163,10 @@ def cashflows( base=base, ) - 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) - - def exo_delta(self, *args: Any, **kwargs: Any) -> DataFrame: - """ - Calculate the delta of the *Instrument* measured - against user defined :class:`~rateslib.dual.Variable`. - - For arguments see - :meth:`Sensitivities.exo_delta()`. - """ - return super().exo_delta(*args, **kwargs) - def rate( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), diff --git a/python/rateslib/instruments/components/stir_future.py b/python/rateslib/instruments/components/stir_future.py index 943641a1..ccde5bb5 100644 --- a/python/rateslib/instruments/components/stir_future.py +++ b/python/rateslib/instruments/components/stir_future.py @@ -10,19 +10,21 @@ from rateslib.instruments.components.protocols.pricing import ( _Curves, _get_fx_maybe_from_solver, + _maybe_get_curve_maybe_from_solver, + _maybe_get_curve_object_maybe_from_solver, _maybe_get_curve_or_dict_maybe_from_solver, ) from rateslib.legs.components import FixedLeg, FloatLeg from rateslib.periods.components.utils import ( _maybe_fx_converted, _maybe_local, + _validate_base_curve, ) if TYPE_CHECKING: from rateslib.typing import ( # pragma: no cover CalInput, - CurveOption_, - Curves_, + CurvesT_, DataFrame, DualTypes, DualTypes_, @@ -47,75 +49,157 @@ class STIRFuture(_BaseInstrument): A *short term interest rate (STIR) future* compositing a :class:`~rateslib.legs.components.FixedLeg` and :class:`~rateslib.legs.components.FloatLeg`. - Parameters - ---------- - price : float - The traded price of the future. Defined as 100 minus the fixed rate. - contracts : int - The number of traded contracts. - nominal : float - The nominal value of the contract. E.g. SOFR 3M futures are $1mm. If not given will use the - default notional. - 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. + .. rubric:: Examples - Notes - ----- - A *STIRFuture* is modelled as a single period *IRS* whose payment date is overloaded to always - result in immediate settlement, with the immediate date derived from the discount curve - used during pricing. + .. ipython:: python + :suppress: - Examples - -------- - Construct a curve to price the example. + from rateslib.instruments.components import STIRFuture + from datetime import datetime as dt .. ipython:: python - usd = Curve( - nodes={ - dt(2022, 1, 1): 1.0, - dt(2023, 1, 1): 0.965, - dt(2024, 1, 1): 0.94 - }, - id="usd_stir" + stir = STIRFuture( + effective=dt(2022, 3, 16), + termination=dt(2022, 6, 15), + spec="usd_stir", + price=99.50, + contracts=10, ) + stir.cashflows() - Create the *STIRFuture*, and demonstrate the :meth:`~rateslib.instruments.STIRFuture.rate`, - :meth:`~rateslib.instruments.STIRFuture.npv`, + .. rubric:: Pricing - .. ipython:: python - :suppress: + A *STIRFuture* requires a *disc curve* on both legs (which should be the same *Curve*) and a + *leg2 rate curve* to forecast rates on the *FloatLeg*. The following input formats are + allowed: - from rateslib import STIRFuture + .. code-block:: python - .. ipython:: python + curves = curve | [curve] # a single curve is repeated for all required curves + curves = [rate_curve, disc_curve] # two curves are applied in the given order + curves = [None, disc_curve, rate_curve, disc_curve] # four curves applied to each leg + curves = {"leg2_rate_curve": rate_curve, "disc_curve": disc_curve} # dict form is explicit - stir = STIRFuture( - effective=dt(2022, 3, 16), - termination=dt(2022, 6, 15), - spec="usd_stir", - curves=usd, - price=99.50, - contracts=10, - ) - stir.rate(metric="price") - stir.npv() + The available pricing ``metric`` are in *{'rate', 'price'}* which will return the future's + market price in the respective terms. + + .. role:: red + + .. role:: green + + Parameters + ---------- + . + + .. note:: + + The following define generalised **scheduling** parameters. + + effective : datetime, :red:`required` + The unadjusted effective date. If given as adjusted, unadjusted alternatives may be + inferred. + termination : datetime, str, :red:`required` + The unadjusted termination date. If given as adjusted, unadjusted alternatives may be + inferred. If given as string tenor will be calculated from ``effective``. + frequency : Frequency, str, :red:`required` + The frequency of the schedule. + If given as string will derive a :class:`~rateslib.scheduling.Frequency` aligning with: + monthly ("M"), quarterly ("Q"), semi-annually ("S"), annually("A") or zero-coupon ("Z"), or + a set number of calendar or business days ("_D", "_B"), weeks ("_W"), months ("_M") or + years ("_Y"). + Where required, the :class:`~rateslib.scheduling.RollDay` is derived as per ``roll`` + and business day calendar as per ``calendar``. + roll : RollDay, int in [1, 31], str in {"eom", "imm", "som"}, :green:`optional` + The roll day of the schedule. If not given or not available in ``frequency`` will be + inferred for monthly frequency variants. + eom : bool, :green:`optional` + Use an end of month preference rather than regular rolls for ``roll`` inference. Set by + default. Not required if ``roll`` is defined. + modifier : Adjuster, str in {"NONE", "F", "MF", "P", "MP"}, :green:`optional` + The :class:`~rateslib.scheduling.Adjuster` used for adjusting unadjusted schedule dates + into adjusted dates. If given as string must define simple date rolling rules. + calendar : calendar, str, :green:`optional` + The business day calendar object to use. If string will call + :meth:`~rateslib.scheduling.get_calendar`. + payment_lag: Adjuster, int, :green:`optional` + The :class:`~rateslib.scheduling.Adjuster` to use to map adjusted schedule dates into + a payment date. If given as integer will define the number of business days to + lag payments by. + ex_div: Adjuster, int, :green:`optional` + The :class:`~rateslib.scheduling.Adjuster` to use to map adjusted schedule dates into + additional dates, which may be used, for example by fixings schedules. If given as integer + will define the number of business days to lag dates by. + convention: str, :green:`optional (set by 'defaults')` + The day count convention applied to calculations of period accrual dates. + See :meth:`~rateslib.scheduling.dcf`. + + .. note:: + + The following define generalised **settlement** parameters. + + contracts : int + The number of traded contracts. + nominal : float + The nominal value of the contract. See **Notes**. + currency : str, :green:`optional (set by 'defaults')` + The local settlement currency of the *Instrument* (3-digit code). + + .. note:: + + The following are **rate parameters**. + + price : float + The traded price of the future. Defined as 100 minus the fixed rate. + leg2_fixing_method: FloatFixingMethod, str, :green:`optional (set by 'defaults')` + The :class:`~rateslib.enums.parameters.FloatFixingMethod` describing the determination + of the floating rate for each period. + leg2_method_param: int, :green:`optional (set by 'defaults')` + A specific parameter that is used by the specific ``fixing_method``. + leg2_fixing_frequency: Frequency, str, :green:`optional (set by 'frequency' or '1B')` + The :class:`~rateslib.scheduling.Frequency` as a component of the + :class:`~rateslib.data.fixings.FloatRateIndex`. If not given is assumed to match the + frequency of the schedule for an IBOR type ``fixing_method`` or '1B' if RFR type. + leg2_fixing_series: FloatRateSeries, str, :green:`optional (implied by other parameters)` + The :class:`~rateslib.data.fixings.FloatRateSeries` as a component of the + :class:`~rateslib.data.fixings.FloatRateIndex`. If not given inherits attributes given + such as the ``calendar``, ``convention``, ``method_param`` etc. + leg2_float_spread: float, Dual, Dual2, Variable, :green:`optional (set as 0.0)` + The amount (in bps) added to the rate in each period rate determination. + leg2_spread_compound_method: SpreadCompoundMethod, str, :green:`optional (set by 'defaults')` + The :class:`~rateslib.enums.parameters.SpreadCompoundMethod` used in the calculation + of the period rate when combining a ``float_spread``. Used **only** with RFR type + ``fixing_method``. + leg2_rate_fixings: float, Dual, Dual2, Variable, Series, str, :green:`optional` + See XXX (working with fixings). + The value of the rate fixing. If a scalar, is used directly. If a string identifier, links + to the central ``fixings`` object and data loader. + + .. note:: + + The following are **meta parameters**. + + curves : _BaseCurve, str, dict, _Curves, Sequence, :green:`optional` + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. See + **Pricing**. + metric : str, :green:`optional` (set by 'defaults') + The pricing metric returned by :meth:`~rateslib.instruments.components.STIRFuture.rate`. + spec: str, :green:`optional` + A collective group of parameters. See + :ref:`default argument specifications `. + + Notes + ----- + A *STIRFuture* is modelled as a single period *IRS* whose payment date is overloaded to always + result in immediate settlement, thus replicating the behaviour of traditional exchanges. + The immediate date is derived from the discount curve used during pricing. + + The ``nominal`` for one contract should be set according to the ``convention`` so that the + correct amount of risk is allocated is to 1bp. For example, for a CME SOFR 3M future, setting + a convention of *ActActICMA* yields a DCF of 0.25 and therefore a ``nominal`` of 1mm USD + yields a 1bp sensitivity of 25 USD for any contract, as per the CME contract specification. The + ``leg2_fixing_series`` argument allows full specification of the floating rate index + conventions. """ @@ -147,7 +231,7 @@ def legs(self) -> list[_BaseLeg]: """A list of the *Legs* of the *Instrument*.""" return self._legs - def _parse_curves(self, curves: CurveOption_) -> _Curves: + def _parse_curves(self, curves: CurvesT_) -> _Curves: """ An STIRFuture has two curve requirements: a leg2_rate_curve and a disc_curve used by both legs. @@ -158,19 +242,6 @@ def _parse_curves(self, curves: CurveOption_) -> _Curves: """ if isinstance(curves, NoInput): return _Curves() - if isinstance(curves, dict): - return _Curves( - rate_curve=curves.get("rate_curve", NoInput(0)), - disc_curve=curves.get("disc_curve", NoInput(0)), - leg2_rate_curve=_drb( - curves.get("rate_curve", NoInput(0)), - curves.get("leg2_rate_curve", NoInput(0)), - ), - leg2_disc_curve=_drb( - curves.get("disc_curve", NoInput(0)), - curves.get("leg2_disc_curve", NoInput(0)), - ), - ) elif isinstance(curves, list | tuple): if len(curves) == 2: return _Curves( @@ -195,11 +266,26 @@ def _parse_curves(self, curves: CurveOption_) -> _Curves: raise ValueError( f"{type(self).__name__} requires only 2 curve types. Got {len(curves)}." ) + elif isinstance(curves, dict): + return _Curves( + rate_curve=curves.get("rate_curve", NoInput(0)), + disc_curve=curves.get("disc_curve", NoInput(0)), + leg2_rate_curve=_drb( + curves.get("rate_curve", NoInput(0)), + curves.get("leg2_rate_curve", NoInput(0)), + ), + leg2_disc_curve=_drb( + curves.get("disc_curve", NoInput(0)), + curves.get("leg2_disc_curve", NoInput(0)), + ), + ) + elif isinstance(curves, _Curves): + return curves else: # `curves` is just a single input which is copied across all curves return _Curves( - leg2_rate_curve=curves, - disc_curve=curves, - leg2_disc_curve=curves, + leg2_rate_curve=curves, # type: ignore[arg-type] + disc_curve=curves, # type: ignore[arg-type] + leg2_disc_curve=curves, # type: ignore[arg-type] ) def __init__( @@ -230,7 +316,7 @@ def __init__( leg2_fixing_frequency: Frequency | str_ = NoInput(0), leg2_fixing_series: FloatRateSeries | str_ = NoInput(0), # meta parameters - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), spec: str_ = NoInput(0), metric: str_ = NoInput(0), ) -> None: @@ -303,7 +389,7 @@ def __init__( def npv( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -313,7 +399,7 @@ def npv( forward: datetime_ = NoInput(0), ) -> DualTypes | dict[str, DualTypes]: self._set_pricing_mid(curves=curves, solver=solver, settlement=settlement, forward=forward) - local_npv = super().npv( + local_npv = super().npv( # type: ignore[index] curves=curves, solver=solver, fx=fx, @@ -328,8 +414,10 @@ def npv( _curves_meta: _Curves = self.kwargs.meta["curves"] npv_immediate = ( local_npv - / _maybe_get_curve_or_dict_maybe_from_solver( - solver=solver, curves_meta=_curves_meta, curves=_curves, name="disc_curve" + / _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + solver=solver, curves_meta=_curves_meta, curves=_curves, name="disc_curve" + ) )[self.leg1.settlement_params.payment] ) @@ -345,7 +433,7 @@ def npv( def _set_pricing_mid( self, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), @@ -364,7 +452,7 @@ def _set_pricing_mid( def rate( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -372,7 +460,7 @@ def rate( settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), metric: str_ = NoInput(0), - ) -> DualTypes_: + ) -> DualTypes: _curves = self._parse_curves(curves) metric_ = _drb(self.kwargs.meta["metric"], metric).lower() @@ -380,7 +468,7 @@ def rate( rate_curve=_maybe_get_curve_or_dict_maybe_from_solver( self.kwargs.meta["curves"], _curves, "leg2_rate_curve", solver ), - disc_curve=_maybe_get_curve_or_dict_maybe_from_solver( + disc_curve=_maybe_get_curve_maybe_from_solver( self.kwargs.meta["curves"], _curves, "leg2_disc_curve", solver ), settlement=settlement, @@ -390,7 +478,7 @@ def rate( self.leg1.spread( target_npv=-leg2_npv, rate_curve=NoInput(0), - disc_curve=_maybe_get_curve_or_dict_maybe_from_solver( + disc_curve=_maybe_get_curve_maybe_from_solver( self.kwargs.meta["curves"], _curves, "disc_curve", solver ), index_curve=NoInput(0), @@ -409,7 +497,7 @@ def rate( def analytic_delta( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -419,7 +507,7 @@ def analytic_delta( forward: datetime_ = NoInput(0), leg: int = 1, ) -> DualTypes | dict[str, DualTypes]: - unadjusted_local_analytic_delta = super().analytic_delta( + unadjusted_local_analytic_delta = super().analytic_delta( # type: ignore[index] curves=curves, solver=solver, fx=fx, @@ -435,8 +523,13 @@ def analytic_delta( prefix = "" if leg == 1 else "leg2_" adjusted_local_analytic_delta = ( unadjusted_local_analytic_delta - / _maybe_get_curve_or_dict_maybe_from_solver( - solver=solver, curves_meta=_curves_meta, curves=_curves, name=f"{prefix}disc_curve" + / _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + solver=solver, + curves_meta=_curves_meta, + curves=_curves, + name=f"{prefix}disc_curve", + ) )[self.leg1.settlement_params.payment] ) return _maybe_local( @@ -450,7 +543,7 @@ def analytic_delta( def local_analytic_rate_fixings( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -468,8 +561,49 @@ def local_analytic_rate_fixings( _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] return ( - df - / _maybe_get_curve_or_dict_maybe_from_solver( - solver=solver, curves_meta=_curves_meta, curves=_curves, name="leg2_disc_curve" + df # type: ignore[operator] + / _validate_base_curve( + _maybe_get_curve_or_dict_maybe_from_solver( + solver=solver, curves_meta=_curves_meta, curves=_curves, name="leg2_disc_curve" + ) )[self.leg1.settlement_params.payment] ) + + def cashflows( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + df = super()._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + df[defaults.headers["payment"]] = None + + _curves: _Curves = self._parse_curves(curves) + _curves_meta: _Curves = self.kwargs.meta["curves"] + disc_curve = _maybe_get_curve_object_maybe_from_solver( + solver=solver, curves_meta=_curves_meta, curves=_curves, name="disc_curve" + ) + if isinstance(disc_curve, NoInput): + pass + else: + df[defaults.headers["payment"]] = disc_curve.nodes.initial + df[defaults.headers["npv"]] = df[defaults.headers["npv"]] / df[defaults.headers["df"]] + df[defaults.headers["npv_fx"]] = ( + df[defaults.headers["npv_fx"]] / df[defaults.headers["df"]] + ) + df[defaults.headers["df"]] = 1.0 + + return df diff --git a/python/rateslib/instruments/components/value.py b/python/rateslib/instruments/components/value.py index f8454ee9..fba6f966 100644 --- a/python/rateslib/instruments/components/value.py +++ b/python/rateslib/instruments/components/value.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, NoReturn from rateslib import defaults -from rateslib.curves._parsers import _CurveType +from rateslib.curves.utils import _CurveType from rateslib.dual.utils import dual_log from rateslib.enums.generics import NoInput, _drb from rateslib.enums.parameters import IndexMethod @@ -11,15 +11,16 @@ from rateslib.instruments.components.protocols.kwargs import _KWArgs from rateslib.instruments.components.protocols.pricing import ( _Curves, - _maybe_get_curve_or_dict_maybe_from_solver, + _maybe_get_curve_maybe_from_solver, ) +from rateslib.periods.components.utils import _validate_base_curve from rateslib.scheduling import dcf if TYPE_CHECKING: from rateslib.typing import ( # pragma: no cover Any, Convention, - Curves_, + CurvesT_, DualTypes, FXForwards_, FXVolOption_, @@ -32,41 +33,65 @@ class Value(_BaseInstrument): """ - A null *Instrument* which can be used within a :class:`~rateslib.solver.Solver` - to directly parametrise a *Curve* node, via some calculated value. + A pseudo *Instrument* used to calibrate a *Curve* within a :class:`~rateslib.solver.Solver`. - Parameters - ---------- - effective : datetime - The datetime index for which the `rate`, which is just the curve value, is - returned. - 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. Only uses the first *Curve* in a list. - convention : str, optional, - Day count convention used with certain ``metric``. - metric : str in {"curve_value", "index_value", "cc_zero_rate", "o/n_rate"}, optional - Configures which value to extract from the *Curve*. - - Examples - -------- - The below :class:`~rateslib.curves.Curve` is solved directly - from a calibrating DF value on 1st Nov 2022. + .. rubric:: Examples .. ipython:: python :suppress: - from rateslib import Value + from rateslib.instruments.components import Value + from datetime import datetime as dt + from rateslib import Curve, Solver + + The below :class:`~rateslib.curves.Curve` is solved directly + from a calibrating DF value on 1st Nov 2022. .. ipython:: python + val = Value(dt(2022, 11, 1), curves=["v"], metric="curve_value") curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 1.0}, id="v") - instruments = [(Value(dt(2022, 11, 1)), (curve,), {})] - solver = Solver([curve], [], instruments, [0.99]) - curve[dt(2022, 1, 1)] + solver = Solver(curves=[curve], instruments=[val], s=[0.99]) curve[dt(2022, 11, 1)] - curve[dt(2023, 1, 1)] + + .. rubric:: Pricing + + A *Value* requires, and will calibrate, just one *Curve*. This *Curve*, appropriating + a *rate curve* or an *index curve*, is dependent upon the ``metric``. + Allowable inputs are: + + .. code-block:: python + + curves = curve | [curve] # a single curve is repeated for all required curves + curves = {"rate_curve": rate_curve} | {"index_curve": index_curve} # dict form is explicit + + The various *rate* ``metric`` that can be calculated for a *Curve* are as follows; + + - *'curve_value'*: returns the discount factor or a value from a DF-based or value-based + *rate curve*. + - *'index_value'*: returns a daily interpolated index value using an index lag derived from the + *index curve*. + - *'cc_zero_rate'*: returns a continuously compounded zero rate to the provided *effective* + date from a DF based *rate curve*. + - *'o/n_rate'*: returns a 1 calendar day rate starting on the effective date with the provided + *convention* from a *rate curve*. + + .. role:: red + + .. role:: green + + Parameters + ---------- + effective : datetime, :red:`required` + The datetime index for which the `rate`, which is just the curve value, is + returned. + curves : _BaseCurve, str, dict, _Curves, Sequence, :green:`optional` + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. See + **Pricing**. + metric : str, :green:`optional` (set as 'curve_value') + The pricing metric returned by :meth:`~rateslib.instruments.components.Value.rate`. See + **Pricing**. + """ _rate_scalars = { @@ -79,9 +104,10 @@ class Value(_BaseInstrument): def __init__( self, effective: datetime, + *, convention: Convention | str_ = NoInput(0), metric: str_ = NoInput(0), - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), ) -> None: user_args = dict( effective=effective, @@ -99,18 +125,22 @@ def __init__( self._rate_scalar = self._rate_scalars.get(self.kwargs.meta["metric"], 1.0) - def _parse_curves(self, curves: Curves_) -> _Curves: + def _parse_curves(self, curves: CurvesT_) -> _Curves: """ A Value requires only one 1 curve, which is set as all element values """ if isinstance(curves, NoInput): return _Curves() - if isinstance(curves, dict): - raise ValueError("`curves` supplied to a Value should be a single _BaseCurve object.") + elif isinstance(curves, dict): + return _Curves( + rate_curve=curves.get("rate_curve", NoInput(0)), + index_curve=curves.get("index_curve", NoInput(0)), + disc_curve=curves.get("disc_curve", NoInput(0)), + ) elif isinstance(curves, list | tuple): if len(curves) != 1: raise ValueError( - "`curves` supplied to a Value should be a single _BaseCurve object." + f"{type(self).__name__} requires only 1 curve types. Got {len(curves)}." ) else: return _Curves( @@ -118,17 +148,19 @@ def _parse_curves(self, curves: Curves_) -> _Curves: disc_curve=curves[0], index_curve=curves[0], ) + elif isinstance(curves, _Curves): + return curves else: # `curves` is just a single input return _Curves( - rate_curve=curves, - disc_curve=curves, - index_curve=curves, + rate_curve=curves, # type: ignore[arg-type] + disc_curve=curves, # type: ignore[arg-type] + index_curve=curves, # type: ignore[arg-type] ) def rate( self, *, - curves: Curves_ = NoInput(0), + curves: CurvesT_ = NoInput(0), solver: Solver_ = NoInput(0), fx: FXForwards_ = NoInput(0), fx_vol: FXVolOption_ = NoInput(0), @@ -138,30 +170,35 @@ def rate( metric: str_ = NoInput(0), ) -> DualTypes: effective: datetime = self.kwargs.leg1["effective"] - convention: Convention | str = self.kwargs.leg1["convention"] _curves = self._parse_curves(curves) metric_ = _drb(self.kwargs.meta["metric"], metric).lower() if metric_ == "curve_value": - curve = _maybe_get_curve_or_dict_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "rate_curve", solver + curve = _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "rate_curve", solver + ) ) ret: DualTypes = curve[effective] elif metric_ == "cc_zero_rate": - curve = _maybe_get_curve_or_dict_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "rate_curve", solver + curve = _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "rate_curve", solver + ) ) if curve._base_type != _CurveType.dfs: raise TypeError( "`curve` used with `metric`='cc_zero_rate' must be discount factor based.", ) - dcf_ = dcf(start=curve.nodes.initial, end=effective, convention=convention) + dcf_ = dcf(start=curve.nodes.initial, end=effective, convention=curve.meta.convention) ret = (dual_log(curve[effective]) / -dcf_) * 100 elif metric_ == "index_value": - curve = _maybe_get_curve_or_dict_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "index_curve", solver + curve = _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "index_curve", solver + ) ) ret = curve.index_value( index_date=effective, @@ -170,8 +207,10 @@ def rate( ) elif metric_ == "o/n_rate": - curve = _maybe_get_curve_or_dict_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "rate_curve", solver + curve = _validate_base_curve( + _maybe_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "rate_curve", solver + ) ) ret = curve.rate(effective, "1D") # type: ignore[assignment] diff --git a/python/rateslib/solver.py b/python/rateslib/solver.py index 68b54be4..a4227c71 100644 --- a/python/rateslib/solver.py +++ b/python/rateslib/solver.py @@ -1857,6 +1857,11 @@ def gamma( first principles. The results are stated in the cross-gamma grid in figure 22.1. + .. ipython:: python + :suppress: + + from rateslib import Solver, Curve + .. ipython:: python curve_r = Curve( diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 461a687d..3b5b5dad 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -9,7 +9,7 @@ from rateslib.curves import CompositeCurve, Curve, LineCurve, MultiCsaCurve from rateslib.curves._parsers import _map_curve_from_solver from rateslib.default import NoInput -from rateslib.dual import Dual, Dual2, Variable, dual_exp, gradient +from rateslib.dual import Dual, Dual2, Variable, dual_exp, dual_log, gradient from rateslib.fx import FXForwards, FXRates from rateslib.fx_volatility import FXDeltaVolSmile, FXDeltaVolSurface, FXSabrSmile, FXSabrSurface from rateslib.instruments import ( @@ -2080,10 +2080,11 @@ def test_npv_adelta_cashflows_raises(self) -> None: value.analytic_delta() def test_cc_zero_rate(self, curve) -> None: - v = Value(effective=dt(2022, 7, 1), convention="act365f", metric="cc_zero_rate") + v = Value(effective=dt(2022, 7, 1), metric="cc_zero_rate") result = v.rate(curves=curve) - expected = 4.074026613753926 - assert result == expected + t = (dt(2022, 7, 1) - dt(2022, 1, 1)).days / 360 + expected = 100 * dual_log(curve[dt(2022, 7, 1)]) / -t + assert abs(result - expected) < 1e-12 def test_on_rate(self, curve) -> None: c = Curve({dt(2000, 1, 1): 1.0, dt(2000, 7, 1): 1.0}) @@ -4534,6 +4535,20 @@ def test_1m_spec_contracts(self, spec, expected, curve): result = stir.analytic_delta(curves=curve) assert abs(result - expected) < 1e-10 + def test_cashflows(self, curve): + stir = STIRFuture( + effective=dt(2022, 3, 16), + termination=dt(2022, 6, 15), + spec="usd_stir", + price=99.50, + contracts=10, + ) + result = stir.cashflows() + assert result["Payment"].iloc[0] is None + result2 = stir.cashflows(curves=curve) + assert result2["Payment"].iloc[0] == dt(2022, 1, 1) + assert result2["DF"].iloc[0] == 1.0 + class TestPricingMechanism: def test_value(self, curve) -> None: @@ -4941,6 +4956,20 @@ def test_fixings_table_null_inst(self, curve): table = spd.local_analytic_rate_fixings() assert isinstance(table, DataFrame) + def test_cashflows_curve_strings(self): + irs = IRS(dt(2025, 12, 1), dt(2030, 12, 7), spec="gbp_irs", curves=["uk_sonia"]) + ukt = FixedRateBond( + dt(2024, 12, 7), + dt(2030, 12, 7), + fixed_rate=4.75, + spec="uk_gb", + curves=["uk_gb"], + metric="ytm", + ) + asw = Spread(ukt, irs) + result = asw.cashflows() + assert isinstance(result, DataFrame) + class TestSensitivities: def test_sensitivity_raises(self) -> None: @@ -7244,3 +7273,26 @@ def test_repr(self): v = FXVolValue(0.25) expected = f"" assert v.__repr__() == expected + + +@pytest.mark.parametrize( + "inst", + [ + IRS(dt(2000, 1, 1), "1y", spec="usd_irs", curves="sofr"), + SBS(dt(2000, 1, 1), "1y", spec="eur_sbs36", curves=["eur", "eur", "eur", "eur"]), + STIRFuture(dt(2020, 1, 1), "1m", spec="usd_stir1", curves=["sofr"]), + XCS(dt(2000, 1, 1), "1y", spec="eurusd_xcs", curves=["a", "b", "c", "d"]), + CDS(dt(2000, 3, 20), "2y", spec="us_ig_cds", curves=["a", "b"]), + ZCS(dt(2000, 1, 1), "5y", spec="gbp_zcs", curves=["sonia"]), + ZCIS(dt(2000, 1, 1), "2y", spec="gbp_zcis", curves=["index", "sonia"]), + IIRS(dt(2000, 1, 1), "1y", spec="usd_irs", curves=["index", "sonia", "rate", "sonia"]), + FRA(dt(2000, 1, 1), "3m", spec="eur_fra3", curves=["eur"]), + NDF(dt(2000, 1, 1), pair="eurusd", curves=["usd"]), + FixedRateBond( + dt(2000, 1, 1), "2y", spec="uk_gb", curves=["uk"], fixed_rate=1.2, metric="ytm" + ), + ], +) +def test_unpriced_cashflows_string_id(inst): + result = inst.cashflows() + assert isinstance(result, DataFrame)