diff --git a/pyproject.toml b/pyproject.toml index 25cbcdad0..3c5647cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ classifiers = [ test = [ # "pandas>=3.0,<4.0", "pytest>=9.0,<10.0", + "pytest-env>=1.0,<2.0", "coverage>=7.6.1,<8.0", ] lint = [ @@ -62,10 +63,9 @@ gui = [ [tool.pytest.ini_options] # pythonpath = [".", "python/rateslib"] minversion = "8.0" -addopts = """ - --ignore-glob=*_ignore.py - --override-ini='env=MPLBACKEND=Agg' -""" # use -s to show print capture, use -q for quiet, use -v for verbose +addopts = [ + "--ignore-glob=*_ignore.py", +] # use -s to show print capture, use -q for quiet, use -v for verbose testpaths = [ "python/tests", ] @@ -74,6 +74,9 @@ filterwarnings = [ "ignore::PendingDeprecationWarning" ] +[tool.pytest_env] +MPLBACKEND = "Agg" + [tool.setuptools] packages = ["rateslib"] diff --git a/python/rateslib/volatility/ir/base.py b/python/rateslib/volatility/ir/base.py index b9423eedc..fd206ccee 100644 --- a/python/rateslib/volatility/ir/base.py +++ b/python/rateslib/volatility/ir/base.py @@ -674,6 +674,14 @@ def _construct_smile( tenor: datetime, params: Sequence[DualTypes] | Arr1dObj, ) -> _BaseIRSmile: + if isinstance(self.meta.time_scalars, NoInput): + ts = NoInput(0) + else: + if expiry > self.meta.time_scalars.index[-1]: + ts = NoInput(0) + else: + ts = self.meta.time_scalars[expiry] + return self._SmileType( # type: ignore[call-arg] nodes=dict(zip(self.meta.indexes, params, strict=True)), eval_date=self.meta.eval_date, @@ -682,6 +690,7 @@ def _construct_smile( tenor=tenor, shift=self.meta.shift, ad=None, # inherit the AD variables from the params + time_scalar=ts, **self.meta.smile_params, ) diff --git a/python/rateslib/volatility/ir/utils.py b/python/rateslib/volatility/ir/utils.py index 823c3f59f..cb423fedd 100644 --- a/python/rateslib/volatility/ir/utils.py +++ b/python/rateslib/volatility/ir/utils.py @@ -20,6 +20,7 @@ import numpy as np from pandas import Series +from rateslib import calendars from rateslib.data.fixings import IRSFixing, _get_irs_series from rateslib.enums.generics import NoInput from rateslib.scheduling import Adjuster, add_tenor @@ -212,6 +213,16 @@ def __post_init__(self) -> None: for idx in range(1, len(self.expiries)): if self.expiry_dates[idx - 1] >= self.expiry_dates[idx]: raise ValueError("Cube `expiries` are not sorted or contain duplicates.\n") + if not isinstance(self._weights, NoInput): + object.__setattr__( + self, + "_weights", + _scale_weights( + eval_date=self.eval_date, + weights=self._weights, + expiries=self.expiry_dates, + ), + ) @property def shift(self) -> DualTypes: @@ -252,13 +263,15 @@ def weights(self) -> Series[float] | NoInput: return self._weights @cached_property - def weights_cum(self) -> Series[float] | NoInput: + def time_scalars(self) -> Series[float] | NoInput: """Weight adjusted time to expiry (in calendar days) per date for temporal volatility interpolation.""" if isinstance(self.weights, NoInput): - return self.weights + return NoInput(0) else: - return self.weights.cumsum() + c = Series(index=self.weights.index, data=1.0) + c.iloc[0] = 0.0 + return self.weights.cumsum() / c.cumsum() @property def tenors(self) -> list[str]: @@ -506,3 +519,55 @@ def _bilinear_interp( + bl * (1 - h[1]) * v[0] + br * h[1] * v[1] ) + + +def _scale_weights( + eval_date: datetime, + weights: Series[float], + expiries: list[datetime], +) -> Series[float]: + # the last weight is considered the end point of interest + w = weights.sort_index(ascending=True) # sorted input + del weights + d = calendars.get("all").cal_date_range(eval_date, w.index[-1]) + s = Series(data=1.0, index=d) + s.update(w) + s.update(Series(index=[eval_date], data=0.0)) + c = s.cumsum() + adj_expiries = [eval_date] + expiries + for i, expiry in enumerate(adj_expiries): + if i == 0: + continue + if expiry < s.index[-1]: + # this expiry is within the middle of the weights series + left_index = (adj_expiries[i - 1] - eval_date).days + right_index = (expiry - adj_expiries[i - 1]).days + left_index + left_count = c[adj_expiries[i - 1]] + right_count = c[adj_expiries[i]] + s.iloc[left_index + 1 : right_index + 1] *= (right_index - left_index) / ( + right_count - left_count + ) + elif adj_expiries[i - 1] < s.index[-1]: + # the weights extend beyond the last expiry but to to the present expiry + left_index = (adj_expiries[i - 1] - eval_date).days + right_index = (s.index[-1] - adj_expiries[i - 1]).days + left_index + left_count = c[adj_expiries[i - 1]] + right_count = c[s.index[-1]] + s.iloc[left_index + 1 : right_index + 1] *= (right_index - left_index) / ( + right_count - left_count + ) + else: + # the weights have been exhausted + break + + if s.index[-1] > expiries[-1]: + # scale the weights beyond last expiry + left_index = (adj_expiries[-1] - eval_date).days + right_index = (s.index[-1] - adj_expiries[-1]).days + left_index + left_count = c[adj_expiries[-1]] + right_count = c[s.index[-1]] + s.iloc[left_index + 1 : right_index + 1] *= (right_index - left_index) / ( + right_count - left_count + ) + + return s diff --git a/python/tests/test_ir_volatility.py b/python/tests/test_ir_volatility.py index 94007a186..42fbed677 100644 --- a/python/tests/test_ir_volatility.py +++ b/python/tests/test_ir_volatility.py @@ -18,7 +18,7 @@ from matplotlib import pyplot as plt from pandas import DataFrame, Index, IndexSlice, Series from pandas.testing import assert_frame_equal, assert_series_equal -from rateslib import default_context +from rateslib import calendars, default_context from rateslib.curves import CompositeCurve, Curve, LineCurve from rateslib.data.fixings import IRSSeries from rateslib.default import NoInput @@ -32,7 +32,7 @@ IRSplineCube, IRSplineSmile, ) -from rateslib.volatility.ir.utils import _bilinear_interp +from rateslib.volatility.ir.utils import _bilinear_interp, _scale_weights from rateslib.volatility.utils import _OptionModelBachelier, _OptionModelBlack76, _SabrSmileNodes @@ -1523,6 +1523,66 @@ def test_set_node_vector(self): assert result[2] == Dual(0.30, ["X_a_1_0"], []) assert result[9] == Dual(20.0, ["X_v_0_1"], []) + @pytest.mark.parametrize( + ("weights", "expiries"), + [ + ( + Series(index=[dt(2000, 1, 3), dt(2000, 1, 8), dt(2000, 1, 4)], data=0.0), + [dt(2000, 1, 5), dt(2000, 1, 10), dt(2000, 1, 15)], + ), + ( + Series(index=[dt(2000, 1, 3), dt(2000, 1, 20), dt(2000, 1, 4)], data=0.0), + [dt(2000, 1, 5), dt(2000, 1, 10), dt(2000, 1, 15)], + ), + ], + ) + def test_weights_implementation(self, weights, expiries): + result = _scale_weights( + eval_date=dt(2000, 1, 1), + weights=weights, + expiries=expiries, + ) + + c = result.cumsum() + for expiry in expiries: + if expiry > c.index[-1]: + assert c.iloc[-1] == (c.index[-1] - dt(2000, 1, 1)).days + else: + assert c[expiry] == (expiry - dt(2000, 1, 1)).days + + assert c.iloc[-1] == (c.index[-1] - dt(2000, 1, 1)).days + + def test_weights(self): + nyc = calendars.get("nyc") + irsc = IRSabrCube( + eval_date=dt(2000, 1, 1), + expiries=["1y", "2y"], + tenors=["1y", "2y"], + irs_series=IRSSeries( + currency="usd", + settle=0, + frequency="A", + convention="Act360", + calendar="all", + leg2_fixing_method="ibor(2)", + ), + beta=0.5, + alpha=np.array([[0.1, 0.2], [0.3, 0.4]]), + rho=np.array([[1.0, 2.0], [3.0, 4.0]]), + nu=np.array([[10.0, 20.0], [30.0, 40.0]]), + id="X", + weights=Series( + index=[ + _ + for _ in nyc.cal_date_range(dt(2000, 1, 1), dt(2001, 2, 3)) + if nyc.is_non_bus_day(_) + ], + data=0.0, + ), + ) + result = irsc.meta.time_scalars + assert abs(result.iloc[-1] - 1.0) < 1e-14 + class TestIRSplineSmile: @pytest.mark.parametrize( @@ -1998,6 +2058,73 @@ def test_pricing_model(self, model, metric): expected = 20.0 assert abs(result - expected) < 1e-6 + def test_business_day_time_and_weights(self): + nyc = calendars.get("nyc") + irsc = IRSplineCube( + eval_date=dt(2000, 1, 3), + expiries=["1m", "3m", "6m"], + tenors=["1y"], + strikes=[0], + parameters=[[[30.0]], [[35.0]], [[38.0]]], + irs_series="usd_irs", + ) + irsc2 = IRSplineCube( + eval_date=dt(2000, 1, 3), + expiries=["1m", "3m", "6m"], + tenors=["1y"], + strikes=[0], + parameters=[[[30.0]], [[35.0]], [[38.0]]], + irs_series="usd_irs", + weights=Series( + index=[ + _ + for _ in nyc.cal_date_range(dt(2000, 1, 7), dt(2000, 7, 15)) + if nyc.is_non_bus_day(_) + ], + data=0.0, + ), + ) + curve = Curve( + nodes={dt(2000, 1, 3): 1.0, dt(2002, 1, 3): 0.93}, + convention="act360", + calendar="nyc", + ) + for expiry in irsc.meta.expiry_dates: + # test at expiries time remapping does not exist because these are the natural pillars + iro = IRSCall( + expiry=expiry, + strike="atm", + irs_series="usd_irs", + tenor="1y", + ) + r1 = iro.rate(curves=curve, vol=irsc, metric="percentnotional") * 100.0 + r2 = iro.rate(curves=curve, vol=irsc2, metric="percentnotional") * 100.0 + assert abs(r1 - r2) < 1e-8 + + for expiry in [dt(2000, 1, 14), dt(2000, 2, 18), dt(2000, 5, 12)]: + # test at expiries inbetween the time remapping exists + iro = IRSCall( + expiry=expiry, + strike="atm", + irs_series="usd_irs", + tenor="1y", + ) + r1 = iro.rate(curves=curve, vol=irsc, metric="percentnotional") * 100.0 + r2 = iro.rate(curves=curve, vol=irsc2, metric="percentnotional") * 100.0 + assert abs(r1 - r2) > 1e-3 + + for expiry in [dt(2000, 7, 20), dt(2000, 7, 25)]: + # test after weights stop being defined + iro = IRSCall( + expiry=expiry, + strike="atm", + irs_series="usd_irs", + tenor="1y", + ) + r1 = iro.rate(curves=curve, vol=irsc, metric="percentnotional") * 100.0 + r2 = iro.rate(curves=curve, vol=irsc2, metric="percentnotional") * 100.0 + assert abs(r1 - r2) < 1e-8 + class TestStateAndCache: @pytest.mark.parametrize(