diff --git a/pandas/_libs/tslib.pyx b/pandas/_libs/tslib.pyx index e4c065202a6ef..cabd792d69477 100644 --- a/pandas/_libs/tslib.pyx +++ b/pandas/_libs/tslib.pyx @@ -1761,10 +1761,6 @@ def monthrange(int64_t year, int64_t month): return (dayofweek(year, month, 1), days) -cdef inline int days_in_month(pandas_datetimestruct dts) nogil: - return days_per_month_table[is_leapyear(dts.year)][dts.month - 1] - - cpdef normalize_date(object dt): """ Normalize datetime.datetime value to midnight. Returns datetime.date as a @@ -1783,104 +1779,3 @@ cpdef normalize_date(object dt): return datetime(dt.year, dt.month, dt.day) else: raise TypeError('Unrecognized type: %s' % type(dt)) - - -cdef inline int _year_add_months(pandas_datetimestruct dts, int months) nogil: - """new year number after shifting pandas_datetimestruct number of months""" - return dts.year + (dts.month + months - 1) / 12 - - -cdef inline int _month_add_months(pandas_datetimestruct dts, int months) nogil: - """ - New month number after shifting pandas_datetimestruct - number of months. - """ - cdef int new_month = (dts.month + months) % 12 - return 12 if new_month == 0 else new_month - - -@cython.wraparound(False) -@cython.boundscheck(False) -def shift_months(int64_t[:] dtindex, int months, object day=None): - """ - Given an int64-based datetime index, shift all elements - specified number of months using DateOffset semantics - - day: {None, 'start', 'end'} - * None: day of month - * 'start' 1st day of month - * 'end' last day of month - """ - cdef: - Py_ssize_t i - pandas_datetimestruct dts - int count = len(dtindex) - int months_to_roll - bint roll_check - int64_t[:] out = np.empty(count, dtype='int64') - - if day is None: - with nogil: - for i in range(count): - if dtindex[i] == NPY_NAT: - out[i] = NPY_NAT - continue - - dt64_to_dtstruct(dtindex[i], &dts) - dts.year = _year_add_months(dts, months) - dts.month = _month_add_months(dts, months) - - dts.day = min(dts.day, days_in_month(dts)) - out[i] = dtstruct_to_dt64(&dts) - elif day == 'start': - roll_check = False - if months <= 0: - months += 1 - roll_check = True - with nogil: - for i in range(count): - if dtindex[i] == NPY_NAT: - out[i] = NPY_NAT - continue - - dt64_to_dtstruct(dtindex[i], &dts) - months_to_roll = months - - # offset semantics - if on the anchor point and going backwards - # shift to next - if roll_check and dts.day == 1: - months_to_roll -= 1 - - dts.year = _year_add_months(dts, months_to_roll) - dts.month = _month_add_months(dts, months_to_roll) - dts.day = 1 - - out[i] = dtstruct_to_dt64(&dts) - elif day == 'end': - roll_check = False - if months > 0: - months -= 1 - roll_check = True - with nogil: - for i in range(count): - if dtindex[i] == NPY_NAT: - out[i] = NPY_NAT - continue - - dt64_to_dtstruct(dtindex[i], &dts) - months_to_roll = months - - # similar semantics - when adding shift forward by one - # month if already at an end of month - if roll_check and dts.day == days_in_month(dts): - months_to_roll += 1 - - dts.year = _year_add_months(dts, months_to_roll) - dts.month = _month_add_months(dts, months_to_roll) - - dts.day = days_in_month(dts) - out[i] = dtstruct_to_dt64(&dts) - else: - raise ValueError("day must be None, 'start' or 'end'") - - return np.asarray(out) diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index f38aca21a0438..526595e3a2eda 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -2,6 +2,7 @@ # cython: profile=False cimport cython +from cython cimport Py_ssize_t import time from cpython.datetime cimport datetime, timedelta, time as dt_time @@ -10,6 +11,7 @@ from dateutil.relativedelta import relativedelta import numpy as np cimport numpy as np +from numpy cimport int64_t np.import_array() @@ -19,6 +21,10 @@ from pandas._libs.tslib import monthrange from conversion cimport tz_convert_single, pydt_to_i8 from frequencies cimport get_freq_code +from nattype cimport NPY_NAT +from np_datetime cimport (pandas_datetimestruct, + dtstruct_to_dt64, dt64_to_dtstruct, + is_leapyear, days_per_month_table) # --------------------------------------------------------------------- # Constants @@ -419,13 +425,121 @@ class BaseOffset(_BaseOffset): # ---------------------------------------------------------------------- # RelativeDelta Arithmetic +@cython.wraparound(False) +@cython.boundscheck(False) +cdef inline int get_days_in_month(int year, int month) nogil: + return days_per_month_table[is_leapyear(year)][month - 1] + + +cdef inline int year_add_months(pandas_datetimestruct dts, int months) nogil: + """new year number after shifting pandas_datetimestruct number of months""" + return dts.year + (dts.month + months - 1) / 12 + + +cdef inline int month_add_months(pandas_datetimestruct dts, int months) nogil: + """ + New month number after shifting pandas_datetimestruct + number of months. + """ + cdef int new_month = (dts.month + months) % 12 + return 12 if new_month == 0 else new_month + + +@cython.wraparound(False) +@cython.boundscheck(False) +def shift_months(int64_t[:] dtindex, int months, object day=None): + """ + Given an int64-based datetime index, shift all elements + specified number of months using DateOffset semantics + + day: {None, 'start', 'end'} + * None: day of month + * 'start' 1st day of month + * 'end' last day of month + """ + cdef: + Py_ssize_t i + pandas_datetimestruct dts + int count = len(dtindex) + int months_to_roll + bint roll_check + int64_t[:] out = np.empty(count, dtype='int64') + + if day is None: + with nogil: + for i in range(count): + if dtindex[i] == NPY_NAT: + out[i] = NPY_NAT + continue + + dt64_to_dtstruct(dtindex[i], &dts) + dts.year = year_add_months(dts, months) + dts.month = month_add_months(dts, months) + + dts.day = min(dts.day, get_days_in_month(dts.year, dts.month)) + out[i] = dtstruct_to_dt64(&dts) + elif day == 'start': + roll_check = False + if months <= 0: + months += 1 + roll_check = True + with nogil: + for i in range(count): + if dtindex[i] == NPY_NAT: + out[i] = NPY_NAT + continue + + dt64_to_dtstruct(dtindex[i], &dts) + months_to_roll = months + + # offset semantics - if on the anchor point and going backwards + # shift to next + if roll_check and dts.day == 1: + months_to_roll -= 1 + + dts.year = year_add_months(dts, months_to_roll) + dts.month = month_add_months(dts, months_to_roll) + dts.day = 1 + + out[i] = dtstruct_to_dt64(&dts) + elif day == 'end': + roll_check = False + if months > 0: + months -= 1 + roll_check = True + with nogil: + for i in range(count): + if dtindex[i] == NPY_NAT: + out[i] = NPY_NAT + continue + + dt64_to_dtstruct(dtindex[i], &dts) + months_to_roll = months + + # similar semantics - when adding shift forward by one + # month if already at an end of month + if roll_check and dts.day == get_days_in_month(dts.year, + dts.month): + months_to_roll += 1 + + dts.year = year_add_months(dts, months_to_roll) + dts.month = month_add_months(dts, months_to_roll) + + dts.day = get_days_in_month(dts.year, dts.month) + out[i] = dtstruct_to_dt64(&dts) + else: + raise ValueError("day must be None, 'start' or 'end'") + + return np.asarray(out) + + cpdef datetime shift_month(datetime stamp, int months, object day_opt=None): """ Given a datetime (or Timestamp) `stamp`, an integer `months` and an option `day_opt`, return a new datetimelike that many months later, with day determined by `day_opt` using relativedelta semantics. - Scalar analogue of tslib.shift_months + Scalar analogue of shift_months Parameters ---------- diff --git a/pandas/tests/indexes/datetimes/test_ops.py b/pandas/tests/indexes/datetimes/test_ops.py index 9e4f8d979ca99..0db26652eb191 100644 --- a/pandas/tests/indexes/datetimes/test_ops.py +++ b/pandas/tests/indexes/datetimes/test_ops.py @@ -8,6 +8,7 @@ from itertools import product import pandas as pd import pandas._libs.tslib as tslib +from pandas._libs.tslibs.offsets import shift_months import pandas.util.testing as tm from pandas import (DatetimeIndex, PeriodIndex, Series, Timestamp, date_range, _np_version_under1p10, Index, @@ -668,8 +669,7 @@ def test_shift_months(years, months): Timestamp('2000-01-01'), Timestamp('2000-02-29'), Timestamp('2000-12-31')]) - actual = DatetimeIndex(tslib.shift_months(s.asi8, years * 12 + - months)) + actual = DatetimeIndex(shift_months(s.asi8, years * 12 + months)) expected = DatetimeIndex([x + pd.offsets.DateOffset( years=years, months=months) for x in s]) tm.assert_index_equal(actual, expected) diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index c9c4d1b1e7119..021d636042954 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -230,7 +230,7 @@ def apply_index(self, i): months = ((self.kwds.get('years', 0) * 12 + self.kwds.get('months', 0)) * self.n) if months: - shifted = tslib.shift_months(i.asi8, months) + shifted = liboffsets.shift_months(i.asi8, months) i = i._shallow_copy(shifted) weeks = (self.kwds.get('weeks', 0)) * self.n @@ -928,58 +928,57 @@ def name(self): return "{code}-{month}".format(code=self.rule_code, month=_int_to_month[self.n]) - -class MonthEnd(MonthOffset): - """DateOffset of one month end""" - _prefix = 'M' - _day_opt = 'end' + def onOffset(self, dt): + if self.normalize and not _is_normalized(dt): + return False + return dt.day == self._get_offset_day(dt) @apply_wraps def apply(self, other): n = self.n compare_day = self._get_offset_day(other) - if other.day < compare_day: - other = shift_month(other, -1, self._day_opt) - if n <= 0: - n = n + 1 - other = shift_month(other, n, self._day_opt) - return 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 + + return shift_month(other, n, self._day_opt) + + +class MonthEnd(MonthOffset): + """DateOffset of one month end""" + _prefix = 'M' + _day_opt = 'end' @apply_index_wraps def apply_index(self, i): - shifted = tslib.shift_months(i.asi8, self.n, self._day_opt) + shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt) return i._shallow_copy(shifted) - def onOffset(self, dt): - if self.normalize and not _is_normalized(dt): - return False - return dt.day == self._get_offset_day(dt) - class MonthBegin(MonthOffset): """DateOffset of one month at beginning""" _prefix = 'MS' _day_opt = 'start' - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - if other.day > compare_day and n <= 0: # then roll forward if n<=0 - n += 1 - - return shift_month(other, n, self._day_opt) - @apply_index_wraps def apply_index(self, i): - shifted = tslib.shift_months(i.asi8, self.n, self._day_opt) + shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt) return i._shallow_copy(shifted) - def onOffset(self, dt): - if self.normalize and not _is_normalized(dt): - return False - return dt.day == self._get_offset_day(dt) + +class BusinessMonthEnd(MonthOffset): + """DateOffset increments between business EOM dates""" + _prefix = 'BM' + _day_opt = 'business_end' + + +class BusinessMonthBegin(MonthOffset): + """DateOffset of one business month at beginning""" + _prefix = 'BMS' + _day_opt = 'business_start' class SemiMonthOffset(DateOffset): @@ -1181,48 +1180,6 @@ def _apply_index_days(self, i, roll): return i + (roll % 2) * Timedelta(days=self.day_of_month - 1).value -class BusinessMonthEnd(MonthOffset): - """DateOffset increments between business EOM dates""" - _prefix = 'BM' - _day_opt = 'business_end' - - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - if n > 0 and not other.day >= compare_day: - n = n - 1 - elif n <= 0 and other.day > compare_day: - n = n + 1 - - return shift_month(other, n, self._day_opt) - - -class BusinessMonthBegin(MonthOffset): - """DateOffset of one business month at beginning""" - _prefix = 'BMS' - _day_opt = 'business_start' - - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - if other.day > compare_day and n <= 0: - # as if rolled forward already - n += 1 - elif other.day < compare_day and n > 0: - n -= 1 - - return shift_month(other, n, self._day_opt) - - def onOffset(self, dt): - if self.normalize and not _is_normalized(dt): - return False - return dt.day == self._get_offset_day(dt) - - class CustomBusinessMonthEnd(BusinessMixin, MonthOffset): """ DateOffset subclass representing one custom business month, incrementing @@ -1245,6 +1202,8 @@ class CustomBusinessMonthEnd(BusinessMixin, MonthOffset): _cacheable = False _prefix = 'CBM' + onOffset = DateOffset.onOffset # override MonthOffset method + def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): self.n = int(n) @@ -1316,6 +1275,8 @@ class CustomBusinessMonthBegin(BusinessMixin, MonthOffset): _cacheable = False _prefix = 'CBMS' + onOffset = DateOffset.onOffset # override MonthOffset method + def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri', holidays=None, calendar=None, offset=timedelta(0)): self.n = int(n) @@ -1641,6 +1602,23 @@ def rule_code(self): month = _int_to_month[self.startingMonth] return '{prefix}-{month}'.format(prefix=self._prefix, month=month) + @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) + class BQuarterEnd(QuarterOffset): """DateOffset increments between business Quarter dates @@ -1654,27 +1632,11 @@ class BQuarterEnd(QuarterOffset): _prefix = 'BQ' _day_opt = 'business_end' - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - monthsToGo = 3 - ((other.month - self.startingMonth) % 3) - if monthsToGo == 3: - monthsToGo = 0 - - if n > 0 and not (other.day >= compare_day and monthsToGo == 0): - n = n - 1 - elif n <= 0 and other.day > compare_day and monthsToGo == 0: - n = n + 1 - - return shift_month(other, monthsToGo + 3 * n, self._day_opt) - def onOffset(self, dt): if self.normalize and not _is_normalized(dt): return False modMonth = (dt.month - self.startingMonth) % 3 - return BMonthEnd().onOffset(dt) and modMonth == 0 + return modMonth == 0 and dt.day == self._get_offset_day(dt) _int_to_month = tslib._MONTH_ALIASES @@ -1690,26 +1652,6 @@ class BQuarterBegin(QuarterOffset): _prefix = 'BQS' _day_opt = 'business_start' - @apply_wraps - def apply(self, other): - n = self.n - - compare_day = self._get_offset_day(other) - - monthsSince = (other.month - self.startingMonth) % 3 - - if n <= 0 and monthsSince != 0: # make sure to roll forward so negate - monthsSince = monthsSince - 3 - - # roll forward if on same month later than first bday - if n <= 0 and (monthsSince == 0 and other.day > compare_day): - n = n + 1 - # pretend to roll back if on same month but before firstbday - elif n > 0 and (monthsSince == 0 and other.day < compare_day): - n = n - 1 - - return shift_month(other, 3 * n - monthsSince, self._day_opt) - class QuarterEnd(EndMixin, QuarterOffset): """DateOffset increments between business Quarter dates @@ -1722,24 +1664,6 @@ class QuarterEnd(EndMixin, QuarterOffset): _prefix = 'Q' _day_opt = 'end' - @apply_wraps - def apply(self, other): - n = self.n - other = datetime(other.year, other.month, other.day, - other.hour, other.minute, other.second, - other.microsecond) - compare_day = self._get_offset_day(other) - - monthsToGo = 3 - ((other.month - self.startingMonth) % 3) - if monthsToGo == 3: - monthsToGo = 0 - - if n > 0 and not (other.day >= compare_day and monthsToGo == 0): - n = n - 1 - - other = shift_month(other, monthsToGo + 3 * n, self._day_opt) - return other - @apply_index_wraps def apply_index(self, i): return self._end_apply_index(i, self.freqstr) @@ -1748,7 +1672,7 @@ def onOffset(self, dt): if self.normalize and not _is_normalized(dt): return False modMonth = (dt.month - self.startingMonth) % 3 - return MonthEnd().onOffset(dt) and modMonth == 0 + return modMonth == 0 and dt.day == self._get_offset_day(dt) class QuarterBegin(BeginMixin, QuarterOffset): @@ -1758,24 +1682,6 @@ class QuarterBegin(BeginMixin, QuarterOffset): _prefix = 'QS' _day_opt = 'start' - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - monthsSince = (other.month - self.startingMonth) % 3 - - if n <= 0 and monthsSince != 0: - # make sure you roll forward, so negate - monthsSince = monthsSince - 3 - - if n <= 0 and (monthsSince == 0 and other.day > compare_day): - # after start, so come back an extra period as if rolled forward - n = n + 1 - - other = shift_month(other, 3 * n - monthsSince, self._day_opt) - return other - @apply_index_wraps def apply_index(self, i): freq_month = 12 if self.startingMonth == 1 else self.startingMonth - 1 @@ -1796,6 +1702,17 @@ def _get_offset_day(self, other): return liboffsets.get_day_of_month(other.replace(month=self.month), self._day_opt) + @apply_wraps + def apply(self, other): + years = roll_yearday(other, self.n, self.month, self._day_opt) + months = years * 12 + (self.month - other.month) + return shift_month(other, months, self._day_opt) + + def onOffset(self, dt): + if self.normalize and not _is_normalized(dt): + return False + return dt.month == self.month and dt.day == self._get_offset_day(dt) + def __init__(self, n=1, normalize=False, month=None): month = month if month is not None else self._default_month self.month = month @@ -1825,24 +1742,6 @@ class BYearEnd(YearOffset): _prefix = 'BA' _day_opt = 'business_end' - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - years = n - if n > 0: - if (other.month < self.month or - (other.month == self.month and other.day < compare_day)): - years -= 1 - elif n <= 0: - if (other.month > self.month or - (other.month == self.month and other.day > compare_day)): - years += 1 - - months = years * 12 + (self.month - other.month) - return shift_month(other, months, self._day_opt) - class BYearBegin(YearOffset): """DateOffset increments between business year begin dates""" @@ -1851,26 +1750,6 @@ class BYearBegin(YearOffset): _prefix = 'BAS' _day_opt = 'business_start' - @apply_wraps - def apply(self, other): - n = self.n - compare_day = self._get_offset_day(other) - - years = n - - if n > 0: # roll back first for positive n - if (other.month < self.month or - (other.month == self.month and other.day < compare_day)): - years -= 1 - elif n <= 0: # roll forward - if (other.month > self.month or - (other.month == self.month and other.day > compare_day)): - years += 1 - - # set first bday for result - months = years * 12 + (self.month - other.month) - return shift_month(other, months, self._day_opt) - class YearEnd(EndMixin, YearOffset): """DateOffset increments between calendar year ends""" @@ -1878,25 +1757,11 @@ class YearEnd(EndMixin, YearOffset): _prefix = 'A' _day_opt = 'end' - @apply_wraps - def apply(self, other): - n = roll_yearday(other, self.n, self.month, self._day_opt) - year = other.year + n - days_in_month = tslib.monthrange(year, self.month)[1] - return datetime(year, self.month, days_in_month, - other.hour, other.minute, other.second, - other.microsecond) - @apply_index_wraps def apply_index(self, i): # convert month anchor to annual period tuple return self._end_apply_index(i, self.freqstr) - def onOffset(self, dt): - if self.normalize and not _is_normalized(dt): - return False - return self.month == dt.month and dt.day == self._get_offset_day(dt) - class YearBegin(BeginMixin, YearOffset): """DateOffset increments between calendar year begin dates""" @@ -1904,23 +1769,12 @@ class YearBegin(BeginMixin, YearOffset): _prefix = 'AS' _day_opt = 'start' - @apply_wraps - def apply(self, other): - n = roll_yearday(other, self.n, self.month, self._day_opt) - year = other.year + n - return other.replace(year=year, month=self.month, day=1) - @apply_index_wraps def apply_index(self, i): freq_month = 12 if self.month == 1 else self.month - 1 freqstr = 'A-{month}'.format(month=_int_to_month[freq_month]) return self._beg_apply_index(i, freqstr) - def onOffset(self, dt): - if self.normalize and not _is_normalized(dt): - return False - return dt.month == self.month and dt.day == self._get_offset_day(dt) - # --------------------------------------------------------------------- # Special Offset Classes diff --git a/setup.py b/setup.py index 76847e66b8bd5..44e7de1665bf0 100755 --- a/setup.py +++ b/setup.py @@ -577,7 +577,10 @@ def pxd(name): 'pyxfile': '_libs/tslibs/offsets', 'pxdfiles': ['_libs/src/util', '_libs/tslibs/conversion', - '_libs/tslibs/frequencies']}, + '_libs/tslibs/frequencies', + '_libs/tslibs/nattype'], + 'depends': tseries_depends, + 'sources': np_datetime_sources}, '_libs.tslibs.parsing': { 'pyxfile': '_libs/tslibs/parsing', 'pxdfiles': ['_libs/src/util',