diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 933e7ed64b837..d3278e42e413f 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -523,11 +523,9 @@ def shift_quarters(int64_t[:] dtindex, int quarters, n = quarters months_since = (dts.month - q1start_month) % modby - compare_month = dts.month - months_since - compare_month = compare_month or 12 # compare_day is only relevant for comparison in the case # where months_since == 0. - compare_day = get_firstbday(dts.year, compare_month) + compare_day = get_firstbday(dts.year, dts.month) if n <= 0 and (months_since != 0 or (months_since == 0 and dts.day > compare_day)): @@ -556,11 +554,9 @@ def shift_quarters(int64_t[:] dtindex, int quarters, n = quarters months_since = (dts.month - q1start_month) % modby - compare_month = dts.month - months_since - compare_month = compare_month or 12 # compare_day is only relevant for comparison in the case # where months_since == 0. - compare_day = get_lastbday(dts.year, compare_month) + compare_day = get_lastbday(dts.year, dts.month) if n <= 0 and (months_since != 0 or (months_since == 0 and dts.day > compare_day)): @@ -827,7 +823,55 @@ cpdef int get_day_of_month(datetime other, day_opt) except? -1: raise ValueError(day_opt) -cpdef int roll_yearday(other, n, month, day_opt='start') except? -1: +cpdef int roll_convention(int other, int n, int compare): + """ + Possibly increment or decrement the number of periods to shift + based on rollforward/rollbackward conventions. + + Parameters + ---------- + other : int, generally the day component of a datetime + n : number of periods to increment, before adjusting for rolling + compare : int, generally the day component of a datetime, in the same + month as the datetime form which `other` was taken. + + Returns + ------- + n : int number of periods to increment + """ + if n > 0 and other < compare: + n -= 1 + elif n <= 0 and other > compare: + # as if rolled forward already + n += 1 + return n + + +cpdef int roll_monthday(datetime other, int n, datetime compare): + """ + Possibly increment or decrement the number of periods to shift + based on rollforward/rollbackward conventions. + + Parameters + ---------- + other : datetime + n : number of periods to increment, before adjusting for rolling + compare : datetime + + Returns + ------- + n : int number of periods to increment + """ + if n > 0 and other < compare: + n -= 1 + elif n <= 0 and other > compare: + # as if rolled forward already + n += 1 + return n + + +cpdef int roll_qtrday(datetime other, int n, int month, object day_opt, + int modby=3) except? -1: """ Possibly increment or decrement the number of periods to shift based on rollforward/rollbackward conventions. @@ -836,6 +880,48 @@ cpdef int roll_yearday(other, n, month, day_opt='start') except? -1: ---------- other : datetime or Timestamp n : number of periods to increment, before adjusting for rolling + month : int reference month giving the first month of the year + day_opt : 'start', 'end', 'business_start', 'business_end' + The convention to use in finding the day in a given month against + which to compare for rollforward/rollbackward decisions. + modby : int 3 for quarters, 12 for years + + Returns + ------- + n : int number of periods to increment + """ + # TODO: Merge this with roll_yearday by setting modby=12 there? + # code de-duplication versus perf hit? + # TODO: with small adjustments this could be used in shift_quarters + months_since = other.month % modby - month % modby + + if n > 0: + if months_since < 0 or (months_since == 0 and + other.day < get_day_of_month(other, + day_opt)): + # pretend to roll back if on same month but + # before compare_day + n -= 1 + else: + if months_since > 0 or (months_since == 0 and + other.day > get_day_of_month(other, + day_opt)): + # make sure to roll forward, so negate + n += 1 + return n + + +cpdef int roll_yearday(datetime other, int n, int month, + object day_opt) except? -1: + """ + Possibly increment or decrement the number of periods to shift + based on rollforward/rollbackward conventions. + + Parameters + ---------- + other : datetime or Timestamp + n : number of periods to increment, before adjusting for rolling + month : reference month giving the first month of the year day_opt : 'start', 'end' 'start': returns 1 'end': returns last day of the month @@ -846,7 +932,7 @@ cpdef int roll_yearday(other, n, month, day_opt='start') except? -1: Notes ----- - * Mirrors `roll_check` in tslib.shift_months + * Mirrors `roll_check` in shift_months Examples ------- @@ -888,7 +974,7 @@ cpdef int roll_yearday(other, n, month, day_opt='start') except? -1: other.day < get_day_of_month(other, day_opt)): n -= 1 - elif n <= 0: + else: if other.month > month or (other.month == month and other.day > get_day_of_month(other, day_opt)): diff --git a/pandas/tests/tseries/offsets/test_liboffsets.py b/pandas/tests/tseries/offsets/test_liboffsets.py index 8aa32bc600ee6..1e0ecc39084eb 100644 --- a/pandas/tests/tseries/offsets/test_liboffsets.py +++ b/pandas/tests/tseries/offsets/test_liboffsets.py @@ -9,6 +9,7 @@ from pandas import Timestamp import pandas._libs.tslibs.offsets as liboffsets +from pandas._libs.tslibs.offsets import roll_qtrday def test_get_lastbday(): @@ -95,3 +96,93 @@ def test_roll_yearday(): assert liboffsets.roll_yearday(other, 5, month, day_opt) == 5 assert liboffsets.roll_yearday(other, -7, month, day_opt) == -6 assert liboffsets.roll_yearday(other, 0, month, day_opt) == 1 + + +def test_roll_qtrday(): + other = Timestamp(2072, 10, 1, 6, 17, 18) # Saturday + for day_opt in ['start', 'end', 'business_start', 'business_end']: + # as long as (other.month % 3) != (month % 3), day_opt is irrelevant + # the `day_opt` doesn't matter. + month = 5 # (other.month % 3) < (month % 3) + assert roll_qtrday(other, 4, month, day_opt, modby=3) == 3 + assert roll_qtrday(other, -3, month, day_opt, modby=3) == -3 + + month = 3 # (other.month % 3) > (month % 3) + assert roll_qtrday(other, 4, month, day_opt, modby=3) == 4 + assert roll_qtrday(other, -3, month, day_opt, modby=3) == -2 + + month = 2 + other = datetime(1999, 5, 31) # Monday + # has (other.month % 3) == (month % 3) + + n = 2 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + assert roll_qtrday(other, n, month, 'end', modby=3) == n + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n + + n = -1 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + 1 + assert roll_qtrday(other, n, month, 'end', modby=3) == n + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n + 1 + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n + + other = Timestamp(2072, 10, 1, 6, 17, 18) # Saturday + month = 4 # (other.month % 3) == (month % 3) + n = 2 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + assert roll_qtrday(other, n, month, 'end', modby=3) == n - 1 + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n - 1 + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n - 1 + + n = -1 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + assert roll_qtrday(other, n, month, 'end', modby=3) == n + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n + + other = Timestamp(2072, 10, 3, 6, 17, 18) # First businessday + month = 4 # (other.month % 3) == (month % 3) + n = 2 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + assert roll_qtrday(other, n, month, 'end', modby=3) == n - 1 + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n - 1 + + n = -1 + assert roll_qtrday(other, n, month, 'start', modby=3) == n + 1 + assert roll_qtrday(other, n, month, 'end', modby=3) == n + assert roll_qtrday(other, n, month, 'business_start', modby=3) == n + assert roll_qtrday(other, n, month, 'business_end', modby=3) == n + + +def test_roll_monthday(): + other = Timestamp('2017-12-29', tz='US/Pacific') + before = Timestamp('2017-12-01', tz='US/Pacific') + after = Timestamp('2017-12-31', tz='US/Pacific') + + n = 42 + assert liboffsets.roll_monthday(other, n, other) == n + assert liboffsets.roll_monthday(other, n, before) == n + assert liboffsets.roll_monthday(other, n, after) == n - 1 + + n = -4 + assert liboffsets.roll_monthday(other, n, other) == n + assert liboffsets.roll_monthday(other, n, before) == n + 1 + assert liboffsets.roll_monthday(other, n, after) == n + + +def test_roll_convention(): + other = 29 + before = 1 + after = 31 + + n = 42 + assert liboffsets.roll_convention(other, n, other) == n + assert liboffsets.roll_convention(other, n, before) == n + assert liboffsets.roll_convention(other, n, after) == n - 1 + + n = -4 + assert liboffsets.roll_convention(other, n, other) == n + assert liboffsets.roll_convention(other, n, before) == n + 1 + assert liboffsets.roll_convention(other, n, after) == n diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 0e6a2259274ed..4f3c24ba534ff 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -22,7 +22,7 @@ from pandas._libs.tslibs.offsets import ( ApplyTypeError, as_datetime, _is_normalized, - _get_calendar, _to_dt64, _validate_business_time, + _get_calendar, _to_dt64, _determine_offset, apply_index_wraps, roll_yearday, @@ -557,28 +557,31 @@ def get_str(td): def apply(self, other): if isinstance(other, datetime): n = self.n + wday = other.weekday() - if n == 0 and other.weekday() > 4: - n = 1 - - result = other - - # avoid slowness below - if abs(n) > 5: - k = n // 5 - result = result + timedelta(7 * k) - if n < 0 and result.weekday() > 4: - n += 1 - n -= 5 * k - if n == 0 and result.weekday() > 4: - n -= 1 + # avoid slowness below by operating on weeks first + weeks = n // 5 + if n <= 0 and wday > 4: + # roll forward + n += 1 - while n != 0: - k = n // abs(n) - result = result + timedelta(k) - if result.weekday() < 5: - n -= k + n -= 5 * weeks + + # n is always >= 0 at this point + if n == 0 and wday > 4: + # roll back + days = 4 - wday + elif wday > 4: + # roll forward + days = (7 - wday) + (n - 1) + elif wday + n <= 4: + # shift by n days without leaving the current week + days = n + else: + # shift by n days plus 2 to get past the weekend + days = n + 2 + result = other + timedelta(days=7 * weeks + days) if self.offset: result = result + self.offset return result @@ -614,8 +617,8 @@ class BusinessHourMixin(BusinessMixin): def __init__(self, start='09:00', end='17:00', offset=timedelta(0)): # must be validated here to equality check kwds = {'offset': offset} - self.start = kwds['start'] = _validate_business_time(start) - self.end = kwds['end'] = _validate_business_time(end) + self.start = kwds['start'] = liboffsets._validate_business_time(start) + self.end = kwds['end'] = liboffsets._validate_business_time(end) self.kwds.update(kwds) self._offset = offset @@ -969,15 +972,8 @@ def onOffset(self, dt): @apply_wraps def apply(self, other): - n = self.n compare_day = self._get_offset_day(other) - - if n > 0 and other.day < compare_day: - n -= 1 - elif n <= 0 and other.day > compare_day: - # as if rolled forward already - n += 1 - + n = liboffsets.roll_convention(other.day, self.n, compare_day) return shift_month(other, n, self._day_opt) @apply_index_wraps @@ -1063,22 +1059,12 @@ class CustomBusinessMonthEnd(_CustomBusinessMonth): @apply_wraps def apply(self, other): - n = self.n - # First move to month offset cur_mend = self.m_offset.rollforward(other) # Find this custom month offset - cur_cmend = self.cbday.rollback(cur_mend) - - # handle zero case. arbitrarily rollforward - if n == 0 and other != cur_cmend: - n += 1 - - if other < cur_cmend and n >= 1: - n -= 1 - elif other > cur_cmend and n <= -1: - n += 1 + compare_date = self.cbday.rollback(cur_mend) + n = liboffsets.roll_monthday(other, self.n, compare_date) new = cur_mend + n * self.m_offset result = self.cbday.rollback(new) @@ -1091,23 +1077,12 @@ class CustomBusinessMonthBegin(_CustomBusinessMonth): @apply_wraps def apply(self, other): - n = self.n - dt_in = other - # First move to month offset - cur_mbegin = self.m_offset.rollback(dt_in) + cur_mbegin = self.m_offset.rollback(other) # Find this custom month offset - cur_cmbegin = self.cbday.rollforward(cur_mbegin) - - # handle zero case. arbitrarily rollforward - if n == 0 and dt_in != cur_cmbegin: - n += 1 - - if dt_in > cur_cmbegin and n <= -1: - n += 1 - elif dt_in < cur_cmbegin and n >= 1: - n -= 1 + compare_date = self.cbday.rollforward(cur_mbegin) + n = liboffsets.roll_monthday(other, self.n, compare_date) new = cur_mbegin + n * self.m_offset result = self.cbday.rollforward(new) @@ -1147,21 +1122,21 @@ def rule_code(self): @apply_wraps def apply(self, other): - n = self.n - if not self.onOffset(other): - _, days_in_month = tslib.monthrange(other.year, other.month) - if 1 < other.day < self.day_of_month: - other = other.replace(day=self.day_of_month) - if n > 0: - # rollforward so subtract 1 - n -= 1 - elif self.day_of_month < other.day < days_in_month: - other = other.replace(day=self.day_of_month) - if n < 0: - # rollforward in the negative direction so add 1 - n += 1 - elif n == 0: - n = 1 + # shift `other` to self.day_of_month, incrementing `n` if necessary + n = liboffsets.roll_convention(other.day, self.n, self.day_of_month) + + days_in_month = tslib.monthrange(other.year, other.month)[1] + + # For SemiMonthBegin on other.day == 1 and + # SemiMonthEnd on other.day == days_in_month, + # shifting `other` to `self.day_of_month` _always_ requires + # incrementing/decrementing `n`, regardless of whether it is + # initially positive. + if type(self) is SemiMonthBegin and (self.n <= 0 and other.day == 1): + n -= 1 + elif type(self) is SemiMonthEnd and (self.n > 0 and + other.day == days_in_month): + n += 1 return self._apply(n, other) @@ -1231,12 +1206,6 @@ def onOffset(self, dt): return dt.day in (self.day_of_month, days_in_month) def _apply(self, n, other): - # if other.day is not day_of_month move to day_of_month and update n - if n > 0 and other.day < self.day_of_month: - n -= 1 - elif other.day > self.day_of_month: - n += 1 - months = n // 2 day = 31 if n % 2 else self.day_of_month return shift_month(other, months, day) @@ -1282,12 +1251,6 @@ def onOffset(self, dt): return dt.day in (1, self.day_of_month) def _apply(self, n, other): - # if other.day is not day_of_month move to day_of_month and update n - if other.day < self.day_of_month: - n -= 1 - elif n <= 0 and other.day > self.day_of_month: - n += 1 - months = n // 2 + n % 2 day = 1 if n % 2 else self.day_of_month return shift_month(other, months, day) @@ -1564,7 +1527,8 @@ class QuarterOffset(DateOffset): _from_name_startingMonth = None _adjust_dst = True # TODO: Consider combining QuarterOffset and YearOffset __init__ at some - # point + # point. Also apply_index, onOffset, rule_code if + # startingMonth vs month attr names are resolved def __init__(self, n=1, normalize=False, startingMonth=None): self.n = self._validate_n(n) @@ -1595,26 +1559,22 @@ def rule_code(self): @apply_wraps def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - months_since = (other.month - self.startingMonth) % 3 - - if n <= 0 and (months_since != 0 or - (months_since == 0 and other.day > compare_day)): - # make sure to roll forward, so negate - n += 1 - elif n > 0 and (months_since == 0 and other.day < compare_day): - # pretend to roll back if on same month but before compare_day - n -= 1 - - return shift_month(other, 3 * n - months_since, self._day_opt) + # months_since: find the calendar quarter containing other.month, + # e.g. if other.month == 8, the calendar quarter is [Jul, Aug, Sep]. + # Then find the month in that quarter containing an onOffset date for + # self. `months_since` is the number of months to shift other.month + # to get to this on-offset month. + months_since = other.month % 3 - self.startingMonth % 3 + qtrs = liboffsets.roll_qtrday(other, self.n, self.startingMonth, + day_opt=self._day_opt, modby=3) + months = qtrs * 3 - months_since + return shift_month(other, months, self._day_opt) def onOffset(self, dt): if self.normalize and not _is_normalized(dt): return False - modMonth = (dt.month - self.startingMonth) % 3 - return modMonth == 0 and dt.day == self._get_offset_day(dt) + mod_month = (dt.month - self.startingMonth) % 3 + return mod_month == 0 and dt.day == self._get_offset_day(dt) @apply_index_wraps def apply_index(self, dtindex): @@ -2142,6 +2102,7 @@ def apply(self, other): n -= 1 elif n < 0 and other > current_easter: n += 1 + # TODO: Why does this handle the 0 case the opposite of others? # NOTE: easter returns a datetime.date so we have to convert to type of # other