diff --git a/python/rateslib/__init__.py b/python/rateslib/__init__.py index 9363ce98d..5f7294aa7 100644 --- a/python/rateslib/__init__.py +++ b/python/rateslib/__init__.py @@ -60,6 +60,7 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] index_left, index_value, ) +from rateslib.data.fixings import FXFixing, IBORFixing, IBORStubFixing, IndexFixing, RFRFixing from rateslib.dual import ADOrder, Dual, Dual2, Variable, dual_exp, dual_log, dual_solve, gradient from rateslib.enums.generics import NoInput from rateslib.fx import FXForwards, FXRates @@ -201,12 +202,12 @@ def __exit__(self, *args) -> None: # type: ignore[no-untyped-def] "ProxyCurve", "index_left", "index_value", - # # analytic_fixings.py cannot load due to circular import - # "FXFixing", - # "IBORFixing", - # "IBORStubFixing", - # "IndexFixing", - # "RFRFixing", + # fixings.py + "FXFixing", + "IBORFixing", + "IBORStubFixing", + "IndexFixing", + "RFRFixing", # fx_volatility.py "FXDeltaVolSmile", "FXDeltaVolSurface", diff --git a/python/rateslib/data/fixings.py b/python/rateslib/data/fixings.py index ddb37d8bb..c67f40f07 100644 --- a/python/rateslib/data/fixings.py +++ b/python/rateslib/data/fixings.py @@ -48,6 +48,7 @@ Result, _BaseCurve_, datetime_, + int_, str_, ) @@ -85,17 +86,53 @@ def __init__( self._state = 0 self._date = date - def reset(self) -> None: + def reset(self, state: int_ = NoInput(0)) -> None: """ Sets the ``value`` attribute to :class:`rateslib.enums.generics.NoInput`, which allows it to be redetermined from a timeseries. + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib import fixings, dt, NoInput, FXFixing + from pandas import Series + + .. ipython:: python + + fx_fixing1 = FXFixing(date=dt(2021, 1, 1), pair="eurusd", identifier="A") + fx_fixing2 = FXFixing(date=dt(2021, 1, 1), pair="gbpusd", identifier="B") + fixings.add("A_eurusd", Series(index=[dt(2021, 1, 1)], data=[1.1]), state=100) + fixings.add("B_gbpusd", Series(index=[dt(2021, 1, 1)], data=[1.4]), state=200) + + # data is populated from the available Series + fx_fixing1.value + fx_fixing2.value + + # fixings are reset according to the data state + fx_fixing1.reset(state=100) + fx_fixing2.reset(state=100) + + # only the private data for fixing1 is removed because of its link to the data state + fx_fixing1._value + fx_fixing2._value + + .. role:: green + + Parameters + ---------- + state: int, :green:`optional` + If given only fixings whose state matches this value will be reset. If no state is + given then the value will be reset. + Returns ------- None """ - self._value = NoInput(0) - self._state = 0 + if isinstance(state, NoInput) or self._state == state: + self._value = NoInput(0) + self._state = 0 @property def value(self) -> DualTypes_: @@ -796,6 +833,13 @@ def value(self) -> DualTypes_: ) return self._value + def reset(self, state: int_ = NoInput(0)) -> None: + if not isinstance(self._fixing1, NoInput): + self._fixing1.reset(state=state) + if not isinstance(self._fixing2, NoInput): + self._fixing2.reset(state=state) + self._value = NoInput(0) + @cached_property def weights(self) -> tuple[float, float]: """Scalar multiplier to apply to each tenor fixing for the interpolation.""" @@ -1019,6 +1063,12 @@ def __init__( self._method_param = method_param self._populated = Series(index=[], data=[], dtype=float) # type: ignore[assignment] + def reset(self, state: int_ = NoInput(0)) -> None: + if isinstance(state, NoInput) or self._state == state: + self._populated = Series(index=[], data=[], dtype=float) # type: ignore[assignment] + self._value = NoInput(0) + self._state = 0 + @property def fixing_method(self) -> FloatFixingMethod: """The :class:`FloatFixingMethod` object used to combine multiple RFR fixings.""" diff --git a/python/rateslib/data/loader.py b/python/rateslib/data/loader.py index 02bd8085d..8a30df2f1 100644 --- a/python/rateslib/data/loader.py +++ b/python/rateslib/data/loader.py @@ -12,7 +12,15 @@ from rateslib.enums.generics import Err, NoInput, Ok, _drb if TYPE_CHECKING: - from rateslib.typing import Adjuster, CalTypes, DualTypes, FloatRateSeries, Result, datetime_ + from rateslib.typing import ( + Adjuster, + CalTypes, + DualTypes, + FloatRateSeries, + Result, + datetime_, + int_, + ) class _BaseFixingsLoader(metaclass=ABCMeta): @@ -50,7 +58,7 @@ def __getitem__(self, name: str) -> tuple[int, Series[DualTypes], tuple[datetime pass @abstractmethod - def add(self, name: str, series: Series[DualTypes]) -> None: # type: ignore[type-var] + def add(self, name: str, series: Series[DualTypes], state: int_ = NoInput(0)) -> None: # type: ignore[type-var] """ Add a timeseries to the data loader directly from Python. @@ -278,14 +286,18 @@ def __getitem__(self, name: str) -> tuple[int, Series[DualTypes], tuple[datetime self.loaded[name_] = data return data - def add(self, name: str, series: Series[DualTypes]) -> None: # type: ignore[type-var] + def add(self, name: str, series: Series[DualTypes], state: int_ = NoInput(0)) -> None: # type: ignore[type-var] if name in self.loaded: raise ValueError(f"Fixing data for the index '{name}' has already been loaded.") s = series.sort_index(ascending=True) s.index.name = "reference_date" s.name = "rate" name_ = name.upper() - self.loaded[name_] = (hash(os.urandom(8)), s, (s.index[0], s.index[-1])) + if isinstance(state, NoInput): + state_: int = hash(os.urandom(64)) + else: + state_ = state + self.loaded[name_] = (state_, s, (s.index[0], s.index[-1])) def pop(self, name: str) -> Series[DualTypes] | None: # type: ignore[type-var] name_ = name.upper() @@ -380,10 +392,44 @@ def loader(self) -> _BaseFixingsLoader: """ return self._loader - def add(self, name: str, series: Series[DualTypes]) -> None: # type: ignore[type-var] - return self.loader.add(name, series) + def add(self, name: str, series: Series[DualTypes], state: int_ = NoInput(0)) -> None: # type: ignore[type-var] + """ + Add a Series to the Fixings object directly from Python + + .. role:: red + + .. role:: green + + Parameters + ---------- + name: str, :red:`required` + The string identifier key for the timeseries. + series: Series, :red:`required` + The timeseries indexed by datetime. + state, int, :green:`optional` + The state id to be used upon insertion of the Series. + + Returns + ------- + None + """ + return self.loader.add(name, series, state) def pop(self, name: str) -> Series[DualTypes] | None: # type: ignore[type-var] + """ + Remove a Series from the Fixings object. + + .. role:: red + + Parameters + ---------- + name: str, :red:`required` + The string identifier key for the timeseries. + + Returns + ------- + Series, or None (if name not found) + """ return self.loader.pop(name) diff --git a/python/rateslib/default.py b/python/rateslib/default.py index 64d95163f..abd55bb91 100644 --- a/python/rateslib/default.py +++ b/python/rateslib/default.py @@ -295,7 +295,7 @@ class Defaults: def __new__(cls) -> Defaults: if cls._instance is None: - print("Creating the object") + # Singleton pattern creates only one instance: TODO (low) might not be thread safe cls._instance = super(Defaults, cls).__new__(cls) # noqa: UP008 for k, v in DEFAULTS.items(): diff --git a/python/rateslib/instruments/protocols/__init__.py b/python/rateslib/instruments/protocols/__init__.py index 4954a718d..d12bf054f 100644 --- a/python/rateslib/instruments/protocols/__init__.py +++ b/python/rateslib/instruments/protocols/__init__.py @@ -6,6 +6,7 @@ from rateslib.instruments.protocols.analytic_delta import _WithAnalyticDelta from rateslib.instruments.protocols.analytic_fixings import _WithAnalyticRateFixings from rateslib.instruments.protocols.cashflows import _WithCashflows +from rateslib.instruments.protocols.fixings import _WithFixings from rateslib.instruments.protocols.kwargs import _KWArgs from rateslib.instruments.protocols.npv import _WithNPV from rateslib.instruments.protocols.rate import _WithRate @@ -21,6 +22,7 @@ class _BaseInstrument( _WithNPV, _WithRate, _WithCashflows, + _WithFixings, _WithAnalyticDelta, _WithAnalyticRateFixings, metaclass=ABCMeta, @@ -33,6 +35,7 @@ class _BaseInstrument( "_WithNPV", "_WithRate", "_WithCashflows", + "_WithFixings", "_WithAnalyticDelta", "_WithAnalyticRateFixings", "_WithSensitivities", diff --git a/python/rateslib/instruments/protocols/fixings.py b/python/rateslib/instruments/protocols/fixings.py new file mode 100644 index 000000000..1f60bb941 --- /dev/null +++ b/python/rateslib/instruments/protocols/fixings.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from pandas import DataFrame, Series + +from rateslib.enums.generics import NoInput +from rateslib.periods.protocols.fixings import ( + _replace_fixings_with_ad_variables, + _reset_fixings_data, + _structure_sensitivity_data, +) + +if TYPE_CHECKING: + from rateslib.typing import ( + CurvesT_, + DualTypes, + FXForwards_, + Sequence, + Solver_, + VolT_, + datetime_, + int_, + str_, + ) + + +class _WithFixings(Protocol): + """ + Protocol for determining fixing sensitivity for a *Period* with AD. + + .. rubric:: Provided methods + + .. autosummary:: + + ~_WithFixings.reset_fixings + + """ + + def npv( + self, + *, + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + base: str_ = NoInput(0), + local: bool = False, + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DualTypes | dict[str, DualTypes]: ... + + def reset_fixings(self, state: int_ = NoInput(0)) -> None: + """ + Resets any fixings values of the *Instrument* derived using the given data state. + + .. role:: green + + Parameters + ---------- + state: int, :green:`optional` + The *state id* of the data series that set the fixing. Only fixings determined by this + data will be reset. If not given resets all fixings. + + Returns + ------- + None + """ + if hasattr(self, "legs"): + for leg in self.legs: + leg.reset_fixings(state) + elif hasattr(self, "instruments"): + for inst in self.instruments: + inst.reset_fixings(state) + + def local_fixings( + self, + identifiers: Sequence[tuple[str, Series]], + scalars: Sequence[float] | NoInput = NoInput(0), + curves: CurvesT_ = NoInput(0), + solver: Solver_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + vol: VolT_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + """ + Calculate the sensitivity to fixings of the *Instrument*, expressed in local + settlement currency. + + .. role:: red + + .. role:: green + + Parameters + ---------- + identifiers: Sequence of tuple[str, Series], :red:`required` + These are the series string identifiers and the data values that will be used in each + Series to determine the sensitivity against. + scalars: Sequence of floats, :green:`optional (each set as 1.0)` + A sequence of scalars to multiply the sensitivities by for each on of the + ``identifiers``. + curves: _Curves, :green:`optional` + Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + solver: Solver, :green:`optional` + A :class:`~rateslib.solver.Solver` object containing *Curve*, *Smile*, *Surface*, or + *Cube* mappings for pricing. + fx: FXForwards, :green:`optional` + The :class:`~rateslib.fx.FXForwards` object used for forecasting FX rates, if necessary. + vol: _Vol, :green:`optional` + Pricing objects. See **Pricing** on each *Instrument* for details of allowed inputs. + settlement: datetime, :green:`optional` + The assumed settlement date of the *PV* determination. Used only to evaluate + *ex-dividend* status. + forward: datetime, :green:`optional` + The future date to project the *PV* to using the ``disc_curve``. + + Returns + ------- + DataFrame + """ + original_data, index, state = _replace_fixings_with_ad_variables(identifiers) + # Extract sensitivity data + pv: dict[str, DualTypes] = self.npv( # type: ignore[assignment] + curves=curves, + solver=solver, + fx=fx, + vol=vol, + settlement=settlement, + forward=forward, + local=True, + ) + df = _structure_sensitivity_data(pv, index, identifiers, scalars) + _reset_fixings_data(self, original_data, state, identifiers) + return df diff --git a/python/rateslib/legs/protocols/__init__.py b/python/rateslib/legs/protocols/__init__.py index 9557b970c..38e0866b5 100644 --- a/python/rateslib/legs/protocols/__init__.py +++ b/python/rateslib/legs/protocols/__init__.py @@ -3,10 +3,12 @@ from rateslib.legs.protocols.analytic_delta import _WithAnalyticDelta from rateslib.legs.protocols.analytic_fixings import _WithAnalyticRateFixings from rateslib.legs.protocols.cashflows import _WithCashflows, _WithExDiv +from rateslib.legs.protocols.fixings import _WithFixings from rateslib.legs.protocols.npv import _WithNPV class _BaseLeg( + _WithFixings, # inherits _WIthNPV so first in MRO _WithNPV, _WithCashflows, _WithAnalyticDelta, @@ -21,6 +23,7 @@ class _BaseLeg( __all__ = [ "_WithNPV", "_WithCashflows", + "_WithFixings", "_WithAnalyticDelta", "_WithAnalyticRateFixings", "_WithExDiv", diff --git a/python/rateslib/legs/protocols/fixings.py b/python/rateslib/legs/protocols/fixings.py new file mode 100644 index 000000000..9baebbb49 --- /dev/null +++ b/python/rateslib/legs/protocols/fixings.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Protocol + +from pandas import DataFrame, Series + +from rateslib.enums.generics import NoInput +from rateslib.legs.protocols.npv import _WithNPV +from rateslib.periods.protocols.fixings import ( + _replace_fixings_with_ad_variables, + _reset_fixings_data, + _structure_sensitivity_data, +) + +if TYPE_CHECKING: + from rateslib.typing import ( + CurveOption_, + DualTypes, + FXForwards_, + Sequence, + _BaseCurve_, + _BasePeriod, + _FXVolOption_, + datetime_, + int_, + ) + + +class _WithFixings(_WithNPV, Protocol): + """ + Protocol for determining fixing sensitivity for a *Period* with AD. + + .. rubric:: Provided methods + + .. autosummary:: + + ~_WithFixings.reset_fixings + + """ + + @property + def periods(self) -> Sequence[_BasePeriod]: ... + + def reset_fixings(self, state: int_ = NoInput(0)) -> None: + """ + Resets any fixings values of the *Leg* derived using the given data state. + + .. role:: green + + Parameters + ---------- + state: int, :green:`optional` + The *state id* of the data series that set the fixing. Only fixings determined by this + data will be reset. If not given resets all fixings. + + Returns + ------- + None + """ + for period in self.periods: + period.reset_fixings(state) + + def local_fixings( + self, + identifiers: Sequence[tuple[str, Series]], + scalars: Sequence[float] | NoInput = NoInput(0), + rate_curve: CurveOption_ = NoInput(0), + index_curve: _BaseCurve_ = NoInput(0), + disc_curve: _BaseCurve_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: _FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + """ + Calculate the sensitivity to fixings of the *Instrument*, expressed in local + settlement currency. + + .. role:: red + + .. role:: green + + Parameters + ---------- + indentifiers: Sequence of tuple[str, Series], :red:`required` + These are the series string identifiers and the data values that will be used in each + Series to determine the sensitivity against. + scalars: Sequence of floats, :green:`optional (each set as 1.0)` + A sequence of scalars to multiply the sensitivities by for each on of the + ``identifiers``. + 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 (set as immediate date) + The assumed settlement date of the *PV* determination. Used only to evaluate + *ex-dividend* status. + forward: datetime, optional (set as ``settlement``) + The future date to project the *PV* to using the ``disc_curve``. + + Returns + ------- + DataFrame + """ + original_data, index, state = _replace_fixings_with_ad_variables(identifiers) + # Extract sensitivity data + pv: dict[str, DualTypes] = { + self.settlement_params.currency: self.local_npv( + rate_curve=rate_curve, + index_curve=index_curve, + disc_curve=disc_curve, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) + } + df = _structure_sensitivity_data(pv, index, identifiers, scalars) + _reset_fixings_data(self, original_data, state, identifiers) + return df diff --git a/python/rateslib/legs/protocols/npv.py b/python/rateslib/legs/protocols/npv.py index 3f16925e6..79aa81050 100644 --- a/python/rateslib/legs/protocols/npv.py +++ b/python/rateslib/legs/protocols/npv.py @@ -26,6 +26,19 @@ class _WithNPV(Protocol): """ Protocol to establish value of any *Leg* type. + .. rubric:: Required methods + + .. autosummary:: + + ~_WithNPV.spread + + .. rubric:: Provided methods + + .. autosummary:: + + ~_WithNPV.local_npv + ~_WithNPV.npv + """ @property diff --git a/python/rateslib/periods/cashflow.py b/python/rateslib/periods/cashflow.py index 2899ddc3b..2013ef8ab 100644 --- a/python/rateslib/periods/cashflow.py +++ b/python/rateslib/periods/cashflow.py @@ -204,60 +204,6 @@ def try_unindexed_reference_cashflow_analytic_rate_fixings( return Ok(DataFrame()) -# class NonDeliverableCashflow(Cashflow): -# """ -# Deprecated -# -# .. warning:: -# -# This object is deprecated. Use a :class:`~rateslib.periods.Cashflow` instead. -# -# """ -# -# def __init__(self, **kwargs: Any) -> None: -# super().__init__(**kwargs) -# if not self.is_non_deliverable: -# raise ValueError(err.VE_NEEDS_ND_CURRENCY_PARAMS.format(type(self).__name__)) -# if self.is_indexed: -# raise ValueError(err.VE_HAS_INDEX_PARAMS.format(type(self).__name__)) -# -# -# class IndexCashflow(Cashflow): -# """ -# Deprecated -# -# .. warning:: -# -# This object is deprecated. Use a :class:`~rateslib.periods.Cashflow` instead. -# -# """ -# -# def __init__(self, **kwargs: Any) -> None: -# super().__init__(**kwargs) -# if not self.is_indexed: -# raise ValueError(err.VE_NEEDS_INDEX_PARAMS.format(type(self).__name__)) -# if self.is_non_deliverable: -# raise ValueError(err.VE_HAS_ND_CURRENCY_PARAMS.format(type(self).__name__)) -# -# -# class NonDeliverableIndexCashflow(Cashflow): -# """ -# Deprecated -# -# .. warning:: -# -# This object is deprecated. Use a :class:`~rateslib.periods.Cashflow` instead. -# -# """ -# -# def __init__(self, **kwargs: Any) -> None: -# super().__init__(**kwargs) -# if not self.is_indexed: -# raise ValueError(err.VE_NEEDS_INDEX_PARAMS.format(type(self).__name__)) -# if not self.is_non_deliverable: -# raise ValueError(err.VE_NEEDS_ND_CURRENCY_PARAMS.format(type(self).__name__)) - - class MtmCashflow(_BasePeriodStatic): r""" A *Period* defined by a specific amount calculated from the difference between two diff --git a/python/rateslib/periods/protocols/__init__.py b/python/rateslib/periods/protocols/__init__.py index 1c9b4bb70..f7b9a5039 100644 --- a/python/rateslib/periods/protocols/__init__.py +++ b/python/rateslib/periods/protocols/__init__.py @@ -21,12 +21,16 @@ _WithCashflows, _WithCashflowsStatic, ) +from rateslib.periods.protocols.fixings import ( + _WithFixings, +) class _BasePeriod( _WithCashflows, _WithAnalyticDelta, _WithAnalyticRateFixings, + _WithFixings, metaclass=ABCMeta, ): """Abstract base class for *Period* types.""" @@ -51,6 +55,7 @@ class _BasePeriodStatic( "_BasePeriodStatic", "_WithNPV", "_WithCashflows", + "_WithFixings", "_WithAnalyticDelta", "_WithAnalyticRateFixings", "_WithAnalyticFXOptionGreeks", diff --git a/python/rateslib/periods/protocols/fixings.py b/python/rateslib/periods/protocols/fixings.py new file mode 100644 index 000000000..966eec22c --- /dev/null +++ b/python/rateslib/periods/protocols/fixings.py @@ -0,0 +1,300 @@ +from __future__ import annotations + +import os +from itertools import product +from typing import TYPE_CHECKING, Protocol + +from pandas import DataFrame, DatetimeIndex, MultiIndex, Series, isna + +from rateslib import fixings +from rateslib.dual import Variable, gradient +from rateslib.dual.utils import _dual_float +from rateslib.enums.generics import NoInput +from rateslib.periods.parameters import ( + _FloatRateParams, + _FXOptionParams, + _IndexParams, + _MtmParams, + _NonDeliverableParams, +) +from rateslib.periods.protocols.npv import _WithNPV + +if TYPE_CHECKING: + from rateslib.typing import ( # pragma: no cover + CurveOption_, + DualTypes, + FXForwards_, + Sequence, + _BaseCurve_, + _FXVolOption_, + datetime_, + int_, + ) + + +class _WithFixings(_WithNPV, Protocol): + """ + Protocol for determining fixing sensitivity for a *Period* with AD. + + .. rubric:: Required methods + + .. autosummary:: + + ~_WithFixings.reset_fixings + + .. rubric:: Provided methods + + .. autosummary:: + + ~_WithFixings.reset_fixings + + """ + + # def local_npv( + # self, + # *, + # rate_curve: CurveOption_ = NoInput(0), + # index_curve: _BaseCurve_ = NoInput(0), + # disc_curve: _BaseCurve_ = NoInput(0), + # fx: FXForwards_ = NoInput(0), + # fx_vol: _FXVolOption_ = NoInput(0), + # settlement: datetime_ = NoInput(0), + # forward: datetime_ = NoInput(0), + # ) -> DualTypes: ... + + # @property + # def settlement_param(self) -> _SettlementParams: ... + + def reset_fixings(self, state: int_ = NoInput(0)) -> None: + """ + Resets any fixings values of the *Period* derived using the given data state. + + .. rubric:: Examples + + .. ipython:: python + :suppress: + + from rateslib import fixings, dt, NoInput, FloatPeriod + from pandas import Series + + .. ipython:: python + + fp = FloatPeriod( + start=dt(2026, 1, 12), + end=dt(2026, 1, 16), + payment=dt(2026, 1, 16), + frequency="M", + fixing_method="rfr_payment_delay", + method_param=0, + rate_fixings="sofr" + ) + fixings.add( + name="sofr_1B", + series=Series( + index=[dt(2026, 1, 12), dt(2026, 1, 13), dt(2026, 1, 14), dt(2026, 1, 15)], + data=[3.1, 3.2, 3.3, 3.4] + ) + ) + # value is populated from given data + assert 3.245 < fp.rate_params.rate_fixing.value < 3.255 + fp.reset_fixings() + # private data related to fixing is removed and requires new data lookup + fp.rate_params.rate_fixing._value + fp.rate_params.rate_fixing._populated + + .. role:: green + + Parameters + ---------- + state: int, :green:`optional` + The *state id* of the data series that set the fixing. Only fixings determined by this + data will be reset. If not given resets all fixings. + """ + if isinstance(getattr(self, "index_params", None), _IndexParams): + self.index_params.index_base.reset(state) # type: ignore[attr-defined] + self.index_params.index_fixing.reset(state) # type: ignore[attr-defined] + if isinstance(getattr(self, "rate_params", None), _FloatRateParams): + self.rate_params.rate_fixing.reset(state) # type: ignore[attr-defined] + if isinstance(getattr(self, "mtm_params", None), _MtmParams): + self.mtm_params.fx_fixing_start.reset(state) # type: ignore[attr-defined] + self.mtm_params.fx_fixing_end.reset(state) # type: ignore[attr-defined] + if isinstance(getattr(self, "non_deliverable_params", None), _NonDeliverableParams): + self.non_deliverable_params.fx_fixing.reset(state) # type: ignore[attr-defined] + from rateslib.periods.float_period import ZeroFloatPeriod + + if isinstance(self, ZeroFloatPeriod): + for float_period in self.float_periods: + float_period.reset_fixings(state) + if isinstance(getattr(self, "fx_option_params", None), _FXOptionParams): + self.fx_option_params.option_fixing.reset(state) # type: ignore[attr-defined] + + def local_fixings( + self, + identifiers: Sequence[tuple[str, Series]], + scalars: Sequence[float] | NoInput = NoInput(0), + rate_curve: CurveOption_ = NoInput(0), + index_curve: _BaseCurve_ = NoInput(0), + disc_curve: _BaseCurve_ = NoInput(0), + fx: FXForwards_ = NoInput(0), + fx_vol: _FXVolOption_ = NoInput(0), + settlement: datetime_ = NoInput(0), + forward: datetime_ = NoInput(0), + ) -> DataFrame: + """ + Calculate the sensitivity to fixings of the *Instrument*, expressed in local + settlement currency. + + .. role:: red + + .. role:: green + + Parameters + ---------- + indentifiers: Sequence of tuple[str, Series], :red:`required` + These are the series string identifiers and the data values that will be used in each + Series to determine the sensitivity against. + scalars: Sequence of floats, :green:`optional (each set as 1.0)` + A sequence of scalars to multiply the sensitivities by for each on of the + ``identifiers``. + 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 (set as immediate date) + The assumed settlement date of the *PV* determination. Used only to evaluate + *ex-dividend* status. + forward: datetime, optional (set as ``settlement``) + The future date to project the *PV* to using the ``disc_curve``. + + Returns + ------- + DataFrame + """ + original_data, index, state = _replace_fixings_with_ad_variables(identifiers) + # Extract sensitivity data + pv: dict[str, DualTypes] = { + self.settlement_params.currency: self.local_npv( + rate_curve=rate_curve, + index_curve=index_curve, + disc_curve=disc_curve, + fx=fx, + fx_vol=fx_vol, + settlement=settlement, + forward=forward, + ) + } + df = _structure_sensitivity_data(pv, index, identifiers, scalars) + _reset_fixings_data(self, original_data, state, identifiers) + return df + + +def _replace_fixings_with_ad_variables( + identifiers: Sequence[tuple[str, Series]], +) -> tuple[dict[str, tuple[int, Series]], DatetimeIndex, int]: + """ + For a set of identifiers (which must already exist in the `fixings` object) extend those + with the given data as new fixings expressed as a Variable which will capture sensitivity. + + Parameters + ---------- + identifiers + + Returns + ------- + tuple: the original data that will be reset later, the DatetimeIndex of relevant dates + and the state id used for the added series + """ + + # for each identifier, replace the existing fixing Series with a new one with AD Variables. + state = hash(os.urandom(64)) + original_data: dict[str, tuple[int, Series]] = {} + index = DatetimeIndex(data=[]) + for identifier in identifiers: + original_data[identifier[0]] = (fixings[identifier[0]][0], fixings[identifier[0]][1]) + ad_series = Series( + index=identifier[1].index, + data=[ # type: ignore[arg-type] + Variable(_dual_float(v), [f"{identifier[0]}_{d.strftime('%Y%m%d')}"]) # type: ignore[attr-defined] + for d, v in identifier[1].items() + ], + ) + index = index.union(other=ad_series.index, sort=True) # type: ignore[arg-type] + fixings.pop(name=identifier[0]) + fixings.add( + name=identifier[0], + series=ad_series.combine(original_data[identifier[0]][1], _s2_before_s1), + state=state, + ) + + return original_data, index, state + + +def _structure_sensitivity_data( + pv: dict[str, DualTypes], + index: DatetimeIndex, + identifiers: Sequence[tuple[str, Series]], + scalars: Sequence[float] | NoInput, +) -> DataFrame: + if isinstance(scalars, NoInput): + scalars_: Sequence[float] = [1.0] * len(identifiers) + elif len(scalars) != len(identifiers): + raise ValueError("If given, ``scalars`` must be same length as ``identifiers``.") + else: + scalars_ = scalars + + date_str = [_.strftime("%Y%m%d") for _ in index] + + # Construct DataFrame + df = DataFrame( + columns=MultiIndex.from_tuples( + product(pv.keys(), [i[0] for i in identifiers]), names=["local_ccy", "identifier"] + ), + # index=date_list, + index=index, + data=[], + dtype=float, + ) + for ccy, v in pv.items(): + for j, identifier in enumerate(identifiers): + df[(ccy, identifier[0])] = ( + gradient(v, vars=[identifier[0] + "_" + date for date in date_str]) * scalars_[j] + ) + + return df + + +class _SupportsResetFixings(Protocol): + def reset_fixings(self, state: int_ = NoInput(0)) -> None: ... + + +def _reset_fixings_data( + obj: _SupportsResetFixings, + original_data: dict[str, tuple[int, Series]], + state: int, + identifiers: Sequence[tuple[str, Series]], +) -> None: + # reset all data to original values. + obj.reset_fixings(state=state) + for identifier in identifiers: + fixings.pop(name=identifier[0]) + fixings.add( + name=identifier[0], + series=original_data[identifier[0]][1], + state=original_data[identifier[0]][0], + ) + + +def _s2_before_s1(v1: DualTypes, v2: DualTypes | None) -> DualTypes: + if v2 is None or isna(v2): # type: ignore[arg-type] + return v1 + else: + return v2 diff --git a/python/tests/instruments/test_instruments_legacy.py b/python/tests/instruments/test_instruments_legacy.py index 2eae420eb..e7f1d3b02 100644 --- a/python/tests/instruments/test_instruments_legacy.py +++ b/python/tests/instruments/test_instruments_legacy.py @@ -8347,3 +8347,66 @@ def test_forward_npv_argument_with_fx(curve, curve2, inst, curves): result = npv / curve[dt(2022, 3, 15)] - forward_npv assert abs(result) < 1e-7 + + +class TestFixings: + def test_local_fixings_rate_and_fx(self): + fixings.add("wmr_eurusd", Series(index=[dt(1999, 1, 1)], data=[100.0])) + fixings.add("rpi", Series(index=[dt(1999, 1, 1)], data=[100.0])) + fixings.add("ibor_1M", Series(index=[dt(1999, 1, 1)], data=[100.0])) + + curve = Curve({dt(2000, 1, 1): 1.0, dt(2000, 2, 15): 0.999, dt(2005, 1, 1): 0.9}) + curve2 = Curve({dt(2000, 1, 1): 1.0, dt(2005, 1, 1): 0.95}) + fxf = FXForwards( + fx_curves={"eurusd": curve2, "usdusd": curve, "eureur": curve2}, + fx_rates=FXRates({"eurusd": 1.10}, settlement=dt(2000, 1, 1)), + ) + irs = IRS( + dt(2000, 1, 1), + dt(2000, 4, 1), + "M", + currency="usd", + pair="eurusd", + fx_fixings="wmr", + leg2_fixing_method="ibor", + leg2_method_param=0, + leg2_rate_fixings="ibor", + payment_lag=0, + curves=[curve], + fixed_rate=2.07, + ) + + # cf = irs.cashflows(fx=fxf) + cft = irs.cashflows_table(fx=fxf) + + result = irs.local_fixings( + identifiers=[ + ( + "wmr_eurusd", + Series( + index=[dt(2000, 2, 1), dt(2000, 3, 1), dt(2000, 4, 1)], + data=[1.0998008124280523, 1.1002139078693074, 1.101254251708383], + ), + ), + ( + "ibor_1m", + Series( + index=[dt(2000, 1, 1), dt(2000, 2, 1), dt(2000, 3, 1)], + data=[0.8006761616124619, 1.4777702977501797, 2.110198054725164], + ), + ), + ], + scalars=(1.0, 0.01), + fx=fxf, + ) + + expected_rate_fixings = irs.local_analytic_rate_fixings(fx=fxf) + for i in range(3): + assert abs(result[("usd", "ibor_1m")].iloc[i] - expected_rate_fixings.iloc[i, 0]) < 1e-8 + expected_fx_fixings = [ + cft.iloc[0, 0] / 1.0998008124280523 * curve[dt(2000, 2, 1)], + cft.iloc[1, 0] / 1.1002139078693074 * curve[dt(2000, 3, 1)], + cft.iloc[2, 0] / 1.101254251708383 * curve[dt(2000, 4, 1)], + ] + for i in range(3): + assert abs(result[("usd", "wmr_eurusd")].iloc[i + 1] - expected_fx_fixings[i]) < 1e-6 diff --git a/python/tests/legs/test_leg_fixings.py b/python/tests/legs/test_leg_fixings.py new file mode 100644 index 000000000..8895de331 --- /dev/null +++ b/python/tests/legs/test_leg_fixings.py @@ -0,0 +1,252 @@ +from datetime import datetime as dt + +import pytest +from pandas import Series +from rateslib import fixings +from rateslib.curves import Curve +from rateslib.enums.generics import NoInput +from rateslib.legs import FixedLeg, FloatLeg +from rateslib.scheduling import Schedule + + +class TestFixedLeg: + def test_populated_resets(self): + fixings.add( + name="index", + series=Series( + index=[dt(2000, 1, 1), dt(2000, 7, 1), dt(2001, 1, 1)], data=[1.0, 1.1, 1.2] + ), + state=100, + ) + fixings.add(name="fx_eurusd", series=Series(index=[dt(2000, 1, 1)], data=[2.0]), state=100) + + fl = FixedLeg( + schedule=Schedule(dt(2000, 1, 1), "1y", "S"), + index_fixings="index", + index_lag=0, + index_method="monthly", + pair="eurusd", + fx_fixings="fx", + ) + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + + fixings.pop("index") + fixings.pop("fx_eurusd") + fl.reset_fixings(100) + assert fl.periods[0].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[1].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_base._value == NoInput(0) + assert fl.periods[1].index_params.index_fixing._value == NoInput(0) + assert fl.periods[1].index_params.index_base._value == NoInput(0) + + def test_populated_at_init_no_reset(self): + fixings.add( + name="index", + series=Series( + index=[dt(2000, 1, 1), dt(2000, 7, 1), dt(2001, 1, 1)], data=[1.0, 1.1, 1.2] + ), + state=100, + ) + fixings.add(name="fx_eurusd", series=Series(index=[dt(2000, 1, 1)], data=[2.0]), state=100) + + fl = FixedLeg( + schedule=Schedule(dt(2000, 1, 1), "1y", "S"), + index_fixings="index", + index_lag=0, + index_method="monthly", + pair="eurusd", + fx_fixings="fx", + ) + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + + fixings.pop("index") + fixings.pop("fx_eurusd") + fl.reset_fixings(666) + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + + def test_populated_resets_notional_exchanges(self): + fixings.add( + name="index", + series=Series( + index=[dt(2000, 1, 1), dt(2000, 7, 1), dt(2001, 1, 1)], data=[1.0, 1.1, 1.2] + ), + state=100, + ) + fixings.add(name="fx_eurusd", series=Series(index=[dt(2000, 1, 1)], data=[2.0]), state=100) + + fl = FixedLeg( + schedule=Schedule(dt(2000, 1, 1), "1y", "S"), + index_fixings="index", + index_lag=0, + index_method="monthly", + pair="eurusd", + fx_fixings="fx", + initial_exchange=True, + ) + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[-1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.0 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[-1].index_params.index_fixing.value == 1.2 + assert fl.periods[-1].index_params.index_base.value == 1.0 + + fixings.pop("index") + fixings.pop("fx_eurusd") + fl.reset_fixings(100) + assert fl.periods[0].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[-1].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_base._value == NoInput(0) + assert fl.periods[-1].index_params.index_fixing._value == NoInput(0) + assert fl.periods[-1].index_params.index_base._value == NoInput(0) + + +class TestFloatLeg: + def test_populated_resets_ibor(self): + fixings.add( + name="index", + series=Series( + index=[dt(2000, 1, 1), dt(2000, 3, 1), dt(2000, 6, 1)], data=[1.0, 1.1, 1.2] + ), + state=100, + ) + fixings.add(name="fx_eurusd", series=Series(index=[dt(2000, 1, 1)], data=[2.0]), state=100) + fixings.add( + name="ibor_1M", + series=Series(index=[dt(2000, 1, 1), dt(2000, 3, 1)], data=[1.0, 2.0]), + state=100, + ) + fixings.add( + name="ibor_3M", + series=Series(index=[dt(2000, 1, 1), dt(2000, 3, 1)], data=[1.1, 2.1]), + state=100, + ) + + fl = FloatLeg( + schedule=Schedule(dt(2000, 1, 1), "5m", "Q"), + index_fixings="index", + index_lag=0, + index_method="monthly", + pair="eurusd", + fx_fixings="fx", + fixing_method="ibor", + method_param=0, + rate_fixings="ibor", + ) + assert fl.periods[0].rate_params.rate_fixing.value == 1.0483333333333333 + assert fl.periods[1].rate_params.rate_fixing.value == 2.1 + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + + fixings.pop("index") + fixings.pop("fx_eurusd") + fixings.pop("ibor_1M") + fixings.pop("ibor_3M") + fixings.add(name="ibor_1M", series=Series(index=[dt(1999, 1, 1)], data=[99.0]), state=100) + fixings.add( + name="ibor_3M", + series=Series( + index=[ + dt(1999, 1, 1), + ], + data=[99.0], + ), + state=100, + ) + + fl.reset_fixings(100) + assert fl.periods[0].rate_params.rate_fixing.value == NoInput(0) + assert fl.periods[1].rate_params.rate_fixing.value == NoInput(0) + assert fl.periods[0].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[1].non_deliverable_params.fx_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_fixing._value == NoInput(0) + assert fl.periods[0].index_params.index_base._value == NoInput(0) + assert fl.periods[1].index_params.index_fixing._value == NoInput(0) + assert fl.periods[1].index_params.index_base._value == NoInput(0) + + def test_populated_at_init_no_reset(self): + fixings.add( + name="index", + series=Series( + index=[dt(2000, 1, 1), dt(2000, 3, 1), dt(2000, 6, 1)], data=[1.0, 1.1, 1.2] + ), + state=100, + ) + fixings.add(name="fx_eurusd", series=Series(index=[dt(2000, 1, 1)], data=[2.0]), state=100) + fixings.add( + name="ibor_1M", + series=Series(index=[dt(2000, 1, 1), dt(2000, 3, 1)], data=[1.0, 2.0]), + state=100, + ) + fixings.add( + name="ibor_3M", + series=Series(index=[dt(2000, 1, 1), dt(2000, 3, 1)], data=[1.1, 2.1]), + state=100, + ) + + fl = FloatLeg( + schedule=Schedule(dt(2000, 1, 1), "5m", "Q"), + index_fixings="index", + index_lag=0, + index_method="monthly", + pair="eurusd", + fx_fixings="fx", + fixing_method="ibor", + method_param=0, + rate_fixings="ibor", + ) + assert fl.periods[0].rate_params.rate_fixing.value == 1.0483333333333333 + assert fl.periods[1].rate_params.rate_fixing.value == 2.1 + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 + + fixings.pop("index") + fixings.pop("fx_eurusd") + fixings.pop("ibor_1M") + fixings.pop("ibor_3M") + fixings.add(name="ibor_1M", series=Series(index=[dt(1999, 1, 1)], data=[99.0]), state=100) + fixings.add( + name="ibor_3M", + series=Series( + index=[ + dt(1999, 1, 1), + ], + data=[99.0], + ), + state=100, + ) + fl.reset_fixings(666) + assert fl.periods[0].rate_params.rate_fixing.value == 1.0483333333333333 + assert fl.periods[1].rate_params.rate_fixing.value == 2.1 + assert fl.periods[0].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[1].non_deliverable_params.fx_fixing.value == 2.0 + assert fl.periods[0].index_params.index_fixing.value == 1.1 + assert fl.periods[0].index_params.index_base.value == 1.0 + assert fl.periods[1].index_params.index_fixing.value == 1.2 + assert fl.periods[1].index_params.index_base.value == 1.0 diff --git a/python/tests/periods/test_fixings_exposure.py b/python/tests/periods/test_fixings_exposure.py index 07f3c88ec..383cb1c00 100644 --- a/python/tests/periods/test_fixings_exposure.py +++ b/python/tests/periods/test_fixings_exposure.py @@ -7,8 +7,11 @@ from rateslib import fixings from rateslib.curves import Curve from rateslib.enums import FloatFixingMethod, SpreadCompoundMethod +from rateslib.enums.generics import NoInput +from rateslib.fx import FXForwards, FXRates from rateslib.instruments import IRS -from rateslib.periods import FixedPeriod, FloatPeriod +from rateslib.periods import FixedPeriod, FloatPeriod, FXCallPeriod, MtmCashflow, ZeroFloatPeriod +from rateslib.scheduling import Schedule from rateslib.solver import Solver @@ -328,6 +331,31 @@ def test_rfr_curve_book(self, method, param, expected, curve): for i in range(10): assert abs(expected[i] - result.iloc[i, 0] * 1000) < 5e-1 + def test_doc_reset(self): + fp = FloatPeriod( + start=dt(2026, 1, 12), + end=dt(2026, 1, 16), + payment=dt(2026, 1, 16), + frequency="M", + fixing_method="rfr_payment_delay", + method_param=0, + rate_fixings="sofr", + ) + fixings.add( + name="sofr_1B", + series=pd.Series( + index=[dt(2026, 1, 12), dt(2026, 1, 13), dt(2026, 1, 14), dt(2026, 1, 15)], + data=[3.1, 3.2, 3.3, 3.4], + ), + ) + # value is populated from given data + assert 3.245 < fp.rate_params.rate_fixing.value < 3.255 + fp.reset_fixings() + # private data related to fixing is removed and requires new data lookup + assert fp.rate_params.rate_fixing._value == NoInput(0) + assert fp.rate_params.rate_fixing._populated.empty + fixings.pop("sofr_1B") + class TestFixedPeriod: def test_immediate_fixing_sensitivity(self, curve): @@ -344,3 +372,133 @@ def test_immediate_fixing_sensitivity(self, curve): result = p.try_immediate_analytic_rate_fixings(disc_curve=curve).unwrap() assert isinstance(result, pd.DataFrame) assert result.empty + + +class TestMtmCashflow: + def test_local_fixings(self): + curve1 = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 0.98}) + curve2 = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 0.98}) + fxf = FXForwards( + fx_rates=FXRates({"eurusd": 1.10}, dt(2000, 1, 1)), + fx_curves={"eureur": curve2, "eurusd": curve2, "usdusd": curve1}, + ) + fixings.add("wmr12_eurusd", pd.Series(index=[dt(1999, 1, 1)], data=[1.15])) + mc = MtmCashflow( + currency="usd", + notional=2e6, + pair="eurusd", + payment=dt(2000, 2, 15), + start=dt(2000, 1, 10), + end=dt(2000, 2, 15), + fx_fixings_start="wmr12", + fx_fixings_end="wmr12", + ) + result = mc.local_fixings( + disc_curve=curve1, + fx=fxf, + identifiers=[ + ( + "wmr12_eurusd", + pd.Series( + index=[dt(2000, 1, 10), dt(2000, 2, 15)], + data=[ + fxf.rate("eurusd", dt(2000, 1, 10)), + fxf.rate("eurusd", dt(2000, 2, 15)), + ], + ), + ) + ], + ) + assert abs(result.iloc[0, 0] - 2e6 * 1.0 * curve1[dt(2000, 2, 15)]) < 1e-6 + assert abs(result.iloc[1, 0] + 2e6 * 1.0 * curve1[dt(2000, 2, 15)]) < 1e-6 + fixings.pop("wmr12_eurusd") + + +class TestFXCallPeriod: + @pytest.mark.parametrize(("fixing", "itm"), [(1.15, True), (1.05, False)]) + def test_itm_otm_fixing(self, fixing, itm): + curve1 = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 0.98}) + # curve2 = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 0.98}) + # fxf = FXForwards( + # fx_rates=FXRates({"eurusd": 1.10}, dt(2000, 1, 1)), + # fx_curves={"eureur": curve2, "eurusd": curve2, "usdusd": curve1}, + # ) + fixings.add("wmr13_eurusd", pd.Series(index=[dt(1999, 1, 1)], data=[1.15])) + fxo = FXCallPeriod( + delivery=dt(2000, 3, 1), + pair="eurusd", + expiry=dt(2000, 2, 28), + strike=1.10, + delta_type="forward", + notional=1e6, + option_fixings="wmr13", + ) + result = fxo.local_fixings( + identifiers=[ + ("wmr13_eurusd", pd.Series(index=[dt(2000, 3, 1)], data=[fixing])), + ], + disc_curve=curve1, + ) + assert abs(result.iloc[0, 0] - itm * 1e6 * 1.0 * curve1[dt(2000, 3, 1)]) < 1e-6 + fixings.pop("wmr13_eurusd") + + +class TestZeroFloatPeriod: + def test_multiple_sub_periods(self): + fixings.add("MY_RATE_INDEX_6M", pd.Series(index=[dt(1999, 1, 1)], data=[1.15])) + period = ZeroFloatPeriod( + schedule=Schedule(dt(2000, 1, 1), "2Y", "S"), + fixing_method="IBOR", + rate_fixings="MY_RATE_INDEX", + convention="Act360", + method_param=0, + notional=1e6, + ) + rc = Curve({dt(2000, 1, 1): 1.0, dt(2003, 1, 1): 0.95}) + from rateslib.legs import CustomLeg + + # cf = CustomLeg(periods=period.float_periods).cashflows(rate_curve=rc) + result = period.local_fixings( + identifiers=[ + ( + "MY_RATE_INDEX_6M", + pd.Series(index=[dt(2000, 1, 1), dt(2000, 7, 1)], data=[1.692, 1.692]), + ) + ], + scalars=[0.01], + rate_curve=rc, + ) + expected = period.local_analytic_rate_fixings(rate_curve=rc) + + assert abs(result.iloc[0, 0] - expected.iloc[0, 0]) < 1e-4 + assert abs(result.iloc[1, 0] - expected.iloc[1, 0]) < 1e-4 + + assert period.float_periods[0].rate_params.rate_fixing.value == NoInput(0) + fixings.pop("MY_RATE_INDEX_6M") + + +def test_local_fixings_raises_scalars(): + curve1 = Curve({dt(2000, 1, 1): 1.0, dt(2001, 1, 1): 0.98}) + fixings.add("wmr12_eurusd", pd.Series(index=[dt(1999, 1, 1)], data=[1.15])) + mc = MtmCashflow( + currency="usd", + notional=2e6, + pair="eurusd", + payment=dt(2000, 2, 15), + start=dt(2000, 1, 10), + end=dt(2000, 2, 15), + fx_fixings_start="wmr12", + fx_fixings_end="wmr12", + ) + with pytest.raises(ValueError, match="If given, ``scalars`` must be same length as"): + mc.local_fixings( + identifiers=[ + ( + "wmr12_eurusd", + pd.Series(index=[dt(2000, 1, 10), dt(2000, 2, 15)], data=[1.1, 1.1]), + ) + ], + scalars=[1.0, 2.0], + disc_curve=curve1, + ) + fixings.pop("wmr12_eurusd") diff --git a/python/tests/serialization/test_repr.py b/python/tests/serialization/test_repr.py index 23a7e7b6e..cd0a46d43 100644 --- a/python/tests/serialization/test_repr.py +++ b/python/tests/serialization/test_repr.py @@ -1,7 +1,17 @@ import pytest from rateslib import dt from rateslib.dual import Dual, Dual2 -from rateslib.scheduling import Adjuster, Frequency, Imm, Cal, UnionCal, NamedCal, RollDay, Schedule, StubInference +from rateslib.scheduling import ( + Adjuster, + Cal, + Frequency, + Imm, + NamedCal, + RollDay, + Schedule, + StubInference, + UnionCal, +) from rateslib.splines import PPSplineDual, PPSplineDual2, PPSplineF64 @@ -35,8 +45,8 @@ ), "PPSplineDual2", ), - (Cal([],[]), "Cal"), - (UnionCal([Cal([],[]), Cal([],[])], []), "UnionCal"), + (Cal([], []), "Cal"), + (UnionCal([Cal([], []), Cal([], [])], []), "UnionCal"), (NamedCal("tgt,ldn|fed"), "NamedCal:'tgt,ldn|fed'"), ], ) diff --git a/python/tests/test_fixings.py b/python/tests/test_fixings.py index 5793add08..7c2536c8e 100644 --- a/python/tests/test_fixings.py +++ b/python/tests/test_fixings.py @@ -5,6 +5,7 @@ from rateslib import dt, fixings from rateslib.curves import Curve from rateslib.data.fixings import FloatRateIndex, FloatRateSeries, FXFixing, RFRFixing +from rateslib.enums.generics import NoInput from rateslib.instruments import IRS from rateslib.scheduling import Adjuster, get_calendar @@ -46,6 +47,16 @@ def test_add_fixings_directly() -> None: fixings.pop("my_values") +def test_add_fixings_directly_with_specific_state() -> None: + s = Series( + index=[dt(2000, 2, 1), dt(2000, 3, 1), dt(2000, 1, 1)], + data=[200.0, 300.0, 100.0], + ) + fixings.add("my_values", s, 10103) + assert fixings["my_values"][0] == 10103 + fixings.pop("my_values") + + def test_get_stub_ibor_fixings() -> None: s = Series( index=[dt(2000, 2, 1), dt(2000, 3, 1), dt(2000, 1, 1)], @@ -142,6 +153,36 @@ def test_state_id(): assert before != fixings["usd_IBOR_3w"][0] +def test_series_combine(): + from rateslib.periods.protocols.fixings import _s2_before_s1 + + s1 = Series(index=[2, 3], data=[100.0, 200.0]) + s2 = Series(index=[1, 2], data=[300.0, 400.0]) + result = s1.combine(s2, _s2_before_s1) + assert all(result == Series(index=[1, 2, 3], data=[300.0, 400.0, 200.0])) + + +def test_reset_doc(): + fx_fixing1 = FXFixing(date=dt(2021, 1, 1), pair="eurusd", identifier="A") + fx_fixing2 = FXFixing(date=dt(2021, 1, 1), pair="gbpusd", identifier="B") + fixings.add("A_eurusd", Series(index=[dt(2021, 1, 1)], data=[1.1]), state=100) + fixings.add("B_gbpusd", Series(index=[dt(2021, 1, 1)], data=[1.4]), state=200) + + # data is populated from the available Series + assert fx_fixing1.value == 1.1 + assert fx_fixing2.value == 1.4 + + # fixings are reset according to the data state + fx_fixing1.reset(state=100) + fx_fixing2.reset(state=100) + + # only the private data for fixing1 is removed because of its link to the data state + assert fx_fixing1._value == NoInput.blank + assert fx_fixing2._value == 1.4 + fixings.pop("A_eurusd") + fixings.pop("B_gbpusd") + + class TestRFRFixing: def test_rfr_lockout(self) -> None: name = str(hash(os.urandom(8))) + "_1B" @@ -218,3 +259,30 @@ def test_cross2(self) -> None: assert fx_fixing.value == 2.0 * 1 / 4.0 fixings.pop(name + "_RUBUSD") fixings.pop(name + "_INRUSD") + + def test_reset(self): + fx_fixing = FXFixing(dt(2000, 1, 1), pair="rubusd", identifier="test") + fixings.add("test_USDRUB", Series(index=[dt(2000, 1, 1)], data=[2.0])) + assert fx_fixing.value == 0.5 + fx_fixing.reset(state=1) + assert fx_fixing._value == 0.5 + fx_fixing.reset(state=fixings["TEST_USDRUB"][0]) + assert fx_fixing._value == NoInput(0) + fixings.pop("test_USDRUB") + + def test_no_state_update(self): + # test that the fixing value and state is updated at the appropriate times. + fx_fixing = FXFixing(dt(2000, 1, 1), pair="rubusd", identifier="test") + fixings.add("test_USDRUB", Series(index=[dt(2000, 1, 1)], data=[2.0])) + assert fx_fixing.value == 0.5 + old_state = fx_fixing._state + fixings.pop("test_USDRUB") + fixings.add("test_USDRUB", Series(index=[dt(2000, 1, 1)], data=[5.0])) + # value and state are unchanged + assert fx_fixing.value == 0.5 + assert fx_fixing._state == old_state + fx_fixing.reset() + # value are state are now set after reset + assert fx_fixing.value == 0.20 + assert fx_fixing._state == fixings["TEST_USDRUB"][0] + fixings.pop("test_USDRUB")