diff --git a/python/rateslib/default.py b/python/rateslib/default.py index 70e29150..c2b1d747 100644 --- a/python/rateslib/default.py +++ b/python/rateslib/default.py @@ -59,6 +59,9 @@ def __init__(self) -> None: # Instrument parameterisation + self.metric = { + "SBS": "float_spread", + } self.convention = "ACT360" self.notional = 1.0e6 self.index_lag = 3 diff --git a/python/rateslib/instruments/components/__init__.py b/python/rateslib/instruments/components/__init__.py index 7ecb810b..f30f6d1d 100644 --- a/python/rateslib/instruments/components/__init__.py +++ b/python/rateslib/instruments/components/__init__.py @@ -1,7 +1,15 @@ from rateslib.instruments.components.cds import CDS +from rateslib.instruments.components.fly import Fly from rateslib.instruments.components.irs import IRS +from rateslib.instruments.components.portfolio import Portfolio +from rateslib.instruments.components.sbs import SBS +from rateslib.instruments.components.spread import Spread __all__ = [ "IRS", "CDS", + "SBS", + "Portfolio", + "Fly", + "Spread", ] diff --git a/python/rateslib/instruments/components/cds.py b/python/rateslib/instruments/components/cds.py index 2e63a69b..d5359d56 100644 --- a/python/rateslib/instruments/components/cds.py +++ b/python/rateslib/instruments/components/cds.py @@ -13,10 +13,10 @@ from rateslib.scheduling import Frequency if TYPE_CHECKING: - from rateslib.typing import ( + from rateslib.typing import ( # pragma: no cover CalInput, - CurveOption_, Curves_, + DataFrame, DualTypes, DualTypes_, Frequency, @@ -67,11 +67,6 @@ def __init__( currency: str_ = NoInput(0), amortization: float_ = NoInput(0), convention: str_ = NoInput(0), - leg2_float_spread: DualTypes_ = NoInput(0), - leg2_spread_compound_method: str_ = NoInput(0), - leg2_rate_fixings: FixingsRates_ = NoInput(0), # type: ignore[type-var] - leg2_fixing_method: str_ = NoInput(0), - leg2_method_param: int_ = NoInput(0), leg2_effective: datetime_ = NoInput(1), leg2_termination: datetime | str_ = NoInput(1), leg2_frequency: Frequency | str_ = NoInput(0), @@ -170,7 +165,8 @@ def rate( base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), - ) -> DualTypes_: + metric: str_ = NoInput(0), + ) -> DualTypes: _curves = self._parse_curves(curves) disc_curve = _get_curve_maybe_from_solver( self.kwargs.meta["curves"], _curves, "disc_curve", solver @@ -199,7 +195,7 @@ def rate( / 100 ) - def accrued(self, settlement: datetime_ = NoInput(0)) -> DualTypes: + def accrued(self, settlement: datetime) -> DualTypes: """ Calculate the amount of premium accrued until a specific date within the relevant *Period*. @@ -229,25 +225,17 @@ def spread( settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), ) -> DualTypes: - _curves = self._parse_curves(curves) - leg2_rate_curve = _get_curve_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "leg2_rate_curve", solver - ) - disc_curve = _get_curve_maybe_from_solver( - self.kwargs.meta["curves"], _curves, "disc_curve", solver - ) - leg1_npv: DualTypes = self.leg1.local_npv( - rate_curve=NoInput(0), - disc_curve=disc_curve, - index_curve=NoInput(0), - settlement=settlement, - forward=forward, - ) - return self.leg2.spread( - target_npv=-leg1_npv, - rate_curve=leg2_rate_curve, - disc_curve=disc_curve, - index_curve=NoInput(0), + return ( + self.rate( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + * 100.0 ) def npv( @@ -297,14 +285,11 @@ def _set_pricing_mid( ) self.leg1.fixed_rate = _dual_float(mid_market_rate) - def _parse_curves(self, curves: CurveOption_) -> _Curves: + def _parse_curves(self, curves: Curves_) -> _Curves: """ A CDS has two curve requirements: a hazard_curve and a disc_curve used by both legs. - When given as only 1 element this curve is applied to all of those components, although - this is a technical failure - - When given as 2 elements the first is treated as the rate curve and the 2nd as disc curve. + When given as anything other than two curves will raise an Exception. """ if isinstance(curves, NoInput): return _Curves() @@ -329,21 +314,48 @@ def _parse_curves(self, curves: CurveOption_) -> _Curves: disc_curve=curves[1], leg2_disc_curve=curves[1], ) - elif len(curves) == 1: - return _Curves( - rate_curve=curves[0], - leg2_rate_curve=curves[0], - disc_curve=curves[0], - leg2_disc_curve=curves[0], - ) else: - raise ValueError( - f"{type(self).__name__} requires only 2 curve types. Got {len(curves)}." - ) - else: # `curves` is just a single input which is copied across all curves - return _Curves( - rate_curve=curves, - leg2_rate_curve=curves, - disc_curve=curves, - leg2_disc_curve=curves, - ) + raise ValueError(f"{type(self).__name__} requires 2 `curves`. Got {len(curves)}.") + + else: # `curves` is just a single input + raise ValueError(f"{type(self).__name__} requires 2 `curves`. Got 1.") + + def cashflows( + self, + *, + curves: Curves_ = 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: + return super()._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return self._local_analytic_rate_fixings_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) diff --git a/python/rateslib/instruments/components/fly.py b/python/rateslib/instruments/components/fly.py new file mode 100644 index 00000000..70430f9a --- /dev/null +++ b/python/rateslib/instruments/components/fly.py @@ -0,0 +1,295 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, NoReturn + +from pandas import DataFrame, DatetimeIndex + +from rateslib.enums.generics import NoInput +from rateslib.instruments.components.protocols import _BaseInstrument +from rateslib.instruments.components.protocols.utils import ( + _get_fx_maybe_from_solver, +) +from rateslib.periods.components.utils import _maybe_fx_converted + +if TYPE_CHECKING: + from rateslib.typing import ( + Any, + Curves_, + DualTypes, + FXForwards_, + FXVolOption_, + Solver_, + datetime_, + str_, + ) + + +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] + 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 + + +class Fly(_BaseInstrument): + """ + Create a butterfly of *Instruments*. + + Parameters + ---------- + instrument1 : _BaseInstrument + An *Instrument* with the shortest maturity. + instrument2 : _BaseInstrument + The *Instrument* of the body of the *Fly*. + instrument3 : _BaseInstrument + An *Instrument* with the longest maturity. + """ + + _instruments: Sequence[_BaseInstrument] + + @property + def instruments(self) -> Sequence[_BaseInstrument]: + """The *Instruments* contained within the *Portfolio*.""" + return self._instruments + + def __init__( + self, + instrument1: _BaseInstrument, + instrument2: _BaseInstrument, + instrument3: _BaseInstrument, + ) -> None: + self._instruments = [instrument1, instrument2, instrument3] + + def npv( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + """ + Return the NPV of the *Portfolio* by summing individual *Instrument* NPVs. + """ + local_npv = self._npv_single_core( + curves=curves, solver=solver, fx=fx, fx_vol=fx_vol, base=base + ) + if not local: + single_value: DualTypes = 0.0 + for k, v in local_npv.items(): + single_value += _maybe_fx_converted( + value=v, + currency=k, + fx=_get_fx_maybe_from_solver(fx=fx, solver=solver), + base=base, + ) + return single_value + else: + return local_npv + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + """ + Return a DataFrame of financial sensitivity to published interest rate fixings, + expressed in local **settlement currency** of the *Period*. + + If the *Period* has no sensitivity to rates fixings this *DataFrame* is empty. + + Parameters + ---------- + rate_curve: _BaseCurve or dict of such indexed by string tenor, optional + Used to forecast floating period rates, if necessary. + index_curve: _BaseCurve, optional + Used to forecast index values for indexation, if necessary. + disc_curve: _BaseCurve, optional + Used to discount cashflows. + fx: FXForwards, optional + The :class:`~rateslib.fx.FXForwards` object used for forecasting the + ``fx_fixing`` for deliverable cashflows, if necessary. Or, an + :class:`~rateslib.fx.FXRates` object purely for immediate currency conversion. + fx_vol: FXDeltaVolSmile, FXSabrSmile, FXDeltaVolSurface, FXSabrSurface, optional + The FX volatility *Smile* or *Surface* object used for determining Black calendar + day implied volatility values. + settlement: datetime, optional + The assumed settlement date of the *PV* determination. Used only to evaluate + *ex-dividend* status. + forward: datetime, optional + The future date to project the *PV* to using the ``disc_curve``. + + Returns + ------- + DataFrame + """ + df_result = DataFrame(index=DatetimeIndex([], name="obs_dates")) + for inst in self.instruments: + try: + df = inst.local_analytic_rate_fixings( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + forward=forward, + settlement=settlement, + ) + except AttributeError: + continue + df_result = _composit_fixings_table(df_result, df) + return df_result + + def cashflows( + self, + *, + curves: Curves_ = 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: + return self._cashflows_from_instruments( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + 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), + 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), + metric: str_ = NoInput(0), + ) -> DualTypes: + rates: list[DualTypes] = [] + for inst in self.instruments: + rates.append( + inst.rate( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + metric=metric, + ) + ) + return (-rates[0] + 2 * rates[1] - rates[2]) * 100.0 + + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`analytic_delta` is not defined for Portfolio.") diff --git a/python/rateslib/instruments/components/irs.py b/python/rateslib/instruments/components/irs.py index 4f6c67ec..8a372784 100644 --- a/python/rateslib/instruments/components/irs.py +++ b/python/rateslib/instruments/components/irs.py @@ -12,12 +12,14 @@ from rateslib.legs.components import FixedLeg, FloatLeg if TYPE_CHECKING: - from rateslib.typing import ( + from rateslib.typing import ( # pragma: no cover CalInput, CurveOption_, Curves_, + DataFrame, DualTypes, DualTypes_, + FixingsRates_, Frequency, FXForwards_, FXVolOption_, @@ -182,6 +184,7 @@ def rate( base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), + metric: str_ = NoInput(0), ) -> DualTypes_: _curves = self._parse_curves(curves) leg2_rate_curve = _get_curve_maybe_from_solver( @@ -335,3 +338,43 @@ def _parse_curves(self, curves: CurveOption_) -> _Curves: disc_curve=curves, leg2_disc_curve=curves, ) + + def cashflows( + self, + *, + curves: Curves_ = 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: + return super()._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return self._local_analytic_rate_fixings_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) diff --git a/python/rateslib/instruments/components/portfolio.py b/python/rateslib/instruments/components/portfolio.py new file mode 100644 index 00000000..ced4a1ba --- /dev/null +++ b/python/rateslib/instruments/components/portfolio.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, NoReturn + +from pandas import DataFrame + +from rateslib import defaults +from rateslib.enums.generics import NoInput +from rateslib.instruments.components.protocols import _BaseInstrument +from rateslib.instruments.components.protocols.utils import ( + _get_fx_maybe_from_solver, +) +from rateslib.periods.components.utils import _maybe_fx_converted + +if TYPE_CHECKING: + from rateslib.typing import ( + Any, + Curves_, + DualTypes, + FXForwards_, + FXVolOption_, + Solver_, + datetime_, + str_, + ) + + +def _instrument_npv( + instrument: _BaseInstrument, *args: Any, **kwargs: Any +) -> DualTypes | dict[str, DualTypes]: # 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) + + +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] + 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 + + +class Portfolio(_BaseInstrument): + """ + Create a collection of *Instruments* to group metrics + + Parameters + ---------- + instruments : list + This should be a list 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. + + Examples + -------- + See examples for :class:`Spread` for similar functionality. + """ + + _instruments: Sequence[_BaseInstrument] + + @property + def instruments(self) -> Sequence[_BaseInstrument]: + """The *Instruments* contained within the *Portfolio*.""" + return self._instruments + + def __init__(self, instruments: Sequence[_BaseInstrument]) -> None: + if not isinstance(instruments, Sequence): + raise ValueError("`instruments` should be a list of Instruments.") + self._instruments = instruments + + def npv( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + """ + Return the NPV of the *Portfolio* by summing individual *Instrument* NPVs. + """ + # if the pool is 1 do not do any parallel processing and return the single core func + if defaults.pool == 1: + local_npv: dict[str, DualTypes] = self._npv_single_core( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + else: + from functools import partial + from multiprocessing import Pool + + func = partial( + _instrument_npv, + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + local=True, + forward=forward, + settlement=settlement, + ) + p = Pool(defaults.pool) + results = p.map(func, self.instruments) + p.close() + + # Aggregate results: + _ = DataFrame(results).fillna(0.0) + _ = _.sum() + local_npv = _.to_dict() + + # ret = {} + # for result in results: + # for ccy in result: + # if ccy in ret: + # ret[ccy] += result[ccy] + # else: + # ret[ccy] = result[ccy] + + if not local: + single_value: DualTypes = 0.0 + for k, v in local_npv.items(): + single_value += _maybe_fx_converted( + value=v, + currency=k, + fx=_get_fx_maybe_from_solver(fx=fx, solver=solver), + base=base, + ) + return single_value + else: + return local_npv + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return self._local_analytic_rate_fixings_from_instruments( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) + + def cashflows( + self, + *, + curves: Curves_ = 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: + return self._cashflows_from_instruments( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + 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.") + + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`analytic_delta` is not defined for Portfolio.") diff --git a/python/rateslib/instruments/components/protocols/analytic_delta.py b/python/rateslib/instruments/components/protocols/analytic_delta.py index 91f987a8..5351e11f 100644 --- a/python/rateslib/instruments/components/protocols/analytic_delta.py +++ b/python/rateslib/instruments/components/protocols/analytic_delta.py @@ -26,7 +26,7 @@ class _WithAnalyticDelta(_WithCurves, Protocol): """ - Protocol to establish value of any *Instrument* type. + Protocol to determine the *analytic rate delta* of a particular *Leg* of an *Instrument*. """ _legs: list[_BaseLeg] diff --git a/python/rateslib/instruments/components/protocols/analytic_fixings.py b/python/rateslib/instruments/components/protocols/analytic_fixings.py index 0b1e4d94..2cb70b3d 100644 --- a/python/rateslib/instruments/components/protocols/analytic_fixings.py +++ b/python/rateslib/instruments/components/protocols/analytic_fixings.py @@ -3,7 +3,7 @@ import warnings from typing import TYPE_CHECKING, Protocol -from pandas import DataFrame, concat +from pandas import DataFrame, DatetimeIndex, concat from rateslib.enums.generics import NoInput from rateslib.instruments.components.protocols.curves import _WithCurves @@ -18,16 +18,49 @@ FXForwards_, FXVolOption_, Solver_, - _BaseLeg, _Curves, datetime_, ) -class _WithAnalyticRateFixings(_WithCurves, Protocol): - @property - def legs(self) -> list[_BaseLeg]: ... +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] + 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 + +class _WithAnalyticRateFixings(_WithCurves, Protocol): def local_analytic_rate_fixings( self, *, @@ -69,6 +102,22 @@ def local_analytic_rate_fixings( ------- DataFrame """ + raise NotImplementedError( + f"{type(self).__name__} must implement `local_analytic_rate_fixings`" + ) + + def _local_analytic_rate_fixings_from_legs( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + assert hasattr(self, "legs") + # this is a generic implementation to handle 2 legs. _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] @@ -113,4 +162,33 @@ def local_analytic_rate_fixings( with warnings.catch_warnings(): # TODO: pandas 2.1.0 has a FutureWarning for concatenating DataFrames with Null entries warnings.filterwarnings("ignore", category=FutureWarning) - return concat(dfs) + df = concat(dfs) + return df.sort_index() + + def _local_analytic_rate_fixings_from_instruments( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + assert hasattr(self, "instruments") + + df_result = DataFrame(index=DatetimeIndex([], name="obs_dates")) + for inst in self.instruments: + try: + df = inst.local_analytic_rate_fixings( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + forward=forward, + settlement=settlement, + ) + except AttributeError: + continue + df_result = _composit_fixings_table(df_result, df) + return df_result diff --git a/python/rateslib/instruments/components/protocols/cashflows.py b/python/rateslib/instruments/components/protocols/cashflows.py index 734086cc..b0127775 100644 --- a/python/rateslib/instruments/components/protocols/cashflows.py +++ b/python/rateslib/instruments/components/protocols/cashflows.py @@ -15,11 +15,11 @@ if TYPE_CHECKING: from rateslib.typing import ( + Any, Curves_, FXForwards_, FXVolOption_, Solver_, - _BaseLeg, _Curves, datetime_, str_, @@ -27,13 +27,8 @@ class _WithCashflows(_WithCurves, Protocol): - _legs: list[_BaseLeg] _kwargs: _KWArgs - @property - def legs(self) -> list[_BaseLeg]: - return self._legs - @property def kwargs(self) -> _KWArgs: return self._kwargs @@ -63,6 +58,37 @@ def cashflows( ---------- XXX + Returns + ------- + dict of values + """ + raise NotImplementedError(f"{type(self).__name__} must implement `cashflows`.") + + def _cashflows_from_legs( + self, + *, + curves: Curves_ = 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: + """ + Return aggregated cashflow data for the *Period*. + + .. warning:: + + This method is a convenience method to provide a visual representation of all + associated calculation data. Calling this method to extracting certain values + should be avoided. It is more efficent to source relevant parameters or calculations + from object attributes or other methods directly. + + Parameters + ---------- + XXX + Returns ------- dict of values @@ -70,6 +96,7 @@ def cashflows( # this is a generalist implementation of an NPV function for an instrument with 2 legs. # most instruments may be likely to implement NPV directly to benefit from optimisations # specific to that instrument + assert hasattr(self, "legs") _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] @@ -111,3 +138,15 @@ def cashflows( warnings.filterwarnings("ignore", category=FutureWarning) _: DataFrame = concat(dfs_filtered, keys=["leg1", "leg2"]) return _ + + def _cashflows_from_instruments(self, *args: Any, **kwargs: Any) -> DataFrame: + # this is a generalist implementation of an NPV function for an instrument with 2 legs. + # most instruments may be likely to implement NPV directly to benefit from optimisations + # specific to that instrument + assert hasattr(self, "instruments") + + _: DataFrame = concat( + [_.cashflows(*args, **kwargs) for _ in self.instruments], + keys=[f"inst{i}" for i in range(len(self.instruments))], + ) + return _ diff --git a/python/rateslib/instruments/components/protocols/kwargs.py b/python/rateslib/instruments/components/protocols/kwargs.py index bb1125f7..6f07f307 100644 --- a/python/rateslib/instruments/components/protocols/kwargs.py +++ b/python/rateslib/instruments/components/protocols/kwargs.py @@ -102,7 +102,7 @@ def _convert_to_schedule_kwargs(kwargs: dict[str, Any], leg: int) -> dict[str, A class _KWArgs: """ - Class to manage keyword argument population of instruments. + Class to manage keyword argument population of *Leg* based *Instruments*. This will first populate any provided ``spec`` arguments if given. Second, the user input arguments that are specific values will overwrite these. diff --git a/python/rateslib/instruments/components/protocols/npv.py b/python/rateslib/instruments/components/protocols/npv.py index fc6296b9..0032aab8 100644 --- a/python/rateslib/instruments/components/protocols/npv.py +++ b/python/rateslib/instruments/components/protocols/npv.py @@ -18,7 +18,6 @@ FXForwards_, FXVolOption_, Solver_, - _BaseLeg, _Curves, datetime_, str_, @@ -29,14 +28,8 @@ class _WithNPV(_WithCurves, Protocol): """ Protocol to establish value of any *Instrument* type. """ - - _legs: list[_BaseLeg] _kwargs: _KWArgs - @property - def legs(self) -> list[_BaseLeg]: - return self._legs - @property def kwargs(self) -> _KWArgs: return self._kwargs @@ -101,6 +94,7 @@ def npv( # this is a generalist implementation of an NPV function for an instrument with 2 legs. # most instruments may be likely to implement NPV directly to benefit from optimisations # specific to that instrument + assert hasattr(self, "legs") _curves: _Curves = self._parse_curves(curves) _curves_meta: _Curves = self.kwargs.meta["curves"] @@ -155,3 +149,39 @@ def npv( return single_value else: return local_npv + + def _npv_single_core( + self, + *, + curves: Curves_ = 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), + ) -> dict[str, DualTypes]: + """ + Private NPV summation function used with a single thread, over all `self.instruments`. + """ + assert hasattr(self, "instruments") + + local_npv: dict[str, DualTypes] = {} + for instrument in self.instruments: + inst_local_npv = instrument.npv( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + local=True, + settlement=settlement, + forward=forward, + ) + + for k, v in inst_local_npv.items(): + if k in local_npv: + local_npv[k] += v + else: + local_npv[k] = v + return local_npv diff --git a/python/rateslib/instruments/components/protocols/rate.py b/python/rateslib/instruments/components/protocols/rate.py index 92f2b2f0..06cceccf 100644 --- a/python/rateslib/instruments/components/protocols/rate.py +++ b/python/rateslib/instruments/components/protocols/rate.py @@ -33,6 +33,7 @@ def rate( base: str_ = NoInput(0), settlement: datetime_ = NoInput(0), forward: datetime_ = NoInput(0), + metric: str_ = NoInput(0), ) -> DualTypes: raise NotImplementedError(f"`rate` must be implemented for type: {type(self).__name__}") @@ -50,7 +51,7 @@ def spread( raise NotImplementedError(f"`spread` is not implemented for type: {type(self).__name__}") @property - def rate_scalar(self): + def rate_scalar(self) -> float: """ A scaling quantity associated with the :class:`~rateslib.solver.Solver` risk calculations. """ diff --git a/python/rateslib/instruments/components/protocols/sensitivities.py b/python/rateslib/instruments/components/protocols/sensitivities.py index b4ed5704..7a29f580 100644 --- a/python/rateslib/instruments/components/protocols/sensitivities.py +++ b/python/rateslib/instruments/components/protocols/sensitivities.py @@ -17,6 +17,7 @@ DualTypes, FXForwards_, FXVolOption_, + NoInput, Solver_, datetime_, str_, @@ -87,15 +88,17 @@ def delta( 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, + 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), + vars: list[str], # noqa: A002 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*. @@ -142,22 +145,21 @@ def exo_delta( """ 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), + npv: dict[str, DualTypes] = self.npv( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + forward=forward, + settlement=settlement, + local=True, ) - if local: - base_ = NoInput(0) return solver.exo_delta( - npv=npv, # type: ignore[arg-type] + npv=npv, vars=vars, - base=base_, - fx=fx_, + base=base, + fx=_get_fx_maybe_from_solver(fx=fx, solver=solver), vars_scalar=vars_scalar, vars_labels=vars_labels, ) diff --git a/python/rateslib/instruments/components/protocols/utils.py b/python/rateslib/instruments/components/protocols/utils.py index 51816c99..9de7581d 100644 --- a/python/rateslib/instruments/components/protocols/utils.py +++ b/python/rateslib/instruments/components/protocols/utils.py @@ -2,21 +2,14 @@ from typing import TYPE_CHECKING, TypeVar -import rateslib.errors as err from rateslib.curves._parsers import _map_curve_from_solver, _validate_no_str_in_curve_input -from rateslib.enums.generics import Err, NoInput, Ok, Result, _drb +from rateslib.enums.generics import NoInput, _drb if TYPE_CHECKING: from rateslib.typing import ( FX_, - CurveOption, CurveOption_, - Curves_, - Curves_DiscTuple, FXForwards_, - FXVolOption_, - InstrumentCurves, - Sequence, Solver_, _BaseCurve, _BaseCurve_, @@ -62,118 +55,12 @@ def _get_fx_maybe_from_solver( return fx_ -def _get_curves_fx_vol_maybe_from_solver( - curves_meta: Curves_, - curves: Curves_, - fx_vol_meta: FXVolOption_, - fx_vol: FXVolOption_, - fx: FX_, - solver: Solver_, -) -> tuple[dict[str, CurveOption_], FXVolOption_, FXForwards_]: - """ - Attempt to resolve pricing objects from given inputs or attached to a *Solver* - - 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 - ------- - curves: 6-Tuple of Curve, dict[str, Curve], NoInput, - fx_vol: FXVol, NoInput - fx: FXForwards, NoInput - """ - is_solver = not isinstance(solver, NoInput) - - # Get the `fx` from Solver only if not directly provided and Solver exists. - fx_: FXForwards_ - if isinstance(fx, NoInput): - if is_solver: - fx_ = solver.fx - else: - fx_ = NoInput(0) - else: - fx_ = fx - - # Get the `curves` from a combination - curves_: InstrumentCurves - if isinstance(curves, NoInput) and isinstance(curves_meta, NoInput): - # no data is available to derive curves - curves_ = (NoInput(0), NoInput(0), 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_meta - - # refactor curves into a list - if isinstance(curves, str) or not isinstance(curves, Sequence): # Sequence can be str! - # convert isolated value input to list - curves_as_list: list[ - _BaseCurve - | dict[str, str | _BaseCurve] - | dict[str, str] - | dict[str, _BaseCurve] - | NoInput - | str - ] = [curves] - else: - curves_as_list = 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_curve_is_not_dict(curve: CurveOption_) -> _BaseCurve_: 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_curve_is_not_dict(curves_tuple[1]), - curves_tuple[2], - _validate_curve_is_not_dict(curves_tuple[3]), - ) - - def _validate_curve_not_no_input(curve: _BaseCurve_) -> _BaseCurve: if isinstance(curve, NoInput): raise ValueError("`curve` must be supplied. Got NoInput or None.") @@ -189,53 +76,6 @@ def _validate_obj_not_no_input(obj: T | NoInput, name: str) -> T: return obj -def _disc_maybe_from_curve(curve: CurveOption_, disc_curve: _BaseCurve_) -> _BaseCurve_: - """Return a discount curve, pointed as the `curve` if not provided and if suitable Type.""" - if isinstance(disc_curve, NoInput): - if isinstance(curve, dict): - raise ValueError("`disc_curve` cannot be inferred from a dictionary of curves.") - elif isinstance(curve, NoInput): - return NoInput(0) - elif curve._base_type == _CurveType.values: - raise ValueError("`disc_curve` cannot be inferred from a non-DF based curve.") - _: _BaseCurve | NoInput = curve - else: - _ = disc_curve - return _ - - -def _disc_required_maybe_from_curve(curve: CurveOption_, disc_curve: CurveOption_) -> _BaseCurve: - """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.") - _: _BaseCurve_ = _disc_maybe_from_curve(curve, disc_curve) - if isinstance(_, NoInput): - raise TypeError( - "`curves` have not been supplied correctly. " - "A `disc_curve` is required to perform function." - ) - return _ - - -def _try_disc_required_maybe_from_curve( - curve: CurveOption_, disc_curve: CurveOption_ -) -> Result[_BaseCurve]: - """Return a discount curve, pointed as the `curve` if not provided and if suitable Type.""" - if isinstance(disc_curve, dict): - return Err(NotImplementedError(err.NI_NO_DISC_FROM_DICT)) - if isinstance(disc_curve, NoInput): - if isinstance(curve, dict): - return Err(NotImplementedError(err.NI_NO_DISC_FROM_DICT)) - elif isinstance(curve, NoInput): - return Err(ValueError(err.VE_NEEDS_DISC_CURVE)) - elif curve._base_type == _CurveType.values: - return Err(ValueError(err.VE_NO_DISC_FROM_VALUES)) - return Ok(curve) - if disc_curve._base_type == _CurveType.values: - return Err(ValueError(err.VE_NO_DISC_FROM_VALUES)) - return Ok(disc_curve) - - def _maybe_set_ad_order( curve: CurveOption_, order: int | dict[str, int | None] | None ) -> int | dict[str, int | None] | None: @@ -263,85 +103,3 @@ def _maybe_set_ad_order( # Curve has no method (possibly a custom curve and not a subclass of _BaseCurve) return None return original_order - - -def _to_six_curve_dict( - curves: CurveOption | list[CurveOption] | dict[str, CurveOption], -) -> dict[str, CurveOption_]: - if isinstance(curves, list | tuple): - if len(curves) == 1: - return dict( - rate=curves[0], - disc=curves[0], - index=NoInput(0), - rate2=curves[0], - disc2=curves[0], - index2=NoInput(0), - ) - if len(curves) == 2: - return dict( - rate=curves[0], - disc=curves[1], - index=NoInput(0), - rate2=curves[0], - disc2=curves[1], - index2=NoInput(0), - ) - if len(curves) == 3: - return dict( - rate=curves[0], - disc=curves[1], - index=curves[2], - rate2=curves[0], - disc2=curves[1], - index2=curves[2], - ) - if len(curves) == 4: - return dict( - rate=curves[0], - disc=curves[1], - index=NoInput(0), - rate2=curves[2], - disc2=curves[3], - index2=NoInput(0), - ) - if len(curves) == 5: - return dict( - rate=curves[0], - disc=curves[1], - index=curves[2], - rate2=curves[3], - disc2=curves[4], - index2=curves[2], - ) - if len(curves) == 6: - return dict( - rate=curves[0], - disc=curves[1], - index=curves[2], - rate2=curves[3], - disc2=curves[4], - index2=curves[5], - ) - else: - raise ValueError( - f"`curves` as sequence must not be greater than 6 in length, got: {len(curves)}." - ) - elif isinstance(curves, dict): - return dict( - rate=curves.get("rate", None) or NoInput(0), - disc=curves.get("disc", None) or curves.get("rate", None) or NoInput(0), - index=curves.get("index", None) or NoInput(0), - rate2=curves.get("rate2", None) or curves.get("rate", None) or NoInput(0), - disc2=curves.get("disc2", None) or curves.get("disc", None) or NoInput(0), - index2=curves.get("index2", None) or curves.get("index", None) or NoInput(0), - ) - else: - return dict( - rate=curves, - disc=curves, - index=NoInput(0), - rate2=curves, - disc2=curves, - index2=NoInput(0), - ) diff --git a/python/rateslib/instruments/components/sbs.py b/python/rateslib/instruments/components/sbs.py new file mode 100644 index 00000000..05e2e5f9 --- /dev/null +++ b/python/rateslib/instruments/components/sbs.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, NoReturn + +from rateslib import defaults +from rateslib.curves._parsers import _Curves +from rateslib.dual.utils import _dual_float +from rateslib.enums.generics import NoInput, _drb +from rateslib.instruments.components.protocols import _BaseInstrument +from rateslib.instruments.components.protocols.kwargs import _convert_to_schedule_kwargs, _KWArgs +from rateslib.instruments.components.protocols.utils import _get_curve_maybe_from_solver +from rateslib.legs.components import FloatLeg + +if TYPE_CHECKING: + from rateslib.typing import ( # pragma: no cover + CalInput, + CurveOption_, + Curves_, + DataFrame, + DualTypes, + DualTypes_, + Frequency, + FXForwards_, + FXVolOption_, + RollDay, + Solver_, + bool_, + datetime, + datetime_, + float_, + int_, + str_, + ) + + +class SBS(_BaseInstrument): + _rate_scalar = 100.0 + + @property + def fixed_rate(self) -> DualTypes_: + raise AttributeError(f"Attribute not available on {type(self).__name__}") + + @property + def float_spread(self) -> DualTypes_: + return self.leg1.float_spread + + @float_spread.setter + def float_spread(self, value: DualTypes) -> None: + self.kwargs.leg1["float_spread"] = value + self.leg1.float_spread = value + + @property + def leg2_fixed_rate(self) -> NoReturn: + raise AttributeError(f"Attribute not available on {type(self).__name__}") + + @property + def leg2_float_spread(self) -> DualTypes_: + return self.leg2.float_spread + + @leg2_float_spread.setter + def leg2_float_spread(self, value: DualTypes) -> None: + self.kwargs.leg2["float_spread"] = value + self.leg2.float_spread = value + + def __init__( + self, + effective: datetime_ = NoInput(0), + termination: datetime | str_ = NoInput(0), + frequency: Frequency | str_ = NoInput(0), + *, + float_spread: DualTypes_ = NoInput(0), + spread_compound_method: str_ = NoInput(0), + rate_fixings: FixingsRates_ = NoInput(0), # type: ignore[type-var] + fixing_method: str_ = NoInput(0), + method_param: int_ = NoInput(0), + stub: str_ = NoInput(0), + front_stub: datetime_ = NoInput(0), + back_stub: datetime_ = NoInput(0), + roll: int | RollDay | str_ = NoInput(0), + eom: bool_ = NoInput(0), + modifier: str_ = NoInput(0), + calendar: CalInput = NoInput(0), + payment_lag: int_ = NoInput(0), + payment_lag_exchange: int_ = NoInput(0), + ex_div: int_ = NoInput(0), + notional: float_ = NoInput(0), + currency: str_ = NoInput(0), + amortization: float_ = NoInput(0), + convention: str_ = NoInput(0), + leg2_float_spread: DualTypes_ = NoInput(0), + leg2_spread_compound_method: str_ = NoInput(0), + leg2_rate_fixings: FixingsRates_ = NoInput(0), # type: ignore[type-var] + leg2_fixing_method: str_ = NoInput(0), + leg2_method_param: int_ = NoInput(0), + leg2_effective: datetime_ = NoInput(1), + leg2_termination: datetime | str_ = NoInput(1), + leg2_frequency: Frequency | str_ = NoInput(1), + leg2_stub: str_ = NoInput(1), + leg2_front_stub: datetime_ = NoInput(1), + leg2_back_stub: datetime_ = NoInput(1), + leg2_roll: int | RollDay | str_ = NoInput(1), + leg2_eom: bool_ = NoInput(1), + leg2_modifier: str_ = NoInput(1), + leg2_calendar: CalInput = NoInput(1), + leg2_payment_lag: int_ = NoInput(1), + leg2_payment_lag_exchange: int_ = NoInput(1), + leg2_notional: float_ = NoInput(-1), + leg2_amortization: float_ = NoInput(-1), + leg2_convention: str_ = NoInput(1), + leg2_ex_div: int_ = NoInput(1), + curves: Curves_ = NoInput(0), + metric: str_ = NoInput(0), + spec: str_ = NoInput(0), + ) -> None: + user_args = dict( + effective=effective, + termination=termination, + frequency=frequency, + float_spread=float_spread, + spread_compound_method=spread_compound_method, + rate_fixings=rate_fixings, + fixing_method=fixing_method, + method_param=method_param, + stub=stub, + front_stub=front_stub, + back_stub=back_stub, + roll=roll, + eom=eom, + modifier=modifier, + calendar=calendar, + payment_lag=payment_lag, + payment_lag_exchange=payment_lag_exchange, + ex_div=ex_div, + notional=notional, + currency=currency, + amortization=amortization, + convention=convention, + leg2_float_spread=leg2_float_spread, + leg2_spread_compound_method=leg2_spread_compound_method, + leg2_rate_fixings=leg2_rate_fixings, + leg2_fixing_method=leg2_fixing_method, + leg2_method_param=leg2_method_param, + 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_payment_lag_exchange=leg2_payment_lag_exchange, + leg2_ex_div=leg2_ex_div, + leg2_notional=leg2_notional, + leg2_amortization=leg2_amortization, + leg2_convention=leg2_convention, + curves=self._parse_curves(curves), + metric=metric, + ) + instrument_args = dict( # these are hard coded arguments specific to this instrument + leg2_currency=NoInput(1), + initial_exchange=False, + final_exchange=False, + leg2_initial_exchange=False, + leg2_final_exchange=False, + ) + + default_args = dict( + notional=defaults.notional, + payment_lag=defaults.payment_lag_specific[type(self).__name__], + payment_lag_exchange=defaults.payment_lag_exchange, + metric=defaults.metric[type(self).__name__], + ) + self._kwargs = _KWArgs( + spec=spec, + user_args={**user_args, **instrument_args}, + default_args=default_args, + meta_args=["curves", "metric"], + ) + + self.leg1 = FloatLeg(**_convert_to_schedule_kwargs(self.kwargs.leg1, 1)) + self.leg2 = FloatLeg(**_convert_to_schedule_kwargs(self.kwargs.leg2, 1)) + self._legs = [self.leg1, self.leg2] + + def rate( + self, + *, + curves: Curves_ = 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), + metric: str_ = NoInput(0), + ) -> DualTypes_: + metric_: str = _drb(self.kwargs.meta["metric"], metric) + _curves = self._parse_curves(curves) + + if metric_.lower() == "float_spread": + leg2_npv: DualTypes = self.leg2.local_npv( + rate_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_rate_curve", solver + ), + disc_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_disc_curve", solver + ), + index_curve=NoInput(0), + settlement=settlement, + forward=forward, + ) + return self.leg1.spread( + target_npv=-leg2_npv, + rate_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "rate_curve", solver + ), + disc_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "disc_curve", solver + ), + index_curve=NoInput(0), + settlement=settlement, + forward=forward, + ) + else: # metric == "leg2_float_spread" + leg1_npv: DualTypes = self.leg1.local_npv( + rate_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "rate_curve", solver + ), + disc_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "disc_curve", solver + ), + index_curve=NoInput(0), + settlement=settlement, + forward=forward, + ) + return self.leg2.spread( + target_npv=-leg1_npv, + rate_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_rate_curve", solver + ), + disc_curve=_get_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_disc_curve", solver + ), + index_curve=NoInput(0), + settlement=settlement, + forward=forward, + ) + + def spread( + self, + *, + curves: Curves_ = 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), + metric: str_ = NoInput(0), + ) -> DualTypes: + return self.rate( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + metric=metric, + ) + + def npv( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + self._set_pricing_mid( + curves=curves, + solver=solver, + settlement=settlement, + forward=forward, + ) + return super().npv( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + local=local, + settlement=settlement, + forward=forward, + ) + + def _set_pricing_mid( + self, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> None: + # the test for an unpriced IRS is that its fixed rate is not set. + if self.kwargs.meta["metric"].lower() == "float_spread": + if isinstance(self.kwargs.leg1["float_spread"], NoInput): + # set a fixed rate for the purpose of generic methods NPV will be zero. + mid_market_rate = self.rate( + curves=curves, + solver=solver, + settlement=settlement, + forward=forward, + ) + self.leg1.float_spread = _dual_float(mid_market_rate) + else: # metric == "leg2_float_spread" + if isinstance(self.kwargs.leg2["float_spread"], NoInput): + # set a fixed rate for the purpose of generic methods NPV will be zero. + mid_market_rate = self.rate( + curves=curves, + solver=solver, + settlement=settlement, + forward=forward, + ) + self.leg2.float_spread = _dual_float(mid_market_rate) + + def _parse_curves(self, curves: CurveOption_) -> _Curves: + """ + An SBS has three curve requirements: + + - a rate_curve + - a disc_curve + - a leg2_rate_curve + + When given as only 1 element this curve is applied to all of the those components + + When given as 2 elements this will raise an Exception. + """ + 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 or len(curves) == 1 or len(curves) > 4: + raise TypeError(f"Number of `curves` for an SBS must be 3 or 4. Got {len(curves)}.") + elif len(curves) == 3: + return _Curves( + rate_curve=curves[0], + disc_curve=curves[1], + leg2_rate_curve=curves[2], + leg2_disc_curve=curves[1], + ) + else: # == 4 + return _Curves( + rate_curve=curves[0], + disc_curve=curves[1], + leg2_rate_curve=curves[2], + leg2_disc_curve=curves[3], + ) + else: # `curves` is just a single input + raise TypeError("Number of `curves` for an SBS must be 3 or 4. Got 1.") + + def cashflows( + self, + *, + curves: Curves_ = 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: + return super()._cashflows_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + ) + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return self._local_analytic_rate_fixings_from_legs( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) diff --git a/python/rateslib/instruments/components/spread.py b/python/rateslib/instruments/components/spread.py new file mode 100644 index 00000000..ba0c9b42 --- /dev/null +++ b/python/rateslib/instruments/components/spread.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from collections.abc import Sequence +from typing import TYPE_CHECKING, NoReturn + +from pandas import DataFrame + +from rateslib.enums.generics import NoInput +from rateslib.instruments.components.protocols import _BaseInstrument +from rateslib.instruments.components.protocols.utils import ( + _get_fx_maybe_from_solver, +) +from rateslib.periods.components.utils import _maybe_fx_converted + +if TYPE_CHECKING: + from rateslib.typing import ( + Any, + Curves_, + DualTypes, + FXForwards_, + FXVolOption_, + Solver_, + datetime_, + str_, + ) + + +class Spread(_BaseInstrument): + """ + Create a *Spread* of *Instruments*. + + Parameters + ---------- + instrument1 : _BaseInstrument + An *Instrument* with the shortest maturity. + instrument2 : _BaseInstrument + The *Instrument* with the longest maturity. + """ + + _instruments: Sequence[_BaseInstrument] + + @property + def instruments(self) -> Sequence[_BaseInstrument]: + """The *Instruments* contained within the *Portfolio*.""" + return self._instruments + + def __init__( + self, + instrument1: _BaseInstrument, + instrument2: _BaseInstrument, + ) -> None: + self._instruments = [instrument1, instrument2] + + def npv( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: + """ + Return the NPV of the *Portfolio* by summing individual *Instrument* NPVs. + """ + local_npv = self._npv_single_core( + curves=curves, solver=solver, fx=fx, fx_vol=fx_vol, base=base, + ) + if not local: + single_value: DualTypes = 0.0 + for k, v in local_npv.items(): + single_value += _maybe_fx_converted( + value=v, + currency=k, + fx=_get_fx_maybe_from_solver(fx=fx, solver=solver), + base=base, + ) + return single_value + else: + return local_npv + + def local_analytic_rate_fixings( + self, + *, + curves: Curves_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + return self._local_analytic_rate_fixings_from_instruments( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) + + def cashflows( + self, + *, + curves: Curves_ = 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: + return self._cashflows_from_instruments( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + 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), + 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), + metric: str_ = NoInput(0), + ) -> DualTypes: + rates: list[DualTypes] = [] + for inst in self.instruments: + rates.append( + inst.rate( + curves=curves, + solver=solver, + fx=fx, + fx_vol=fx_vol, + base=base, + settlement=settlement, + forward=forward, + metric=metric, + ) + ) + return (rates[1] - rates[0]) * 100.0 + + def analytic_delta(self, *args: Any, **kwargs: Any) -> NoReturn: + raise NotImplementedError("`analytic_delta` is not defined for Portfolio.") diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 7ff47352..2ed62368 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -18,14 +18,14 @@ IIRS, # IRS, NDF, - SBS, + # SBS, XCS, ZCIS, ZCS, Bill, FixedRateBond, FloatRateNote, - Fly, + # Fly, FXBrokerFly, FXCall, FXExchange, @@ -35,8 +35,8 @@ FXStrangle, FXSwap, IndexFixedRateBond, - Portfolio, - Spread, + # Portfolio, + # Spread, STIRFuture, Value, VolValue, @@ -44,6 +44,10 @@ from rateslib.instruments.components import ( CDS, IRS, + SBS, + Fly, + Portfolio, + Spread, ) from rateslib.instruments.utils import ( _get_curves_fx_and_base_maybe_from_solver, @@ -1409,6 +1413,21 @@ def test_custom_amortization_as_object(self): assert irs.leg2.amortization.outstanding == (-1000.0, -900.0, -500.0, -450.0) assert irs.leg2.amortization.amortization == (-100.0, -400.0, -50.0) + def test_irs_attributes(self): + irs = IRS(dt(2000, 1, 1), dt(2000, 5, 1), "M", fixed_rate=2.0) + assert irs.fixed_rate == 2.0 + with pytest.raises(AttributeError, match="Attribute not available on IRS"): + irs.float_spread + with pytest.raises(AttributeError, match="Attribute not available on IRS"): + irs.leg2_fixed_rate + assert irs.leg2_float_spread == 0.0 + + def test_irs_parse_curves(self, curve): + irs = IRS(dt(2000, 1, 1), dt(2000, 5, 1), "M", fixed_rate=2.0) + r1 = irs.npv(curves=[curve]) + r2 = irs.npv(curves={"rate_curve": curve, "disc_curve": curve}) + assert r1 == r2 + class TestIIRS: def test_index_base_none_populated(self, curve) -> None: @@ -1559,29 +1578,29 @@ def test_fixing_in_the_past(self): class TestSBS: def test_sbs_npv(self, curve) -> None: sbs = SBS(dt(2022, 1, 1), "9M", "Q", float_spread=3.0) - a_delta = sbs.analytic_delta(curve, curve, leg=1) - npv = sbs.npv(curve) + a_delta = sbs.analytic_delta(curves=[curve, curve, curve], leg=1) + npv = sbs.npv(curves=[curve, curve, curve]) assert abs(npv + 3.0 * a_delta) < 1e-9 sbs.leg2_float_spread = 4.5 - npv = sbs.npv(curve) + npv = sbs.npv(curves=[curve, curve, curve]) assert abs(npv - 1.5 * a_delta) < 1e-9 def test_sbs_rate(self, curve) -> None: sbs = SBS(dt(2022, 1, 1), "9M", "Q", float_spread=3.0) - result = sbs.rate([curve], leg=1) - alias = sbs.spread([curve], leg=1) + result = sbs.rate(curves=[curve] * 3) + alias = sbs.spread(curves=[curve] * 3) assert abs(result - 0) < 1e-8 assert abs(alias - 0) < 1e-8 - result = sbs.rate([curve], leg=2) - alias = sbs.rate([curve], leg=2) + result = sbs.rate(curves=[curve] * 3, metric="leg2_float_spread") + alias = sbs.rate(curves=[curve] * 3, metric="leg2_float_spread") assert abs(result - 3.0) < 1e-8 assert abs(alias - 3.0) < 1e-8 def test_sbs_cashflows(self, curve) -> None: sbs = SBS(dt(2022, 1, 1), "9M", "Q", float_spread=3.0) - result = sbs.cashflows(curve) + result = sbs.cashflows(curves=[curve] * 3) expected = DataFrame( { "Type": ["FloatPeriod", "FloatPeriod"], @@ -1597,15 +1616,15 @@ def test_sbs_cashflows(self, curve) -> None: def test_sbs_fixed_rate_raises(self, curve) -> None: sbs = SBS(dt(2022, 1, 1), "9M", "Q", float_spread=3.0) - with pytest.raises(AttributeError, match="Cannot set `fixed_rate`"): + with pytest.raises(AttributeError, match="property 'fixed_rate' of 'SBS' object has no se"): sbs.fixed_rate = 1.0 - with pytest.raises(AttributeError, match="Cannot set `leg2_fixed_rate`"): + with pytest.raises(AttributeError, match="property 'leg2_fixed_rate' of 'SBS' object has"): sbs.leg2_fixed_rate = 1.0 def test_fixings_table(self, curve): - inst = SBS(dt(2022, 1, 15), "6m", spec="usd_irs", curves=curve) - result = inst.fixings_table() + inst = SBS(dt(2022, 1, 15), "6m", spec="usd_irs", curves=[curve] * 3) + result = inst.local_analytic_rate_fixings() assert isinstance(result, DataFrame) def test_fixings_table_3s1s(self, curve, curve2): @@ -1620,9 +1639,9 @@ def test_fixings_table_3s1s(self, curve, curve2): leg2_frequency="m", curves=[curve, curve, curve2, curve], ) - result = inst.fixings_table() + result = inst.local_analytic_rate_fixings() assert isinstance(result, DataFrame) - assert len(result.columns) == 8 + assert len(result.columns) == 2 assert len(result.index) == 8 @@ -3343,7 +3362,25 @@ def test_standard_model_test_grid(self, cash, tenor, quote, isda_credit_curves_4 ) result = cds.npv() assert abs(result - cash) < 875 - print(abs(result - cash)) + + def test_cds_attributes(self): + cds = CDS( + dt(2022, 1, 1), "6M", "Q", payment_lag=0, currency="eur", notional=1e9, fixed_rate=2.0 + ) + assert cds.fixed_rate == 2.0 + cds.fixed_rate = 1.0 + assert cds.fixed_rate == 1.0 + + def test_cds_parse_curves(self, curve, curve2): + cds = CDS( + dt(2022, 1, 1), "6M", "Q", payment_lag=0, currency="eur", notional=1e9, fixed_rate=2.0 + ) + r1 = cds.npv(curves={"rate_curve": curve, "disc_curve": curve2}) + r2 = cds.npv(curves=[curve, curve2]) + assert r1 == r2 + + with pytest.raises(ValueError, match="CDS requires 2"): + cds.npv(curves=curve) class TestXCS: @@ -4118,7 +4155,7 @@ def test_iirs(self, curve) -> None: ob.spread() def test_sbs(self, curve) -> None: - ob = SBS(dt(2022, 1, 28), "6m", "Q", curves=curve) + ob = SBS(dt(2022, 1, 28), "6m", "Q", curves=[curve] * 3) ob.rate() ob.npv() ob.cashflows() @@ -4300,25 +4337,25 @@ def test_fixings_table(self, curve, curve2): irs2 = IRS(dt(2022, 1, 23), "6m", spec="eur_irs6", curves=curve2, notional=1e6) irs3 = IRS(dt(2022, 1, 17), "6m", spec="eur_irs3", curves=curve, notional=-2e6) pf = Portfolio([irs1, irs2, irs3]) - result = pf.fixings_table() + result = pf.local_analytic_rate_fixings() - # irs1 and irs3 are summed over curve c1 notional - assert abs(result["c1", "notional"][dt(2022, 1, 15)] - 1021994.16) < 1e-2 + # # irs1 and irs3 are summed over curve c1 notional + # assert abs(result["c1", "notional"][dt(2022, 1, 15)] - 1021994.16) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c1", "risk"][dt(2022, 1, 15)] - 25.249) < 1e-2 + assert abs(result["c1", "eur", "eur", "3M"][dt(2022, 1, 13)] - 25.249) < 1e-2 # c1 has no exposure to 22nd Jan - assert isna(result["c1", "risk"][dt(2022, 1, 22)]) - # c1 dcf is not summed - assert abs(result["c1", "dcf"][dt(2022, 1, 15)] - 0.25) < 1e-3 + assert isna(result["c1", "eur", "eur", "3M"][dt(2022, 1, 20)]) + # # c1 dcf is not summed + # assert abs(result["c1", "dcf"][dt(2022, 1, 15)] - 0.25) < 1e-3 - # irs2 is included - assert abs(result["c2", "notional"][dt(2022, 1, 22)] - 1005297.17) < 1e-2 + # # irs2 is included + # assert abs(result["c2", "notional"][dt(2022, 1, 22)] - 1005297.17) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c2", "risk"][dt(2022, 1, 22)] - 48.773) < 1e-3 + assert abs(result["c2", "eur", "eur", "6M"][dt(2022, 1, 20)] - 48.773) < 1e-3 # c2 has no exposure to 15 Jan - assert isna(result["c2", "risk"][dt(2022, 1, 15)]) - # c2 has DCF - assert abs(result["c2", "dcf"][dt(2022, 1, 22)] - 0.50277) < 1e-3 + assert isna(result["c2", "eur", "eur", "6M"][dt(2022, 1, 13)]) + # # c2 has DCF + # assert abs(result["c2", "dcf"][dt(2022, 1, 22)] - 0.50277) < 1e-3 def test_fixings_table_null_inst(self, curve): irs = IRS(dt(2022, 1, 15), "6m", spec="eur_irs3", curves=curve) @@ -4405,25 +4442,17 @@ def test_fixings_table(self, curve, curve2): irs2 = IRS(dt(2022, 1, 23), "6m", spec="eur_irs6", curves=curve2, notional=1e6) irs3 = IRS(dt(2022, 1, 17), "6m", spec="eur_irs3", curves=curve, notional=-2e6) fly = Fly(irs1, irs2, irs3) - result = fly.local_rate_fixings() + result = fly.local_analytic_rate_fixings() - # irs1 and irs3 are summed over curve c1 notional - assert abs(result["c1", "notional"][dt(2022, 1, 15)] - 1021994.16) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c1", "risk"][dt(2022, 1, 15)] - 25.249) < 1e-2 + assert abs(result[("c1", "eur", "eur", "3M")][dt(2022, 1, 13)] - 25.249) < 1e-2 # c1 has no exposure to 22nd Jan - assert isna(result["c1", "risk"][dt(2022, 1, 22)]) - # c1 dcf is not summed - assert abs(result["c1", "dcf"][dt(2022, 1, 15)] - 0.25) < 1e-3 + assert isna(result[("c1", "eur", "eur", "3M")][dt(2022, 1, 20)]) - # irs2 is included - assert abs(result["c2", "notional"][dt(2022, 1, 22)] - 1005297.17) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c2", "risk"][dt(2022, 1, 22)] - 48.773) < 1e-3 + assert abs(result[("c2", "eur", "eur", "6M")][dt(2022, 1, 20)] - 48.773) < 1e-3 # c2 has no exposure to 15 Jan - assert isna(result["c2", "risk"][dt(2022, 1, 15)]) - # c2 has DCF - assert abs(result["c2", "dcf"][dt(2022, 1, 22)] - 0.50277) < 1e-3 + assert isna(result[("c2", "eur", "eur", "6M")][dt(2022, 1, 13)]) def test_fixings_table_null_inst(self, curve): irs = IRS(dt(2022, 1, 15), "6m", spec="eur_irs3", curves=curve) @@ -4486,25 +4515,17 @@ def test_fixings_table(self, curve, curve2): irs2 = IRS(dt(2022, 1, 23), "6m", spec="eur_irs6", curves=curve2, notional=1e6) irs3 = IRS(dt(2022, 1, 17), "6m", spec="eur_irs3", curves=curve, notional=-2e6) spd = Spread(irs1, Spread(irs2, irs3)) - result = spd.fixings_table() + result = spd.local_analytic_rate_fixings() - # irs1 and irs3 are summed over curve c1 notional - assert abs(result["c1", "notional"][dt(2022, 1, 15)] - 1021994.16) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c1", "risk"][dt(2022, 1, 15)] - 25.249) < 1e-2 + assert abs(result[("c1", "eur", "eur", "3M")][dt(2022, 1, 13)] - 25.249) < 1e-2 # c1 has no exposure to 22nd Jan - assert isna(result["c1", "risk"][dt(2022, 1, 22)]) - # c1 dcf is not summed - assert abs(result["c1", "dcf"][dt(2022, 1, 15)] - 0.25) < 1e-3 + assert isna(result[("c1", "eur", "eur", "3M")][dt(2022, 1, 20)]) - # irs2 is included - assert abs(result["c2", "notional"][dt(2022, 1, 22)] - 1005297.17) < 1e-2 # irs1 and irs3 are summed over curve c1 risk - assert abs(result["c2", "risk"][dt(2022, 1, 22)] - 48.773) < 1e-3 + assert abs(result[("c2", "eur", "eur", "6M")][dt(2022, 1, 20)] - 48.773) < 1e-3 # c2 has no exposure to 15 Jan - assert isna(result["c2", "risk"][dt(2022, 1, 15)]) - # c2 has DCF - assert abs(result["c2", "dcf"][dt(2022, 1, 22)] - 0.50277) < 1e-3 + assert isna(result["c2", "eur", "eur", "6M"][dt(2022, 1, 13)]) def test_fixings_table_null_inst(self, curve): irs = IRS(dt(2022, 1, 15), "6m", spec="eur_irs3", curves=curve) @@ -4780,7 +4801,7 @@ def test_xcs(self) -> None: "Q", leg2_frequency="S", currency="eur", - curves=["eureur", "eurusd"], + curves=["eureur", "eurusd", "eureur"], ), DataFrame( [-0.51899, -6260.7208, 6299.28759], diff --git a/rust/scheduling/calendars/named/fed_script.py b/rust/scheduling/calendars/named/fed_script.py index 8fc65f56..753c57a6 100644 --- a/rust/scheduling/calendars/named/fed_script.py +++ b/rust/scheduling/calendars/named/fed_script.py @@ -5,7 +5,6 @@ from pandas.tseries.holiday import ( AbstractHolidayCalendar, Holiday, - nearest_workday, sunday_to_monday, ) from pandas.tseries.offsets import CustomBusinessDay, DateOffset