diff --git a/docs/source/i_whatsnew.rst b/docs/source/i_whatsnew.rst index ff9f08c9..f10981ba 100644 --- a/docs/source/i_whatsnew.rst +++ b/docs/source/i_whatsnew.rst @@ -71,6 +71,9 @@ email contact through **rateslib@gmail.com**. * - Feature - Description + * - Periods + - :class:`~rateslib.periods.FloatPeriod` now allows **averaging** methods for + determining the rate. * - Curves - The :meth:`shift()` operation for *Curves* now defaults to using a *CompositeCurve* approach to preserve a constant spread to the underlying *Curve* via diff --git a/rateslib/default.py b/rateslib/default.py index 979d5a7f..5c89f9c7 100644 --- a/rateslib/default.py +++ b/rateslib/default.py @@ -126,6 +126,10 @@ class Defaults: "rfr_observation_shift": 2, "rfr_lockout": 2, "rfr_lookback": 2, + "rfr_payment_delay_avg": 0, # no observation shift - use payment_delay param + "rfr_observation_shift_avg": 2, + "rfr_lockout_avg": 2, + "rfr_lookback_avg": 2, "ibor": 2, } spread_compound_method = "none_simple" diff --git a/rateslib/periods.py b/rateslib/periods.py index 8008aaf5..5aad1c7a 100644 --- a/rateslib/periods.py +++ b/rateslib/periods.py @@ -502,6 +502,10 @@ def _validate_float_args( "rfr_observation_shift", "rfr_lockout", "rfr_lookback", + "rfr_payment_delay_avg", + "rfr_observation_shift_avg", + "rfr_lockout_avg", + "rfr_lookback_avg", ]: raise ValueError( "`fixing_method` must be in {'rfr_payment_delay', " @@ -611,6 +615,10 @@ class FloatPeriod(BasePeriod): ``method_param`` as integer number of business days defines the observation offset, the DCFs remain static, measured between the start and end dates. + - **"rfr_payment_delay_avg", "rfr_observation_shift_avg", "rfr_lockout_avg", + "rfr_lookback_avg"**: these are the same as the previous conventions except that + the period rate is defined as the arithmetic average of the individual fixings, + weighted by the relevant DCF depending upon the method. - **"ibor"**: this the convention for determining IBOR rates from a curve. The ``method_param`` is the number of fixing lag days before the accrual start when the fixing is published. For example, Euribor or Stibor have 2. @@ -969,6 +977,7 @@ def _rfr_rate_from_df_curve(self, curve: Curve): # TODO: (low:perf) semi-efficient method for lockout under certain conditions else: # return inefficient calculation + # this is also the path for all averaging methods return self._rfr_fixings_array(curve, fixing_exposure=False)[0] def _ibor_rate_from_df_curve(self, curve: Curve): @@ -1009,6 +1018,30 @@ def _ibor_rate_from_line_curve(self, curve: LineCurve): def _rfr_rate_from_line_curve(self, curve: LineCurve): return self._rfr_fixings_array(curve, fixing_exposure=False)[0] + def _avg_rate_with_spread(self, rates, dcf_vals): + """ + Calculate all in rate with float spread under averaging. + + Parameters + ---------- + rates : Series + The rates which are expected for each daily period. + dcf_vals : Series + The weightings which are used for each rate in the compounding formula. + + Returns + ------- + float, Dual, Dual2 + """ + dcf_vals = dcf_vals.set_axis(rates.index) + if self.spread_compound_method != "none_simple": + raise ValueError( + "`spread_compound` method must be 'none_simple' in an RFR averaging " + "period." + ) + else: + return (dcf_vals * rates).sum() / dcf_vals.sum() + self.float_spread / 100 + def _isda_compounded_rate_with_spread(self, rates, dcf_vals): """ Calculate all in rates with float spread under different compounding methods. @@ -1311,9 +1344,12 @@ def _rfr_fixings_array( "rfr_payment_delay", "rfr_observation_shift", "rfr_lockout", + "rfr_payment_delay_avg", + "rfr_observation_shift_avg", + "rfr_lockout_avg", ]: # for all these methods there is no shift dcf_of_r = dcf_vals.copy() - elif self.fixing_method == "rfr_lookback": + elif self.fixing_method in ["rfr_lookback", "rfr_lookback_avg"]: dcf_of_r = Series( [ dcf(obs_dates[i], obs_dates[i + 1], curve.convention) @@ -1322,7 +1358,7 @@ def _rfr_fixings_array( ) v_with_r = Series([disc_curve[obs_dates[i]] for i in range(1, len(dcf_dates.index))]) - if self.fixing_method == "rfr_lockout": + if self.fixing_method in ["rfr_lockout", "rfr_lockout_avg"]: # adjust the final rates values of the lockout arrays according to param try: rates.iloc[-self.method_param :] = rates.iloc[-self.method_param - 1] @@ -1334,9 +1370,12 @@ def _rfr_fixings_array( [Dual(float(r), f"fixing_{i}") for i, (k, r) in enumerate(rates.items())], index=rates.index, ) - if self.fixing_method == "rfr_lockout": + if self.fixing_method in ["rfr_lockout", "rfr_lockout_avg"]: rates_dual.iloc[-self.method_param :] = rates_dual.iloc[-self.method_param - 1] - rate = self._isda_compounded_rate_with_spread(rates_dual, dcf_vals) + if "avg" in self.fixing_method: + rate = self._avg_rate_with_spread(rates_dual, dcf_vals) + else: + rate = self._isda_compounded_rate_with_spread(rates_dual, dcf_vals) notional_exposure = Series( [rate.gradient(f"fixing_{i}")[0] for i in range(len(dcf_dates.index) - 1)] ) @@ -1349,7 +1388,10 @@ def _rfr_fixings_array( "notional": notional_exposure.apply(float, convert_dtype=float), } else: - rate = self._isda_compounded_rate_with_spread(rates, dcf_vals) + if "avg" in self.fixing_method: + rate = self._avg_rate_with_spread(rates, dcf_vals) + else: + rate = self._isda_compounded_rate_with_spread(rates, dcf_vals) extra_cols = {} if rates.isna().any(): @@ -1401,13 +1443,12 @@ def _fixings_table_fast(self, curve: Union[Curve, LineCurve], disc_curve: Curve) v_vals = Series(np.exp(v_vals.to_numpy()), index=obs_vals.index) scalar = dcf_vals.values / obs_vals.values - if self.fixing_method == "rfr_lockout": + if self.fixing_method in ["rfr_lockout", "rfr_lockout_avg"]: scalar[-self.method_param :] = 0.0 scalar[-(self.method_param + 1)] = ( obs_vals.iloc[-(self.method_param + 1) :].sum() / obs_vals.iloc[-(self.method_param + 1)] ) - # perform an efficient rate approximation rate = curve.rate( effective=obs_dates.iloc[0], @@ -1418,7 +1459,9 @@ def _fixings_table_fast(self, curve: Union[Curve, LineCurve], disc_curve: Curve) ) # approximate sensitivity to each fixing z = self.float_spread / 10000 - if self.spread_compound_method == "none_simple": + if "avg" in self.fixing_method: + drdri = (1 / n) + elif self.spread_compound_method == "none_simple": drdri = (1 / n) * (1 + (r_bar / 100) * d) ** (n - 1) elif self.spread_compound_method == "isda_compounding": drdri = (1 / n) * (1 + (r_bar / 100 + z) * d) ** (n - 1) @@ -1495,21 +1538,22 @@ def _is_inefficient(self): def _get_method_dcf_markers(self, curve: Union[Curve, LineCurve], endpoints=False): # Depending upon method get the observation dates and dcf dates - if self.fixing_method in ["rfr_payment_delay", "rfr_lockout"]: + if self.fixing_method in ["rfr_payment_delay", "rfr_lockout", "rfr_payment_delay_avg", "rfr_lockout_avg"]: start_obs, end_obs = self.start, self.end start_dcf, end_dcf = self.start, self.end - elif self.fixing_method == "rfr_observation_shift": + elif self.fixing_method in ["rfr_observation_shift", "rfr_observation_shift_avg"]: start_obs = add_tenor(self.start, f"-{self.method_param}b", "P", curve.calendar) end_obs = add_tenor(self.end, f"-{self.method_param}b", "P", curve.calendar) start_dcf, end_dcf = start_obs, end_obs - elif self.fixing_method == "rfr_lookback": + elif self.fixing_method in ["rfr_lookback", "rfr_lookback_avg"]: start_obs = add_tenor(self.start, f"-{self.method_param}b", "P", curve.calendar) end_obs = add_tenor(self.end, f"-{self.method_param}b", "P", curve.calendar) start_dcf, end_dcf = self.start, self.end else: raise NotImplementedError( "`fixing_method` should be in {'rfr_payment_delay', 'rfr_lockout', " - "'rfr_lookback', 'rfr_observation_shift'}" + "'rfr_lookback', 'rfr_observation_shift'} or the same with '_avg' as " + "a suffix for averaging methods." ) if endpoints: diff --git a/tests/test_periods.py b/tests/test_periods.py index 4ab96b00..5c49c211 100644 --- a/tests/test_periods.py +++ b/tests/test_periods.py @@ -262,6 +262,17 @@ def test_float_period_npv(self, curve): result = float_period.npv(curve) assert abs(result + 9997768.95848275) < 1e-7 + def test_rfr_avg_method_raises(self, curve): + period = FloatPeriod( + dt(2022, 1, 1), dt(2022, 1, 4), dt(2022, 1, 4), "Q", + fixing_method="rfr_payment_delay_avg", + spread_compound_method="isda_compounding", + ) + msg = "`spread_compound` method must be 'none_simple' in an RFR averaging " \ + "period." + with pytest.raises(ValueError, match=msg): + period.rate(curve) + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) def test_rfr_payment_delay_method(self, curve_type, rfr_curve, line_curve): curve = rfr_curve if curve_type == "curve" else line_curve @@ -287,6 +298,88 @@ def test_rfr_payment_delay_method_with_fixings(self, curve_type, rfr_curve, line expected = ((1 + 0.10 / 365) * (1 + 0.08 / 365) * (1 + 0.03 / 365) - 1) * 36500 / 3 assert abs(result - expected) < 1e-12 + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_payment_delay_avg_method(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 1), dt(2022, 1, 4), dt(2022, 1, 4), "Q", fixing_method="rfr_payment_delay_avg" + ) + result = period.rate(curve) + expected = (1.0 + 2.0 + 3.0) / 3 + assert abs(result - expected) < 1e-11 + + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_payment_delay_avg_method_with_fixings(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 1), + dt(2022, 1, 4), + dt(2022, 1, 4), + "Q", + fixing_method="rfr_payment_delay_avg", + fixings=[10, 8], + ) + result = period.rate(curve) + expected = (10.0 + 8.0 + 3.0) / 3 + assert abs(result - expected) < 1e-11 + + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_lockout_avg_method(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 1), + dt(2022, 1, 4), + dt(2022, 1, 4), + "Q", + fixing_method="rfr_lockout_avg", + method_param=2, + ) + assert period._is_inefficient is True # lockout requires all fixings. + result = period.rate(curve) + expected = 1.0 + assert abs(result - expected) < 1e-11 + + period = FloatPeriod( + dt(2022, 1, 2), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_lockout_avg", + method_param=1, + ) + result = period.rate(rfr_curve) + expected = (2 + 3.0 + 3.0) / 3 + assert abs(result - expected) < 1e-11 + + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_lockout_avg_method_with_fixings(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 1), + dt(2022, 1, 4), + dt(2022, 1, 4), + "Q", + fixing_method="rfr_lockout_avg", + method_param=2, + fixings=[10, 8], + ) + result = period.rate(curve) + expected = 10.0 + assert abs(result - expected) < 1e-12 + + period = FloatPeriod( + dt(2022, 1, 2), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_lockout_avg", + method_param=1, + fixings=[10, 8], + ) + result = period.rate(rfr_curve) + expected = (10.0 + 8.0 + 8.0 ) /3 + assert abs(result - expected) < 1e-12 + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) def test_rfr_lockout_method(self, curve_type, rfr_curve, line_curve): curve = rfr_curve if curve_type == "curve" else line_curve @@ -400,6 +493,62 @@ def test_rfr_observation_shift_method_with_fixings(self, curve_type, rfr_curve, expected = ((1 + 0.10 / 365) * (1 + 0.08 / 365) - 1) * 36500 / 2 assert abs(result - expected) < 1e-12 + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_observation_shift_avg_method(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 2), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_observation_shift_avg", + method_param=1, + ) + result = period.rate(curve) + expected = (1.0 + 2 + 3) /3 + assert abs(result - expected) < 1e-11 + + period = FloatPeriod( + dt(2022, 1, 3), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_observation_shift_avg", + method_param=2, + ) + result = period.rate(curve) + expected = (1.0 + 2.0) / 2 + assert abs(result - expected) < 1e-11 + + @pytest.mark.parametrize("curve_type", ["curve", "line_curve"]) + def test_rfr_observation_shift_avg_method_with_fixings(self, curve_type, rfr_curve, line_curve): + curve = rfr_curve if curve_type == "curve" else line_curve + period = FloatPeriod( + dt(2022, 1, 2), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_observation_shift_avg", + method_param=1, + fixings=[10, 8], + ) + result = period.rate(curve) + expected = (10.0 + 8.0 + 3.0) / 3 + assert abs(result - expected) < 1e-11 + + period = FloatPeriod( + dt(2022, 1, 3), + dt(2022, 1, 5), + dt(2022, 1, 5), + "Q", + fixing_method="rfr_observation_shift_avg", + method_param=2, + fixings=[10, 8], + ) + result = period.rate(curve) + expected = (10.0 + 8) / 2 + assert abs(result - expected) < 1e-11 + def test_dcf_obs_period_raises(self): curve = Curve({dt(2022, 1, 1): 1.0, dt(2023, 1, 1): 0.98}, calendar="ldn") float_period = FloatPeriod( @@ -791,16 +940,8 @@ def test_float_period_fixings_list_raises_on_ibor(self, curve, line_curve): with pytest.raises(ValueError, match="`fixings` cannot be supplied as list,"): float_period.rate(line_curve) - def test_rfr_fixings_table(self, curve): - float_period = FloatPeriod( - start=dt(2022, 12, 28), - end=dt(2023, 1, 2), - payment=dt(2023, 1, 2), - frequency="M", - fixings=[1.19, 1.19, -8.81], - ) - result = float_period.fixings_table(curve) - expected = DataFrame( + @pytest.mark.parametrize("meth, exp", [ + ("rfr_payment_delay", DataFrame( { "obs_dates": [ dt(2022, 12, 28), @@ -819,13 +960,44 @@ def test_rfr_fixings_table(self, curve): "dcf": [0.0027777777777777778] * 5, "rates": [1.19, 1.19, -8.81, 4.01364, 4.01364], } - ).set_index("obs_dates") - assert_frame_equal(result, expected, rtol=1e-4) + ).set_index("obs_dates")), + ("rfr_payment_delay_avg", DataFrame( + { + "obs_dates": [ + dt(2022, 12, 28), + dt(2022, 12, 29), + dt(2022, 12, 30), + dt(2022, 12, 31), + dt(2023, 1, 1), + ], + "notional": [ + 0.0, + 0.0, + 0.0, + -999888.52252, + -1000000.00000, + ], + "dcf": [0.0027777777777777778] * 5, + "rates": [1.19, 1.19, -8.81, 4.01364, 4.01364], + } + ).set_index("obs_dates")) + ]) + def test_rfr_fixings_table(self, curve, meth, exp): + float_period = FloatPeriod( + start=dt(2022, 12, 28), + end=dt(2023, 1, 2), + payment=dt(2023, 1, 2), + frequency="M", + fixings=[1.19, 1.19, -8.81], + fixing_method=meth, + ) + result = float_period.fixings_table(curve) + assert_frame_equal(result, exp, rtol=1e-4) curve._set_ad_order(order=1) # assert values are unchanged even if curve can calculate derivatives result = float_period.fixings_table(curve) - assert_frame_equal(result, expected) + assert_frame_equal(result, exp) @pytest.mark.parametrize( "method, param", @@ -875,6 +1047,46 @@ def test_rfr_fixings_table_fast(self, method, param, scm, spd, crv): result = float_period.fixings_table(crv, approximate=True) assert_frame_equal(result, expected, rtol=1e-2) + @pytest.mark.parametrize( + "method, param", + [ + ("rfr_payment_delay_avg", None), + ("rfr_lookback_avg", 4), + ("rfr_lockout_avg", 1), + ("rfr_observation_shift_avg", 2), + ], + ) + @pytest.mark.parametrize( + "crv", + [ + Curve( + { + dt(2022, 1, 1): 1.00, + dt(2022, 4, 1): 0.99, + dt(2022, 7, 1): 0.98, + dt(2022, 10, 1): 0.97, + dt(2023, 6, 1): 0.96, + }, + interpolation="log_linear", + calendar="bus", + ), + ], + ) + def test_rfr_fixings_table_fast_avg(self, method, param, crv): + float_period = FloatPeriod( + start=dt(2022, 12, 28), + end=dt(2023, 1, 3), + payment=dt(2023, 1, 3), + frequency="M", + fixing_method=method, + method_param=param, + spread_compound_method="none_simple", + float_spread=100.0, + ) + expected = float_period.fixings_table(crv) + result = float_period.fixings_table(crv, approximate=True) + assert_frame_equal(result, expected, rtol=1e-2) + def test_rfr_rate_fixings_series_monotonic_error(self): nodes = { dt(2022, 1, 1): 1.00,