Skip to content

DOC/ENH: Holiday exclusion argument #61600

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions doc/source/whatsnew/v3.0.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
101 changes: 101 additions & 0 deletions pandas/tests/tseries/holiday/test_holiday.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 9 additions & 0 deletions pandas/tseries/holiday.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
--------
Expand Down Expand Up @@ -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
Copy link
Author

@sharkipelago sharkipelago Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should I also switch this to throw a ValueError on failing? Or would that be a separate PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A separate PR would be better, thanks.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this, should I open a new issue? Or just make another PR?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just another PR is fine

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 = ""
Expand Down Expand Up @@ -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
Expand Down
Loading