From 744eb0813bd13315a93113caf625a6cadc93679f Mon Sep 17 00:00:00 2001 From: Abhayindia Date: Sun, 13 Sep 2026 12:09:51 +0900 Subject: [PATCH 1/2] Add an ultimate label alias, global and per-Triangle --- chainladder/_config/options.py | 4 +++ chainladder/core/display.py | 2 +- chainladder/core/pandas.py | 2 +- chainladder/core/triangle.py | 26 ++++++++++++++ chainladder/utils/tests/test_utilities.py | 44 +++++++++++++++++++++++ 5 files changed, 76 insertions(+), 2 deletions(-) diff --git a/chainladder/_config/options.py b/chainladder/_config/options.py index d125a1f8..fbe14e98 100644 --- a/chainladder/_config/options.py +++ b/chainladder/_config/options.py @@ -55,6 +55,9 @@ class Options: when comparing or concatenating them. ULT_VAL: str The default ultimate valuation datetime, precision set to default of Pandas installation. + ULT_LABEL: str | None + Label displayed in place of the ultimate development period. ``None`` shows the + underlying ``ULT_VAL``/9999 sentinel. Overridden per-Triangle by ``Triangle.ult_label``. """ @@ -65,6 +68,7 @@ def __init__(self): self.ULT_VAL = str( pd.Timestamp("2262-01-01") - pd.Timedelta(1, unit=__dt64_unit__) # noqa ) + self.ULT_LABEL = None # Store initial values as defaults. self._defaults = copy.deepcopy({ k: v for k, v in vars(self).items() if not k.startswith("_") diff --git a/chainladder/core/display.py b/chainladder/core/display.py index e2015b5f..822f165f 100644 --- a/chainladder/core/display.py +++ b/chainladder/core/display.py @@ -137,7 +137,7 @@ def _repr_format(self, origin_as_datetime: bool = False) -> DataFrame: .replace("Q3", "H2") ) origin = origin_formatted - development = self.development.copy() + development = self._display_development().copy() development.name = None return pd.DataFrame(out, index=origin, columns=development) diff --git a/chainladder/core/pandas.py b/chainladder/core/pandas.py index 4b7237ba..894a0163 100644 --- a/chainladder/core/pandas.py +++ b/chainladder/core/pandas.py @@ -171,7 +171,7 @@ def to_frame( 0: self.kdims, 1: self.vdims, 2: self.origin, - 3: self.development, + 3: self._display_development(), } # Set the index to be key dimension if the key dimension is greater than length 1. diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 0c7799cd..5df1429d 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -886,6 +886,32 @@ def development(self): ] return pd.Series(list(ddims), name="development") + @property + def ult_label(self): + """Label shown in place of the ultimate development period. + + Overrides ``chainladder.options.ULT_LABEL`` for this Triangle only. + ``None`` falls back to the global option. + """ + return getattr(self, "_ult_label", None) + + @ult_label.setter + def ult_label(self, value): + self._ult_label = value + + def _display_development(self): + """``development`` with the ultimate period relabelled for display.""" + development = self.development + label = self.ult_label + if label is None: + label = options.ULT_LABEL + if label is None or not self.is_ultimate: + return development + # the sentinel is always the last development period + return pd.Series( + list(development[:-1]) + [label], name=development.name + ) + @development.setter def development(self, value): self._len_check(self.development, value) diff --git a/chainladder/utils/tests/test_utilities.py b/chainladder/utils/tests/test_utilities.py index 34171be9..3849e107 100644 --- a/chainladder/utils/tests/test_utilities.py +++ b/chainladder/utils/tests/test_utilities.py @@ -702,6 +702,7 @@ def test_options_defaults() -> None: assert options.AUTO_SPARSE assert options.ARRAY_PRIORITY == ["dask", "sparse", "cupy", "numpy"] assert isinstance(options.ULT_VAL, str) + assert options.ULT_LABEL is None def test_get_option() -> None: @@ -717,6 +718,7 @@ def test_get_option() -> None: assert cl.options.get_option("AUTO_SPARSE") == cl.options.AUTO_SPARSE assert cl.options.get_option("ARRAY_PRIORITY") == cl.options.ARRAY_PRIORITY assert cl.options.get_option("ULT_VAL") == cl.options.ULT_VAL + assert cl.options.get_option("ULT_LABEL") == cl.options.ULT_LABEL def test_set_option_consistency() -> None: @@ -1306,3 +1308,45 @@ def test_triangleweight_full_triangle(raa: Triangle) -> None: tw = cl.TriangleWeight(n_periods=4).fit(raa) tw_full = cl.TriangleWeight(n_periods=4).fit(ult.full_triangle_) assert tw.w_.iloc[:, :, :, 0] == tw_full.w_.iloc[:, :, :, 0] + + +def test_ult_label() -> None: + """ + ULT_LABEL should relabel the ultimate period for display only, with a + Triangle-level setting taking precedence over the global option. + + Returns + ------- + None + + """ + triangle = cl.load_sample("raa") + model = cl.Chainladder().fit(triangle) + ultimate = model.ultimate_ + full_expectation = model.full_expectation_ + + assert ultimate.development.tolist() == ["2261"] + assert full_expectation.development.tolist()[-1] == 9999 + + try: + cl.options.set_option("ULT_LABEL", "Ultimate") + assert list(ultimate.to_frame(origin_as_datetime=False).columns) == ["Ultimate"] + assert ( + list(full_expectation.to_frame(origin_as_datetime=False).columns)[-1] + == "Ultimate" + ) + + # a Triangle-level label wins, and doesn't leak to other Triangles + override = ultimate.copy() + override.ult_label = "ULT" + assert list(override.to_frame(origin_as_datetime=False).columns) == ["ULT"] + assert list(ultimate.to_frame(origin_as_datetime=False).columns) == ["Ultimate"] + assert override.copy().ult_label == "ULT" + + # the sentinel itself is untouched, so calculations are unaffected + assert ultimate.development.tolist() == ["2261"] + + # a Triangle without an ultimate period keeps its labels + assert list(triangle.to_frame(origin_as_datetime=False).columns)[-1] == 120 + finally: + cl.options.set_option("ULT_LABEL", None) From 3a5f9ac86b21e3071ec3fc77558fefc0af9aba7d Mon Sep 17 00:00:00 2001 From: Abhayindia Date: Sun, 13 Sep 2026 12:12:21 +0900 Subject: [PATCH 2/2] Format the new helper per ruff --- chainladder/core/triangle.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 5df1429d..de339717 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -908,9 +908,7 @@ def _display_development(self): if label is None or not self.is_ultimate: return development # the sentinel is always the last development period - return pd.Series( - list(development[:-1]) + [label], name=development.name - ) + return pd.Series(list(development[:-1]) + [label], name=development.name) @development.setter def development(self, value):