diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 03a386708323d..5160d2ea8b8fe 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -64,6 +64,7 @@ Other enhancements - :meth:`Series.nlargest` uses a 'stable' sort internally and will preserve original ordering. - :class:`ArrowDtype` now supports ``pyarrow.JsonType`` (:issue:`60958`) - :class:`DataFrameGroupBy` and :class:`SeriesGroupBy` methods ``sum``, ``mean``, ``median``, ``prod``, ``min``, ``max``, ``std``, ``var`` and ``sem`` now accept ``skipna`` parameter (:issue:`15675`) +- :class:`Holiday` has gained the constructor argument and field ``exclude_dates`` to exclude specific datetimes from a custom holiday calendar (:issue:`54382`) - :class:`Rolling` and :class:`Expanding` now support ``nunique`` (:issue:`26958`) - :class:`Rolling` and :class:`Expanding` now support aggregations ``first`` and ``last`` (:issue:`33155`) - :func:`read_parquet` accepts ``to_pandas_kwargs`` which are forwarded to :meth:`pyarrow.Table.to_pandas` which enables passing additional keywords to customize the conversion to pandas, such as ``maps_as_pydicts`` to read the Parquet map data type as python dictionaries (:issue:`56842`) diff --git a/pandas/tests/tseries/holiday/test_holiday.py b/pandas/tests/tseries/holiday/test_holiday.py index ffe6ff0b51bcf..42ec4983c01b7 100644 --- a/pandas/tests/tseries/holiday/test_holiday.py +++ b/pandas/tests/tseries/holiday/test_holiday.py @@ -353,3 +353,71 @@ def test_holidays_with_timezone_specified_but_no_occurences(): expected_results.index = expected_results.index.as_unit("ns") tm.assert_equal(test_case, expected_results) + + +def test_holiday_with_exclusion(): + # GH 54382 + start = Timestamp("2020-05-01") + end = Timestamp("2025-05-31") + exclude = DatetimeIndex([Timestamp("2022-05-30")]) # Queen's platinum Jubilee + default_uk_spring_bank_holiday: Holiday = Holiday( + "UK Spring Bank Holiday", + month=5, + day=31, + offset=DateOffset(weekday=MO(-1)), + ) + + queens_jubilee_uk_spring_bank_holiday: Holiday = Holiday( + "Queen's Jubilee UK Spring Bank Holiday", + month=5, + day=31, + offset=DateOffset(weekday=MO(-1)), + exclude_dates=exclude, + ) + + original_dates = default_uk_spring_bank_holiday.dates(start, end) + actual_dates = queens_jubilee_uk_spring_bank_holiday.dates(start, end) + print(exclude.isin(original_dates).all()) + + assert exclude.isin(original_dates).all() + assert ~exclude.isin(actual_dates).all() + assert actual_dates.isin(original_dates).all() + + +def test_holiday_with_multiple_exclusions(): + start = Timestamp("2000-01-01") + end = Timestamp("2100-05-31") + exclude = DatetimeIndex( + [ + Timestamp("2025-01-01"), + Timestamp("2042-01-01"), + Timestamp("2061-01-01"), + ] + ) # Yakudoshi new year + default_japan_new_year: Holiday = Holiday( + "Japan New Year", + month=1, + day=1, + ) + + yakudoshi_new_year: Holiday = Holiday( + "Yakudoshi New Year", month=1, day=1, exclude_dates=exclude + ) + + original_dates = default_japan_new_year.dates(start, end) + actual_dates = yakudoshi_new_year.dates(start, end) + + assert exclude.isin(original_dates).all() + assert ~exclude.isin(actual_dates).all() + assert actual_dates.isin(original_dates).all() + + +def test_exclude_date_value_error(): + msg = "exclude_dates must be None or of type DatetimeIndex." + + with pytest.raises(ValueError, match=msg): + exclude = [ + Timestamp("2025-06-10"), + Timestamp("2026-06-10"), + ] + Holiday("National Ice Tea Day", month=6, day=10, exclude_dates=exclude) diff --git a/pandas/tseries/holiday.py b/pandas/tseries/holiday.py index 2d195fbbc4e84..8ad2541d2e58d 100644 --- a/pandas/tseries/holiday.py +++ b/pandas/tseries/holiday.py @@ -169,6 +169,7 @@ def __init__( start_date=None, end_date=None, days_of_week: tuple | None = None, + exclude_dates: DatetimeIndex | None = None, ) -> None: """ Parameters @@ -193,6 +194,8 @@ class from pandas.tseries.offsets, default None days_of_week : tuple of int or dateutil.relativedelta weekday strs, default None Provide a tuple of days e.g (0,1,2,3,) for Monday Through Thursday Monday=0,..,Sunday=6 + exclude_dates : DatetimeIndex or default None + Specific dates to exclude e.g. skipping a specific year's holiday Examples -------- @@ -257,6 +260,9 @@ class from pandas.tseries.offsets, default None self.observance = observance assert days_of_week is None or type(days_of_week) == tuple self.days_of_week = days_of_week + if not (exclude_dates is None or isinstance(exclude_dates, DatetimeIndex)): + raise ValueError("exclude_dates must be None or of type DatetimeIndex.") + self.exclude_dates = exclude_dates def __repr__(self) -> str: info = "" @@ -328,6 +334,9 @@ def dates( holiday_dates = holiday_dates[ (holiday_dates >= filter_start_date) & (holiday_dates <= filter_end_date) ] + + if self.exclude_dates is not None: + holiday_dates = holiday_dates.difference(self.exclude_dates) if return_name: return Series(self.name, index=holiday_dates) return holiday_dates