diff --git a/python/rateslib/instruments/components/__init__.py b/python/rateslib/instruments/components/__init__.py index 0aa0ec4e..04905cb1 100644 --- a/python/rateslib/instruments/components/__init__.py +++ b/python/rateslib/instruments/components/__init__.py @@ -4,15 +4,17 @@ from rateslib.instruments.components.fx_vol_value import FXVolValue from rateslib.instruments.components.iirs import IIRS from rateslib.instruments.components.irs import IRS -from rateslib.instruments.components.zcs import ZCS from rateslib.instruments.components.portfolio import Portfolio from rateslib.instruments.components.sbs import SBS from rateslib.instruments.components.spread import Spread from rateslib.instruments.components.value import Value +from rateslib.instruments.components.zcis import ZCIS +from rateslib.instruments.components.zcs import ZCS __all__ = [ "IRS", "ZCS", + "ZCIS", "IIRS", "CDS", "SBS", diff --git a/python/rateslib/instruments/components/irs.py b/python/rateslib/instruments/components/irs.py index 8717d5f8..fdaf2520 100644 --- a/python/rateslib/instruments/components/irs.py +++ b/python/rateslib/instruments/components/irs.py @@ -22,6 +22,7 @@ DualTypes, DualTypes_, FixingsRates_, + FloatRateSeries, Frequency, FXForwards_, FXVolOption_, @@ -34,7 +35,6 @@ float_, int_, str_, - FloatRateSeries, ) diff --git a/python/rateslib/instruments/components/sbs.py b/python/rateslib/instruments/components/sbs.py index b9040655..0d7ef9c6 100644 --- a/python/rateslib/instruments/components/sbs.py +++ b/python/rateslib/instruments/components/sbs.py @@ -21,6 +21,7 @@ DataFrame, DualTypes, DualTypes_, + FloatRateSeries, Frequency, FXForwards_, FXVolOption_, @@ -33,7 +34,6 @@ float_, int_, str_, - FloatRateSeries, ) diff --git a/python/rateslib/instruments/components/zcis.py b/python/rateslib/instruments/components/zcis.py new file mode 100644 index 00000000..323a4236 --- /dev/null +++ b/python/rateslib/instruments/components/zcis.py @@ -0,0 +1,527 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rateslib import defaults +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.pricing import ( + _Curves, + _get_maybe_curve_maybe_from_solver, +) +from rateslib.legs.components import ZeroFixedLeg, ZeroIndexLeg + +if TYPE_CHECKING: + from rateslib.typing import ( # pragma: no cover + CalInput, + CurveOption_, + Curves_, + DataFrame, + DualTypes, + DualTypes_, + Frequency, + FXForwards_, + FXVolOption_, + RollDay, + Solver_, + _BaseLeg, + bool_, + datetime, + datetime_, + float_, + int_, + str_, + IndexMethod, + Series, + ) + + +class ZCIS(_BaseInstrument): + """ + Create a *zero coupon swap (ZCS)* composing a :class:`~rateslib.legs.components.ZeroFixedLeg` + and a :class:`~rateslib.legs.components.ZeroIndexLeg`. + + .. 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``. + stub : StubInference, str in {"ShortFront", "LongFront", "ShortBack", "LongBack"}, :green:`optional` + The stub type used if stub inference is required. If given as string will derive a + :class:`~rateslib.scheduling.StubInference`. + front_stub : datetime, :green:`optional` + The unadjusted date for the start stub period. If given as adjusted, unadjusted + alternatives may be inferred. + back_stub : datetime, :green:`optional` + The unadjusted date for the back stub period. If given as adjusted, unadjusted + alternatives may be inferred. + See notes for combining ``stub``, ``front_stub`` and ``back_stub`` + and any automatic stub inference. + 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. + payment_lag_exchange: Adjuster, int, :green:`optional` + The :class:`~rateslib.scheduling.Adjuster` to use to map adjusted schedule dates into + additional 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`. + leg2_effective : datetime, :green:`optional (inherited from leg1)` + leg2_termination : datetime, str, :green:`optional (inherited from leg1)` + leg2_frequency : Frequency, str, :green:`optional (inherited from leg1)` + leg2_stub : StubInference, str, :green:`optional (inherited from leg1)` + leg2_front_stub : datetime, :green:`optional (inherited from leg1)` + leg2_back_stub : datetime, :green:`optional (inherited from leg1)` + leg2_roll : RollDay, int, str, :green:`optional (inherited from leg1)` + leg2_eom : bool, :green:`optional (inherited from leg1)` + leg2_modifier : Adjuster, str, :green:`optional (inherited from leg1)` + leg2_calendar : calendar, str, :green:`optional (inherited from leg1)` + leg2_payment_lag: Adjuster, int, :green:`optional (inherited from leg1)` + leg2_payment_lag_exchange: Adjuster, int, :green:`optional (inherited from leg1)` + leg2_ex_div: Adjuster, int, :green:`optional (inherited from leg1)` + leg2_convention: str, :green:`optional (inherited from leg1)` + + .. note:: + + The following define generalised **settlement** parameters. + + currency : str, :green:`optional (set by 'defaults')` + The local settlement currency of the *Instrument* (3-digit code). + notional : float, Dual, Dual2, Variable, :green:`optional (set by 'defaults')` + The initial leg notional, defined in units of *reference currency*. + leg2_notional : float, Dual, Dual2, Variable, :green:`optional (negatively inherited from leg1)` + + .. note:: + + The following are **rate parameters**. + + fixed_rate : float or None + The fixed rate applied to the :class:`~rateslib.legs.FixedLeg`. If `None` + will be set to mid-market when curves are provided. + + .. note:: + + The following are **meta parameters**. + + curves : XXX + Pricing objects passed directly to the *Instrument's* methods' ``curves`` argument. + spec: str, :green:`optional` + A collective group of parameters. See + :ref:`default argument specifications `. + + Notes + ------ + The various different ``leg2_fixing_methods``, which describe how an + individual *FloatPeriod* calculates its *rate*, are + fully documented in the notes for the :class:`~rateslib.periods.FloatPeriod`. + These configurations provide the mechanics to differentiate between IBOR swaps, and + OISs with different mechanisms such as *payment delay*, *observation shift*, + *lockout*, and/or *averaging*. + Similarly some information is provided in that same link regarding + ``leg2_fixings``, but a cookbook article is also produced for + :ref:`working with fixings `. + + Examples + -------- + Construct a curve to price the example. + + """ + + _rate_scalar = 1.0 + + @property + def fixed_rate(self) -> DualTypes_: + """The fixed rate parameter of the composited + :class:`~rateslib.legs.components.FixedLeg`.""" + return self.leg1.fixed_rate + + @fixed_rate.setter + def fixed_rate(self, value: DualTypes_) -> None: + self.kwargs.leg1["fixed_rate"] = value + self.leg1.fixed_rate = value + + # @property + # def float_spread(self) -> NoReturn: + # """The float spread parameter of the composited + # :class:`~rateslib.legs.components.FloatLeg`.""" + # raise AttributeError(f"Attribute not available on {type(self).__name__}") + + # @property + # def leg2_fixed_rate(self) -> NoReturn: + # raise AttributeError(f"Attribute not available on {type(self).__name__}") + + # @property + # def leg2_float_spread(self) -> DualTypes_: + # """The float spread parameter of the composited + # :class:`~rateslib.legs.components.FloatLeg`.""" + # 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 + + @property + def leg1(self) -> ZeroFixedLeg: + """The :class:`~rateslib.legs.components.ZeroFixedLeg` of the *Instrument*.""" + return self._leg1 + + @property + def leg2(self) -> ZeroIndexLeg: + """The :class:`~rateslib.legs.components.ZeroFloatLeg` of the *Instrument*.""" + return self._leg2 + + @property + def legs(self) -> list[_BaseLeg]: + """A list of the *Legs* of the *Instrument*.""" + return self._legs + + def __init__( + self, + # scheduling + effective: datetime_ = NoInput(0), + termination: datetime | str_ = NoInput(0), + frequency: Frequency | str_ = 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), + convention: str_ = 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_ex_div: int_ = NoInput(1), + leg2_convention: str_ = NoInput(1), + # settlement parameters + currency: str_ = NoInput(0), + notional: float_ = NoInput(0), + amortization: float_ = NoInput(0), + leg2_notional: float_ = NoInput(-1), + leg2_amortization: float_ = NoInput(-1), + # rate parameters + fixed_rate: DualTypes_ = NoInput(0), + # indexing + leg2_index_base: DualTypes_ = NoInput(0), + leg2_index_lag: int_ = NoInput(0), + leg2_index_method: IndexMethod | str_ = NoInput(0), + leg2_index_fixings: Series[DualTypes] | str_ = NoInput(0), + # meta parameters + curves: Curves_ = NoInput(0), + spec: str_ = NoInput(0), + ) -> None: + user_args = dict( + # scheduling + effective=effective, + leg2_effective=leg2_effective, + termination=termination, + leg2_termination=leg2_termination, + frequency=frequency, + leg2_frequency=leg2_frequency, + stub=stub, + leg2_stub=leg2_stub, + front_stub=front_stub, + leg2_front_stub=leg2_front_stub, + back_stub=back_stub, + leg2_back_stub=leg2_back_stub, + roll=roll, + leg2_roll=leg2_roll, + eom=eom, + leg2_eom=leg2_eom, + modifier=modifier, + leg2_modifier=leg2_modifier, + calendar=calendar, + leg2_calendar=leg2_calendar, + payment_lag=payment_lag, + leg2_payment_lag=leg2_payment_lag, + payment_lag_exchange=payment_lag_exchange, + leg2_payment_lag_exchange=leg2_payment_lag_exchange, + ex_div=ex_div, + leg2_ex_div=leg2_ex_div, + convention=convention, + leg2_convention=leg2_convention, + # settlement + currency=currency, + notional=notional, + leg2_notional=leg2_notional, + # rate + fixed_rate=fixed_rate, + # indexing + leg2_index_base=leg2_index_base, + leg2_index_lag=leg2_index_lag, + leg2_index_method=leg2_index_method, + leg2_index_fixings=leg2_index_fixings, + # meta + curves=self._parse_curves(curves), + ) + 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, + leg2_index_lag=defaults.index_lag, + leg2_index_method=defaults.index_method, + ) + self._kwargs = _KWArgs( + spec=spec, + user_args={**user_args, **instrument_args}, + default_args=default_args, + meta_args=["curves"], + ) + + self._leg1 = ZeroFixedLeg(**_convert_to_schedule_kwargs(self.kwargs.leg1, 1)) + self._leg2 = ZeroIndexLeg(**_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_: + _curves = self._parse_curves(curves) + + leg2_npv: DualTypes = self.leg2.local_npv( + rate_curve=NoInput(0), + disc_curve=_get_maybe_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_disc_curve", solver + ), + index_curve=_get_maybe_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "leg2_index_curve", solver + ), + settlement=settlement, + forward=forward, + ) + return ( + self.leg1.spread( + target_npv=-leg2_npv, + rate_curve=NoInput(0), + disc_curve=_get_maybe_curve_maybe_from_solver( + self.kwargs.meta["curves"], _curves, "disc_curve", solver + ), + index_curve=NoInput(0), + settlement=settlement, + forward=forward, + ) + / 100 + ) + + 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), + ) -> DualTypes: + raise NotImplementedError("ZCIS has no concept of `spread` - use `rate` instead.") + + 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 isinstance(self.kwargs.leg1["fixed_rate"], 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.fixed_rate = _dual_float(mid_market_rate) + + def _parse_curves(self, curves: CurveOption_) -> _Curves: + """ + An ZCIS has two curve requirements: a leg2_index_curve and a disc_curve used by both legs. + + When given as 2 elements the first is treated as the rate curve and the 2nd as disc curve. + """ + if isinstance(curves, NoInput): + return _Curves() + if isinstance(curves, dict): + return _Curves( + index_curve=curves.get("index_curve", NoInput(0)), + disc_curve=curves.get("disc_curve", NoInput(0)), + leg2_index_curve=_drb( + curves.get("index_curve", NoInput(0)), + curves.get("leg2_index_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( + leg2_index_curve=curves[0], + disc_curve=curves[1], + leg2_disc_curve=curves[1], + ) + elif len(curves) == 1: + return _Curves( + leg2_index_curve=curves[0], + disc_curve=curves[0], + leg2_disc_curve=curves[0], + ) + elif len(curves) == 4: + return _Curves( + leg2_index_curve=curves[2], + disc_curve=curves[1], + leg2_disc_curve=curves[3], + ) + 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( + leg2_rate_curve=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/zcs.py b/python/rateslib/instruments/components/zcs.py index 8380ee7f..5ea8940a 100644 --- a/python/rateslib/instruments/components/zcs.py +++ b/python/rateslib/instruments/components/zcs.py @@ -22,6 +22,7 @@ DualTypes, DualTypes_, FixingsRates_, + FloatRateSeries, Frequency, FXForwards_, FXVolOption_, @@ -34,7 +35,6 @@ float_, int_, str_, - FloatRateSeries, ) diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 957a997c..286e7256 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -20,7 +20,7 @@ NDF, # SBS, XCS, - ZCIS, + # ZCIS, # ZCS, Bill, FixedRateBond, @@ -45,8 +45,9 @@ CDS, IIRS, IRS, - ZCS, SBS, + ZCIS, + ZCS, Fly, FXExchange, FXVolValue, @@ -1908,7 +1909,12 @@ def test_leg2_index_base(self, curve) -> None: ) prior = zcis.rate(curves=[curve, curve, i_curve, curve]) - zcis.leg2_index_base = 100.0 # index base is lower + zcis = ZCIS( + effective=dt(2022, 1, 1), + termination="9m", + frequency="Q", + leg2_index_base=100.0, + ) result = zcis.rate(curves=[curve, curve, i_curve, curve]) assert result > (prior + 100) @@ -1931,8 +1937,10 @@ def test_solver_failure_unspecified_index_base(self, curve) -> None: curves=[curve, curve, i_curve, curve], leg2_index_lag=3, ) - with pytest.raises(ValueError, match="Forecasting the `index_base`"): # noqa: SIM117 - with pytest.warns(UserWarning): + with pytest.raises(ZeroDivisionError): # noqa: SIM117 + with pytest.warns( + UserWarning, match="The date queried on the Curve for an `index_value` is prior" + ): zcis.rate() def test_fixing_in_the_past(self): @@ -1941,11 +1949,15 @@ def test_fixing_in_the_past(self): inflation = Curve( {dt(2025, 4, 1): 1.0, dt(2027, 5, 1): 0.98}, index_base=100.0, index_lag=0 ) - fixings = Series( - [97, 98, 99, 100.0], - index=[dt(2025, 1, 1), dt(2025, 2, 1), dt(2025, 3, 1), dt(2025, 4, 1)], + name = str(hash(os.urandom(8))) + fixings.add( + name, + Series( + [97, 98, 99, 100.0], + index=[dt(2025, 1, 1), dt(2025, 2, 1), dt(2025, 3, 1), dt(2025, 4, 1)], + ), ) - zcis = ZCIS(dt(2025, 5, 15), "1y", spec="eur_zcis", leg2_index_fixings=fixings) + zcis = ZCIS(dt(2025, 5, 15), "1y", spec="eur_zcis", leg2_index_fixings=name) result = zcis.rate(curves=[inflation, discount]) assert abs(result - 2.8742266148532813) < 1e-8