Skip to content

Fix bugs in FY5253Quarter and LastWeekOfMonth #19036

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

Merged
merged 7 commits into from
Jan 4, 2018
Merged
Show file tree
Hide file tree
Changes from 4 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/v0.23.0.txt
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ Conversion
- Bug in :class:`FY5253` where ``datetime`` addition and subtraction incremented incorrectly for dates on the year-end but not normalized to midnight (:issue:`18854`)
- 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`)
- Bug in :class:`Series` floor-division where operating on a scalar ``timedelta`` raises an exception (:issue:`18846`)
- Bug in :class:`FY5253Quarter`, :class:`LastWeekOfMonth` where rollback and rollforward behavior was inconsistent with addition and subtraction behavior (:issue:`18854`)


Indexing
Expand Down
20 changes: 20 additions & 0 deletions pandas/tests/tseries/offsets/test_fiscal.py
Original file line number Diff line number Diff line change
Expand Up @@ -633,3 +633,23 @@ def test_fy5253_nearest_onoffset():
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow


def test_fy5253qtr_onoffset_nearest():
ts = Timestamp('1985-09-02 23:57:46.232550356-0300',
Copy link
Member

Choose a reason for hiding this comment

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

Reference your PR as a comment. Same for all other tests that you added.

tz='Atlantic/Bermuda')
offset = FY5253Quarter(n=3, qtr_with_extra_week=1, startingMonth=2,
variation="nearest", weekday=0)
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow


def test_fy5253qtr_onoffset_last():
offset = FY5253Quarter(n=-2, qtr_with_extra_week=1,
startingMonth=7, variation="last", weekday=2)
ts = Timestamp('2011-01-26 19:03:40.331096129+0200',
tz='Africa/Windhoek')
slow = (ts + offset) - offset == ts
fast = offset.onOffset(ts)
assert fast == slow
18 changes: 18 additions & 0 deletions pandas/tests/tseries/offsets/test_offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -3153,3 +3153,21 @@ def test_weekofmonth_onoffset():
fast = offset.onOffset(ts)
slow = (ts + offset) - offset == ts
assert fast == slow


def test_last_week_of_month_on_offset():
# GH#18977 _adjust_dst was incorrect for LastWeekOfMonth
offset = LastWeekOfMonth(n=4, weekday=6)
ts = Timestamp('1917-05-27 20:55:27.084284178+0200',
tz='Europe/Warsaw')
slow = (ts + offset) - offset == ts
fast = offset.onOffset(ts)
assert fast == slow

# negative n
offset = LastWeekOfMonth(n=-4, weekday=5)
ts = Timestamp('2005-08-27 05:01:42.799392561-0500',
tz='America/Rainy_River')
slow = (ts + offset) - offset == ts
fast = offset.onOffset(ts)
assert fast == slow
159 changes: 89 additions & 70 deletions pandas/tseries/offsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,7 @@ class LastWeekOfMonth(_WeekOfMonthMixin, DateOffset):

"""
_prefix = 'LWOM'
_adjust_dst = True

def __init__(self, n=1, normalize=False, weekday=None):
self.n = self._validate_n(n)
Expand Down Expand Up @@ -1715,10 +1716,7 @@ class YearBegin(YearOffset):
# ---------------------------------------------------------------------
# Special Offset Classes

class FY5253(DateOffset):
"""
Describes 52-53 week fiscal year. This is also known as a 4-4-5 calendar.

_5253doc = """
It is used by companies that desire that their
fiscal year always end on the same day of the week.

Expand All @@ -1727,15 +1725,22 @@ class FY5253(DateOffset):
such as retail, manufacturing and parking industry.

For more information see:
http://en.wikipedia.org/wiki/4%E2%80%934%E2%80%935_calendar

http://en.wikipedia.org/wiki/4-4-5_calendar

The year may either:
- end on the last X day of the Y month.
- end on the last X day closest to the last day of the Y month.

X is a specific day of the week.
Y is a certain month of the year
"""


class FY5253(DateOffset):
__doc__ = """
Describes 52-53 week fiscal year. This is also known as a 4-4-5 calendar.

%s

Parameters
----------
Expand All @@ -1751,7 +1756,7 @@ class FY5253(DateOffset):
startingMonth : The month in which fiscal years end. {1, 2, ... 12}
variation : str
{"nearest", "last"} for "LastOfMonth" or "NearestEndMonth"
"""
""" % _5253doc
_prefix = 'RE'
_adjust_dst = True

Expand Down Expand Up @@ -1910,26 +1915,11 @@ def _from_name(cls, *args):


class FY5253Quarter(DateOffset):
"""
__doc__ = """
DateOffset increments between business quarter dates
for 52-53 week fiscal year (also known as a 4-4-5 calendar).

It is used by companies that desire that their
fiscal year always end on the same day of the week.

It is a method of managing accounting periods.
It is a common calendar structure for some industries,
such as retail, manufacturing and parking industry.

For more information see:
http://en.wikipedia.org/wiki/4%E2%80%934%E2%80%935_calendar

The year may either:
- end on the last X day of the Y month.
- end on the last X day closest to the last day of the Y month.

X is a specific day of the week.
Y is a certain month of the year
%s

startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
Expand All @@ -1951,7 +1941,7 @@ class FY5253Quarter(DateOffset):
or 14 week when needed. {1,2,3,4}
variation : str
{"nearest", "last"} for "LastOfMonth" or "NearestEndMonth"
"""
""" % _5253doc
Copy link
Contributor

Choose a reason for hiding this comment

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

can you use .format()

Copy link
Contributor

Choose a reason for hiding this comment

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

and same on all the templates (generally we want to always use the .format())

Copy link
Member Author

Choose a reason for hiding this comment

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

That was the first attempt. That syntax doesn't play nicely with the set syntax inside the docstring. The docstring change here is tangential to the PR; I'll just revert it.


_prefix = 'REQ'
_adjust_dst = True
Expand Down Expand Up @@ -1982,46 +1972,76 @@ def _offset(self):
def isAnchored(self):
return self.n == 1 and self._offset.isAnchored()

def _rollback_to_year(self, other):
"""roll `other` back to the most recent date that was on a fiscal year
end. Return the date of that year-end, the number of full quarters
elapsed between that year-end and other, and the remaining Timedelta
since the most recent quarter-end.

Parameters
----------
other : datetime or Timestamp

Returns
-------
prev_year_end : Timestamp giving most recent fiscal year end
Copy link
Contributor

Choose a reason for hiding this comment

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

tuple of

num_qtrs : int
tdelta : Timedelta
"""
num_qtrs = 0

norm = Timestamp(other).tz_localize(None)
start = self._offset.rollback(norm)
# Note: start <= norm and self._offset.onOffset(start)

if start < norm:
# roll adjustment
qtr_lens = self.get_weeks(norm)

# check thet qtr_lens is consistent with self._offset addition
end = shift_day(start, days=7 * sum(qtr_lens))
assert self._offset.onOffset(end), (start, end, qtr_lens)

tdelta = norm - start
for qlen in qtr_lens:
if qlen * 7 <= tdelta.days:
num_qtrs += 1
tdelta -= Timedelta(days=qlen * 7)
else:
break
else:
tdelta = Timedelta(0)

# Note: we always have tdelta.value >= 0
return start, num_qtrs, tdelta

@apply_wraps
def apply(self, other):
base = other
# Note: self.n == 0 is not allowed.
Copy link
Contributor

Choose a reason for hiding this comment

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

you allow this below

Copy link
Member Author

Choose a reason for hiding this comment

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

This is enforced in init.

n = self.n

if n > 0:
while n > 0:
if not self._offset.onOffset(other):
qtr_lens = self.get_weeks(other)
start = other - self._offset
else:
start = other
qtr_lens = self.get_weeks(other + self._offset)
prev_year_end, num_qtrs, tdelta = self._rollback_to_year(other)
res = prev_year_end
n += num_qtrs
if self.n <= 0 and tdelta.value > 0:
n += 1

for weeks in qtr_lens:
start += timedelta(weeks=weeks)
if start > other:
other = start
n -= 1
break
# Possible speedup by handling years first.
years = n // 4
if years:
res += self._offset * years
n -= years * 4

else:
n = -n
while n > 0:
if not self._offset.onOffset(other):
qtr_lens = self.get_weeks(other)
end = other + self._offset
else:
end = other
qtr_lens = self.get_weeks(other)

for weeks in reversed(qtr_lens):
end -= timedelta(weeks=weeks)
if end < other:
other = end
n -= 1
break
other = datetime(other.year, other.month, other.day,
base.hour, base.minute, base.second, base.microsecond)
return other
# Add an extra day to make *sure* we are getting the quarter lengths
# for the upcoming year, not the previous year
qtr_lens = self.get_weeks(res + Timedelta(days=1))

# Note: we always have 0 <= n < 4
weeks = sum(qtr_lens[:n])
if weeks:
res = shift_day(res, days=weeks * 7)

return res

def get_weeks(self, dt):
ret = [13] * 4
Expand All @@ -2034,16 +2054,15 @@ def get_weeks(self, dt):
return ret

def year_has_extra_week(self, dt):
if self._offset.onOffset(dt):
prev_year_end = dt - self._offset
next_year_end = dt
else:
next_year_end = dt + self._offset
prev_year_end = dt - self._offset

week_in_year = (next_year_end - prev_year_end).days / 7
# Avoid round-down errors --> normalize to get
# e.g. '370D' instead of '360D23H'
norm = Timestamp(dt).normalize().tz_localize(None)

return week_in_year == 53
next_year_end = self._offset.rollforward(norm)
prev_year_end = norm - self._offset
weeks_in_year = (next_year_end - prev_year_end).days / 7
assert weeks_in_year in [52, 53], weeks_in_year
return weeks_in_year == 53

def onOffset(self, dt):
if self.normalize and not _is_normalized(dt):
Expand All @@ -2056,8 +2075,8 @@ def onOffset(self, dt):
qtr_lens = self.get_weeks(dt)

current = next_year_end
for qtr_len in qtr_lens[0:4]:
current += timedelta(weeks=qtr_len)
for qtr_len in qtr_lens:
current = shift_day(current, days=qtr_len * 7)
if dt == current:
return True
return False
Expand Down