From 1ac7da2db83a8c18522fdfb9f8357c439e31ff74 Mon Sep 17 00:00:00 2001 From: Abhayindia Date: Wed, 26 Aug 2026 08:14:46 +0900 Subject: [PATCH] Allow development to accept an age in months relative to origin --- chainladder/core/base.py | 53 ++++++++++++++----- chainladder/core/tests/test_triangle.py | 70 +++++++++++++++++++------ chainladder/core/triangle.py | 1 + 3 files changed, 94 insertions(+), 30 deletions(-) diff --git a/chainladder/core/base.py b/chainladder/core/base.py index f52db62e..61628ef3 100644 --- a/chainladder/core/base.py +++ b/chainladder/core/base.py @@ -175,16 +175,38 @@ def _set_development( data: DataFrame, development: list, development_format: None | str, - origin_date: Series + origin_date: Series, + origin_grain: str ) -> Series: """Initialize development and its grain""" if development: - development_date: Series = TriangleBase._to_datetime( + development_date: Series | None = TriangleBase._to_datetime( data=data, fields=development, period_end=True, - date_format=development_format + date_format=development_format, + allow_age=True, ) + if development_date is None: + # age in months relative to origin's period start, using the + # constructor's own origin_grain so fiscal-year anchors match + age: Series = pd.to_numeric(data[development[0]]).round().astype(int) + grain_base = origin_grain.split("-")[0] + if grain_base == "2Q": + # no native pandas semiannual period; only calendar Jan/Jul anchors supported + if origin_grain not in ("2Q", "2Q-DEC"): + raise ValueError( + "Development expressed as an age is not yet supported for a " + f"non-calendar semiannual origin grain ({origin_grain})." + ) + origin_period_start: Series = origin_date.apply( + lambda d: d.replace(month=((d.month - 1) // 6) * 6 + 1, day=1) + ) + else: + origin_period_start = origin_date.dt.to_period(origin_grain).dt.to_timestamp(how="s") + development_date = ( + origin_period_start.dt.to_period("M") + (age - 1) + ).dt.to_timestamp(how="e") else: o_max: Timestamp = pd.Period( value=origin_date.max(), @@ -193,15 +215,6 @@ def _set_development( development_date: Series = pd.Series([o_max] * len(origin_date)) development_date.name = "__development__" - if ( - pd.Series(development_date).dt.year.min() - == pd.Series(development_date).dt.year.max() - == 1970 - ): - raise ValueError( - "Development lags could not be determined. This may be because development" - "is expressed as an age where a date-like vector is required" - ) return development_date @staticmethod @@ -462,8 +475,9 @@ def _to_datetime( data: DataFrame, fields: list, period_end: bool = False, - date_format: Optional[str] = None - ) -> Series: + date_format: Optional[str] = None, + allow_age: bool = False + ) -> Series | None: """ For tabular form, this will take a set of data column(s) and return a single date array. This function heavily @@ -496,9 +510,11 @@ def _to_datetime( ] datetime_mapping: None | dict = None + matched_a_format: bool = False for date_inference in date_inference_list: try: datetime_mapping = dict(zip(datetime_arg, pd.to_datetime(**date_inference))) + matched_a_format = "format" in date_inference break except ValueError: pass @@ -508,6 +524,15 @@ def _to_datetime( "Unable to infer datetime for field(s): " + str(fields) + ". Please check the underlying data or any supplied format arguments." ) + if not matched_a_format and pd.api.types.is_numeric_dtype(datetime_arg): + # unformatted numeric input falls through to pandas treating it + # as nanoseconds since epoch, not an actual date + if allow_age: + return None + raise ValueError( + "Development lags could not be determined. This may be because development " + "is expressed as an age where a date-like vector is required" + ) target: Series = target_field.map(datetime_mapping) return target diff --git a/chainladder/core/tests/test_triangle.py b/chainladder/core/tests/test_triangle.py index 2b83e10c..1cbea310 100644 --- a/chainladder/core/tests/test_triangle.py +++ b/chainladder/core/tests/test_triangle.py @@ -2033,30 +2033,68 @@ def test_set_development_no_development_column() -> None: assert tri.development[0] == str(tri.origin[-1]) -def test_set_development_age_instead_of_date_raises() -> None: - """ - Initialize a triangle with incorrect development periods specified. Should raise a ValueError. +def test_set_development_age_in_months() -> None: + """Development given as an age in months (not a date) resolves to the + valuation date that many months after the origin's period start.""" + df = pd.DataFrame( + { + 'origin': [1995, 1996], + 'development': [12, 24], + 'reported': [1.0, 2.0] + } + ) + tri = cl.Triangle( + data=df, + origin='origin', + development='development', + columns='reported', + cumulative=True + ) + assert list(tri.development) == [12, 24, 36] + frame = tri.to_frame(origin_as_datetime=False) + assert frame.loc["1995", 12] == 1.0 + assert frame.loc["1996", 24] == 2.0 - Returns - ------- - None - """ +def test_set_development_age_respects_mid_period_origin() -> None: + """Age is relative to the start of the origin's own period, not the + literal recorded origin date.""" df = pd.DataFrame( { - 'origin': [1995, 1996], + 'origin': ['2018-06-15', '2018-06-15'], 'development': [12, 24], + 'reported': [100.0, 150.0] + } + ) + tri = cl.Triangle( + data=df, + origin='origin', + development='development', + columns='reported', + cumulative=True + ) + assert list(tri.development) == [12, 24] + + +def test_set_development_bare_years_unaffected_by_age_support() -> None: + """A development column that is genuinely a bare calendar year (e.g. the + literal year 1970) must still parse as a date, not get reinterpreted as + an age.""" + df = pd.DataFrame( + { + 'origin': [1969, 1970], + 'development': [1970, 1970], 'reported': [1.0, 2.0] } ) - with pytest.raises(ValueError, match="Development lags could not be determined"): - cl.Triangle( - data=df, - origin='origin', - development='development', - columns='reported', - cumulative=True - ) + tri = cl.Triangle( + data=df, + origin='origin', + development='development', + columns='reported', + cumulative=True + ) + assert list(tri.development) == ["1970"] def test_input_validation_non_numeric_columns_raises() -> None: diff --git a/chainladder/core/triangle.py b/chainladder/core/triangle.py index 9990cf72..c753182b 100644 --- a/chainladder/core/triangle.py +++ b/chainladder/core/triangle.py @@ -484,6 +484,7 @@ def __init__( development=development, development_format=development_format, origin_date=origin_date, + origin_grain=self.origin_grain, ) if len(development_date.unique()) == 1: