Skip to content

Commit f911765

Browse files
jbrockmendeljreback
authored andcommitted
Fix bugs in FY5253Quarter and LastWeekOfMonth (#19036)
1 parent 9303315 commit f911765

File tree

4 files changed

+119
-48
lines changed

4 files changed

+119
-48
lines changed

doc/source/whatsnew/v0.23.0.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,7 @@ Conversion
307307
- Bug in :class:`FY5253` where ``datetime`` addition and subtraction incremented incorrectly for dates on the year-end but not normalized to midnight (:issue:`18854`)
308308
- Bug in :class:`DatetimeIndex` where adding or subtracting an array-like of ``DateOffset`` objects either raised (``np.array``, ``pd.Index``) or broadcast incorrectly (``pd.Series``) (:issue:`18849`)
309309
- Bug in :class:`Series` floor-division where operating on a scalar ``timedelta`` raises an exception (:issue:`18846`)
310+
- Bug in :class:`FY5253Quarter`, :class:`LastWeekOfMonth` where rollback and rollforward behavior was inconsistent with addition and subtraction behavior (:issue:`18854`)
310311
- Bug in :class:`Index` constructor with ``dtype=CategoricalDtype(...)`` where ``categories`` and ``ordered`` are not maintained (issue:`19032`)
311312

312313

pandas/tests/tseries/offsets/test_fiscal.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -633,3 +633,25 @@ def test_fy5253_nearest_onoffset():
633633
fast = offset.onOffset(ts)
634634
slow = (ts + offset) - offset == ts
635635
assert fast == slow
636+
637+
638+
def test_fy5253qtr_onoffset_nearest():
639+
# GH#19036
640+
ts = Timestamp('1985-09-02 23:57:46.232550356-0300',
641+
tz='Atlantic/Bermuda')
642+
offset = FY5253Quarter(n=3, qtr_with_extra_week=1, startingMonth=2,
643+
variation="nearest", weekday=0)
644+
fast = offset.onOffset(ts)
645+
slow = (ts + offset) - offset == ts
646+
assert fast == slow
647+
648+
649+
def test_fy5253qtr_onoffset_last():
650+
# GH#19036
651+
offset = FY5253Quarter(n=-2, qtr_with_extra_week=1,
652+
startingMonth=7, variation="last", weekday=2)
653+
ts = Timestamp('2011-01-26 19:03:40.331096129+0200',
654+
tz='Africa/Windhoek')
655+
slow = (ts + offset) - offset == ts
656+
fast = offset.onOffset(ts)
657+
assert fast == slow

pandas/tests/tseries/offsets/test_offsets.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3153,3 +3153,21 @@ def test_weekofmonth_onoffset():
31533153
fast = offset.onOffset(ts)
31543154
slow = (ts + offset) - offset == ts
31553155
assert fast == slow
3156+
3157+
3158+
def test_last_week_of_month_on_offset():
3159+
# GH#19036, GH#18977 _adjust_dst was incorrect for LastWeekOfMonth
3160+
offset = LastWeekOfMonth(n=4, weekday=6)
3161+
ts = Timestamp('1917-05-27 20:55:27.084284178+0200',
3162+
tz='Europe/Warsaw')
3163+
slow = (ts + offset) - offset == ts
3164+
fast = offset.onOffset(ts)
3165+
assert fast == slow
3166+
3167+
# negative n
3168+
offset = LastWeekOfMonth(n=-4, weekday=5)
3169+
ts = Timestamp('2005-08-27 05:01:42.799392561-0500',
3170+
tz='America/Rainy_River')
3171+
slow = (ts + offset) - offset == ts
3172+
fast = offset.onOffset(ts)
3173+
assert fast == slow

pandas/tseries/offsets.py

Lines changed: 78 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1468,6 +1468,7 @@ class LastWeekOfMonth(_WeekOfMonthMixin, DateOffset):
14681468
14691469
"""
14701470
_prefix = 'LWOM'
1471+
_adjust_dst = True
14711472

14721473
def __init__(self, n=1, normalize=False, weekday=None):
14731474
self.n = self._validate_n(n)
@@ -1727,8 +1728,7 @@ class FY5253(DateOffset):
17271728
such as retail, manufacturing and parking industry.
17281729
17291730
For more information see:
1730-
http://en.wikipedia.org/wiki/4%E2%80%934%E2%80%935_calendar
1731-
1731+
http://en.wikipedia.org/wiki/4-4-5_calendar
17321732
17331733
The year may either:
17341734
- end on the last X day of the Y month.
@@ -1922,7 +1922,7 @@ class FY5253Quarter(DateOffset):
19221922
such as retail, manufacturing and parking industry.
19231923
19241924
For more information see:
1925-
http://en.wikipedia.org/wiki/4%E2%80%934%E2%80%935_calendar
1925+
http://en.wikipedia.org/wiki/4-4-5_calendar
19261926
19271927
The year may either:
19281928
- end on the last X day of the Y month.
@@ -1982,46 +1982,77 @@ def _offset(self):
19821982
def isAnchored(self):
19831983
return self.n == 1 and self._offset.isAnchored()
19841984

1985+
def _rollback_to_year(self, other):
1986+
"""roll `other` back to the most recent date that was on a fiscal year
1987+
end. Return the date of that year-end, the number of full quarters
1988+
elapsed between that year-end and other, and the remaining Timedelta
1989+
since the most recent quarter-end.
1990+
1991+
Parameters
1992+
----------
1993+
other : datetime or Timestamp
1994+
1995+
Returns
1996+
-------
1997+
tuple of
1998+
prev_year_end : Timestamp giving most recent fiscal year end
1999+
num_qtrs : int
2000+
tdelta : Timedelta
2001+
"""
2002+
num_qtrs = 0
2003+
2004+
norm = Timestamp(other).tz_localize(None)
2005+
start = self._offset.rollback(norm)
2006+
# Note: start <= norm and self._offset.onOffset(start)
2007+
2008+
if start < norm:
2009+
# roll adjustment
2010+
qtr_lens = self.get_weeks(norm)
2011+
2012+
# check thet qtr_lens is consistent with self._offset addition
2013+
end = shift_day(start, days=7 * sum(qtr_lens))
2014+
assert self._offset.onOffset(end), (start, end, qtr_lens)
2015+
2016+
tdelta = norm - start
2017+
for qlen in qtr_lens:
2018+
if qlen * 7 <= tdelta.days:
2019+
num_qtrs += 1
2020+
tdelta -= Timedelta(days=qlen * 7)
2021+
else:
2022+
break
2023+
else:
2024+
tdelta = Timedelta(0)
2025+
2026+
# Note: we always have tdelta.value >= 0
2027+
return start, num_qtrs, tdelta
2028+
19852029
@apply_wraps
19862030
def apply(self, other):
1987-
base = other
2031+
# Note: self.n == 0 is not allowed.
19882032
n = self.n
19892033

1990-
if n > 0:
1991-
while n > 0:
1992-
if not self._offset.onOffset(other):
1993-
qtr_lens = self.get_weeks(other)
1994-
start = other - self._offset
1995-
else:
1996-
start = other
1997-
qtr_lens = self.get_weeks(other + self._offset)
2034+
prev_year_end, num_qtrs, tdelta = self._rollback_to_year(other)
2035+
res = prev_year_end
2036+
n += num_qtrs
2037+
if self.n <= 0 and tdelta.value > 0:
2038+
n += 1
19982039

1999-
for weeks in qtr_lens:
2000-
start += timedelta(weeks=weeks)
2001-
if start > other:
2002-
other = start
2003-
n -= 1
2004-
break
2040+
# Possible speedup by handling years first.
2041+
years = n // 4
2042+
if years:
2043+
res += self._offset * years
2044+
n -= years * 4
20052045

2006-
else:
2007-
n = -n
2008-
while n > 0:
2009-
if not self._offset.onOffset(other):
2010-
qtr_lens = self.get_weeks(other)
2011-
end = other + self._offset
2012-
else:
2013-
end = other
2014-
qtr_lens = self.get_weeks(other)
2015-
2016-
for weeks in reversed(qtr_lens):
2017-
end -= timedelta(weeks=weeks)
2018-
if end < other:
2019-
other = end
2020-
n -= 1
2021-
break
2022-
other = datetime(other.year, other.month, other.day,
2023-
base.hour, base.minute, base.second, base.microsecond)
2024-
return other
2046+
# Add an extra day to make *sure* we are getting the quarter lengths
2047+
# for the upcoming year, not the previous year
2048+
qtr_lens = self.get_weeks(res + Timedelta(days=1))
2049+
2050+
# Note: we always have 0 <= n < 4
2051+
weeks = sum(qtr_lens[:n])
2052+
if weeks:
2053+
res = shift_day(res, days=weeks * 7)
2054+
2055+
return res
20252056

20262057
def get_weeks(self, dt):
20272058
ret = [13] * 4
@@ -2034,16 +2065,15 @@ def get_weeks(self, dt):
20342065
return ret
20352066

20362067
def year_has_extra_week(self, dt):
2037-
if self._offset.onOffset(dt):
2038-
prev_year_end = dt - self._offset
2039-
next_year_end = dt
2040-
else:
2041-
next_year_end = dt + self._offset
2042-
prev_year_end = dt - self._offset
2043-
2044-
week_in_year = (next_year_end - prev_year_end).days / 7
2068+
# Avoid round-down errors --> normalize to get
2069+
# e.g. '370D' instead of '360D23H'
2070+
norm = Timestamp(dt).normalize().tz_localize(None)
20452071

2046-
return week_in_year == 53
2072+
next_year_end = self._offset.rollforward(norm)
2073+
prev_year_end = norm - self._offset
2074+
weeks_in_year = (next_year_end - prev_year_end).days / 7
2075+
assert weeks_in_year in [52, 53], weeks_in_year
2076+
return weeks_in_year == 53
20472077

20482078
def onOffset(self, dt):
20492079
if self.normalize and not _is_normalized(dt):
@@ -2056,8 +2086,8 @@ def onOffset(self, dt):
20562086
qtr_lens = self.get_weeks(dt)
20572087

20582088
current = next_year_end
2059-
for qtr_len in qtr_lens[0:4]:
2060-
current += timedelta(weeks=qtr_len)
2089+
for qtr_len in qtr_lens:
2090+
current = shift_day(current, days=qtr_len * 7)
20612091
if dt == current:
20622092
return True
20632093
return False

0 commit comments

Comments
 (0)