Skip to content

Commit 13e45c0

Browse files
committed
Merge pull request #11484 from rockg/master
BUG: Holiday observance rules could not be applied
2 parents 83795a3 + 6303d76 commit 13e45c0

File tree

4 files changed

+132
-34
lines changed

4 files changed

+132
-34
lines changed

doc/source/timeseries.rst

+5-1
Original file line numberDiff line numberDiff line change
@@ -1043,10 +1043,14 @@ An example of how holidays and holiday calendars are defined:
10431043
cal.holidays(datetime(2012, 1, 1), datetime(2012, 12, 31))
10441044
10451045
Using this calendar, creating an index or doing offset arithmetic skips weekends
1046-
and holidays (i.e., Memorial Day/July 4th).
1046+
and holidays (i.e., Memorial Day/July 4th). For example, the below defines
1047+
a custom business day offset using the ``ExampleCalendar``. Like any other offset,
1048+
it can be used to create a ``DatetimeIndex`` or added to ``datetime``
1049+
or ``Timestamp`` objects.
10471050

10481051
.. ipython:: python
10491052
1053+
from pandas.tseries.offsets import CDay
10501054
DatetimeIndex(start='7/1/2012', end='7/10/2012',
10511055
freq=CDay(calendar=cal)).to_pydatetime()
10521056
offset = CustomBusinessDay(calendar=cal)

doc/source/whatsnew/v0.17.1.txt

+2
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ Bug Fixes
128128

129129

130130
- Fix regression in setting of ``xticks`` in ``plot`` (:issue:`11529`).
131+
- Bug in ``holiday.dates`` where observance rules could not be applied to holiday and doc enhancement (:issue:`11477`, :issue:`11533`)
132+
131133

132134

133135

pandas/tseries/holiday.py

+59-28
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from datetime import datetime, timedelta
44
from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU
55
from pandas.tseries.offsets import Easter, Day
6+
import numpy as np
67

78

89
def next_monday(dt):
@@ -156,8 +157,8 @@ class from pandas.tseries.offsets
156157
self.month = month
157158
self.day = day
158159
self.offset = offset
159-
self.start_date = start_date
160-
self.end_date = end_date
160+
self.start_date = Timestamp(start_date) if start_date is not None else start_date
161+
self.end_date = Timestamp(end_date) if end_date is not None else end_date
161162
self.observance = observance
162163
assert (days_of_week is None or type(days_of_week) == tuple)
163164
self.days_of_week = days_of_week
@@ -179,7 +180,7 @@ def __repr__(self):
179180

180181
def dates(self, start_date, end_date, return_name=False):
181182
"""
182-
Calculate holidays between start date and end date
183+
Calculate holidays observed between start date and end date
183184
184185
Parameters
185186
----------
@@ -189,63 +190,86 @@ def dates(self, start_date, end_date, return_name=False):
189190
If True, return a series that has dates and holiday names.
190191
False will only return dates.
191192
"""
193+
start_date = Timestamp(start_date)
194+
end_date = Timestamp(end_date)
195+
196+
filter_start_date = start_date
197+
filter_end_date = end_date
198+
192199
if self.year is not None:
193200
dt = Timestamp(datetime(self.year, self.month, self.day))
194201
if return_name:
195202
return Series(self.name, index=[dt])
196203
else:
197204
return [dt]
198205

199-
if self.start_date is not None:
200-
start_date = self.start_date
201-
202-
if self.end_date is not None:
203-
end_date = self.end_date
204-
205-
start_date = Timestamp(start_date)
206-
end_date = Timestamp(end_date)
207-
208-
year_offset = DateOffset(years=1)
209-
base_date = Timestamp(
210-
datetime(start_date.year, self.month, self.day),
211-
tz=start_date.tz,
212-
)
213-
dates = DatetimeIndex(start=base_date, end=end_date, freq=year_offset)
206+
dates = self._reference_dates(start_date, end_date)
214207
holiday_dates = self._apply_rule(dates)
215208
if self.days_of_week is not None:
216-
holiday_dates = list(filter(lambda x: x is not None and
217-
x.dayofweek in self.days_of_week,
218-
holiday_dates))
219-
else:
220-
holiday_dates = list(filter(lambda x: x is not None, holiday_dates))
209+
holiday_dates = holiday_dates[np.in1d(holiday_dates.dayofweek,
210+
self.days_of_week)]
211+
212+
if self.start_date is not None:
213+
filter_start_date = max(self.start_date.tz_localize(filter_start_date.tz), filter_start_date)
214+
if self.end_date is not None:
215+
filter_end_date = min(self.end_date.tz_localize(filter_end_date.tz), filter_end_date)
216+
holiday_dates = holiday_dates[(holiday_dates >= filter_start_date) &
217+
(holiday_dates <= filter_end_date)]
221218
if return_name:
222219
return Series(self.name, index=holiday_dates)
223220
return holiday_dates
221+
222+
223+
def _reference_dates(self, start_date, end_date):
224+
"""
225+
Get reference dates for the holiday.
226+
227+
Return reference dates for the holiday also returning the year
228+
prior to the start_date and year following the end_date. This ensures
229+
that any offsets to be applied will yield the holidays within
230+
the passed in dates.
231+
"""
232+
if self.start_date is not None:
233+
start_date = self.start_date.tz_localize(start_date.tz)
234+
235+
if self.end_date is not None:
236+
end_date = self.end_date.tz_localize(start_date.tz)
237+
238+
year_offset = DateOffset(years=1)
239+
reference_start_date = Timestamp(
240+
datetime(start_date.year-1, self.month, self.day))
241+
242+
reference_end_date = Timestamp(
243+
datetime(end_date.year+1, self.month, self.day))
244+
# Don't process unnecessary holidays
245+
dates = DatetimeIndex(start=reference_start_date, end=reference_end_date,
246+
freq=year_offset, tz=start_date.tz)
247+
248+
return dates
224249

225250
def _apply_rule(self, dates):
226251
"""
227-
Apply the given offset/observance to an
228-
iterable of dates.
252+
Apply the given offset/observance to a DatetimeIndex of dates.
229253
230254
Parameters
231255
----------
232-
dates : array-like
256+
dates : DatetimeIndex
233257
Dates to apply the given offset/observance rule
234258
235259
Returns
236260
-------
237261
Dates with rules applied
238262
"""
239263
if self.observance is not None:
240-
return map(lambda d: self.observance(d), dates)
264+
return dates.map(lambda d: self.observance(d))
241265

242266
if self.offset is not None:
243267
if not isinstance(self.offset, list):
244268
offsets = [self.offset]
245269
else:
246270
offsets = self.offset
247271
for offset in offsets:
248-
dates = list(map(lambda d: d + offset, dates))
272+
dates += offset
249273
return dates
250274

251275
holiday_calendars = {}
@@ -303,6 +327,13 @@ def __init__(self, name=None, rules=None):
303327

304328
if rules is not None:
305329
self.rules = rules
330+
331+
def rule_from_name(self, name):
332+
for rule in self.rules:
333+
if rule.name == name:
334+
return rule
335+
336+
return None
306337

307338
def holidays(self, start=None, end=None, return_name=False):
308339
"""

pandas/tseries/tests/test_holiday.py

+66-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11

22
from datetime import datetime
33
import pandas.util.testing as tm
4+
from pandas import compat
45
from pandas import DatetimeIndex
56
from pandas.tseries.holiday import (
6-
USFederalHolidayCalendar, USMemorialDay, USThanksgivingDay,
7+
USFederalHolidayCalendar, USMemorialDay, USThanksgivingDay,
78
nearest_workday, next_monday_or_tuesday, next_monday,
89
previous_friday, sunday_to_monday, Holiday, DateOffset,
910
MO, Timestamp, AbstractHolidayCalendar, get_calendar,
1011
HolidayCalendarFactory, next_workday, previous_workday,
1112
before_nearest_workday, EasterMonday, GoodFriday,
12-
after_nearest_workday, weekend_to_monday)
13+
after_nearest_workday, weekend_to_monday, USLaborDay,
14+
USColumbusDay, USMartinLutherKingJr, USPresidentsDay)
1315
from pytz import utc
1416
import nose
1517

@@ -72,7 +74,20 @@ def __init__(self, name=None, rules=None):
7274
jan2.holidays(),
7375
DatetimeIndex(['02-Jan-2015'])
7476
)
75-
77+
78+
def test_calendar_observance_dates(self):
79+
# Test for issue 11477
80+
USFedCal = get_calendar('USFederalHolidayCalendar')
81+
holidays0 = USFedCal.holidays(datetime(2015,7,3), datetime(2015,7,3)) # <-- same start and end dates
82+
holidays1 = USFedCal.holidays(datetime(2015,7,3), datetime(2015,7,6)) # <-- different start and end dates
83+
holidays2 = USFedCal.holidays(datetime(2015,7,3), datetime(2015,7,3)) # <-- same start and end dates
84+
85+
tm.assert_index_equal(holidays0, holidays1)
86+
tm.assert_index_equal(holidays0, holidays2)
87+
88+
def test_rule_from_name(self):
89+
USFedCal = get_calendar('USFederalHolidayCalendar')
90+
self.assertEqual(USFedCal.rule_from_name('Thanksgiving'), USThanksgivingDay)
7691

7792
class TestHoliday(tm.TestCase):
7893

@@ -193,6 +208,52 @@ def test_usthanksgivingday(self):
193208
datetime(2020, 11, 26),
194209
],
195210
)
211+
212+
def test_holidays_within_dates(self):
213+
# Fix holiday behavior found in #11477
214+
# where holiday.dates returned dates outside start/end date
215+
# or observed rules could not be applied as the holiday
216+
# was not in the original date range (e.g., 7/4/2015 -> 7/3/2015)
217+
start_date = datetime(2015, 7, 1)
218+
end_date = datetime(2015, 7, 1)
219+
220+
calendar = get_calendar('USFederalHolidayCalendar')
221+
new_years = calendar.rule_from_name('New Years Day')
222+
july_4th = calendar.rule_from_name('July 4th')
223+
veterans_day = calendar.rule_from_name('Veterans Day')
224+
christmas = calendar.rule_from_name('Christmas')
225+
226+
# Holiday: (start/end date, holiday)
227+
holidays = {USMemorialDay: ("2015-05-25", "2015-05-25"),
228+
USLaborDay: ("2015-09-07", "2015-09-07"),
229+
USColumbusDay: ("2015-10-12", "2015-10-12"),
230+
USThanksgivingDay: ("2015-11-26", "2015-11-26"),
231+
USMartinLutherKingJr: ("2015-01-19", "2015-01-19"),
232+
USPresidentsDay: ("2015-02-16", "2015-02-16"),
233+
GoodFriday: ("2015-04-03", "2015-04-03"),
234+
EasterMonday: [("2015-04-06", "2015-04-06"),
235+
("2015-04-05", [])],
236+
new_years: [("2015-01-01", "2015-01-01"),
237+
("2011-01-01", []),
238+
("2010-12-31", "2010-12-31")],
239+
july_4th: [("2015-07-03", "2015-07-03"),
240+
("2015-07-04", [])],
241+
veterans_day: [("2012-11-11", []),
242+
("2012-11-12", "2012-11-12")],
243+
christmas: [("2011-12-25", []),
244+
("2011-12-26", "2011-12-26")]}
245+
246+
for rule, dates in compat.iteritems(holidays):
247+
empty_dates = rule.dates(start_date, end_date)
248+
self.assertEqual(empty_dates.tolist(), [])
249+
250+
if isinstance(dates, tuple):
251+
dates = [dates]
252+
253+
for start, expected in dates:
254+
if len(expected):
255+
expected = [Timestamp(expected)]
256+
self.check_results(rule, start, start, expected)
196257

197258
def test_argument_types(self):
198259
holidays = USThanksgivingDay.dates(self.start_date,
@@ -206,8 +267,8 @@ def test_argument_types(self):
206267
Timestamp(self.start_date),
207268
Timestamp(self.end_date))
208269

209-
self.assertEqual(holidays, holidays_1)
210-
self.assertEqual(holidays, holidays_2)
270+
self.assert_index_equal(holidays, holidays_1)
271+
self.assert_index_equal(holidays, holidays_2)
211272

212273
def test_special_holidays(self):
213274
base_date = [datetime(2012, 5, 28)]

0 commit comments

Comments
 (0)