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..37e029f31b1fd 100644 --- a/pandas/tests/tseries/holiday/test_holiday.py +++ b/pandas/tests/tseries/holiday/test_holiday.py @@ -353,3 +353,104 @@ 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 + + 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, + ) + + result = queens_jubilee_uk_spring_bank_holiday.dates(start, end) + expected = DatetimeIndex( + [ + Timestamp("2020-05-25"), + Timestamp("2021-05-31"), + Timestamp("2023-05-29"), + Timestamp("2024-05-27"), + Timestamp("2025-05-26"), + ], + dtype="datetime64[ns]", + ) + tm.assert_index_equal(result, expected) + + +def test_holiday_with_multiple_exclusions(): + start = Timestamp("2025-01-01") + end = Timestamp("2065-12-31") + exclude = DatetimeIndex( + [ + Timestamp("2025-01-01"), + Timestamp("2042-01-01"), + Timestamp("2061-01-01"), + ] + ) # Yakudoshi new year + + yakudoshi_new_year: Holiday = Holiday( + "Yakudoshi New Year", month=1, day=1, exclude_dates=exclude + ) + + result = yakudoshi_new_year.dates(start, end) + expected = DatetimeIndex( + [ + Timestamp("2026-01-01"), + Timestamp("2027-01-01"), + Timestamp("2028-01-01"), + Timestamp("2029-01-01"), + Timestamp("2030-01-01"), + Timestamp("2031-01-01"), + Timestamp("2032-01-01"), + Timestamp("2033-01-01"), + Timestamp("2034-01-01"), + Timestamp("2035-01-01"), + Timestamp("2036-01-01"), + Timestamp("2037-01-01"), + Timestamp("2038-01-01"), + Timestamp("2039-01-01"), + Timestamp("2040-01-01"), + Timestamp("2041-01-01"), + Timestamp("2043-01-01"), + Timestamp("2044-01-01"), + Timestamp("2045-01-01"), + Timestamp("2046-01-01"), + Timestamp("2047-01-01"), + Timestamp("2048-01-01"), + Timestamp("2049-01-01"), + Timestamp("2050-01-01"), + Timestamp("2051-01-01"), + Timestamp("2052-01-01"), + Timestamp("2053-01-01"), + Timestamp("2054-01-01"), + Timestamp("2055-01-01"), + Timestamp("2056-01-01"), + Timestamp("2057-01-01"), + Timestamp("2058-01-01"), + Timestamp("2059-01-01"), + Timestamp("2060-01-01"), + Timestamp("2062-01-01"), + Timestamp("2063-01-01"), + Timestamp("2064-01-01"), + Timestamp("2065-01-01"), + ], + dtype="datetime64[ns]", + ) + tm.assert_index_equal(result, expected) + + +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