Skip to content

Commit f1988cf

Browse files
committed
implement business_start, business_end for shift_months
1 parent be66ef8 commit f1988cf

File tree

5 files changed

+160
-45
lines changed

5 files changed

+160
-45
lines changed

pandas/_libs/tslibs/ccalendar.pxd

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# -*- coding: utf-8 -*-
2+
# cython: profile=False
3+
4+
from cython cimport Py_ssize_t
5+
6+
from numpy cimport int64_t, int32_t
7+
8+
9+
cpdef monthrange(int64_t year, Py_ssize_t month)
10+
11+
cdef int dayofweek(int y, int m, int d) nogil
12+
cdef int is_leapyear(int64_t year) nogil
13+
cdef int32_t get_days_in_month(int year, int month) nogil

pandas/_libs/tslibs/ccalendar.pyx

+65
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# -*- coding: utf-8 -*-
2+
# cython: profile=False
3+
# cython: boundscheck=False
4+
"""
5+
Cython implementations of functions resembling the stdlib calendar module
6+
"""
7+
8+
cimport cython
9+
from cython cimport Py_ssize_t
10+
11+
import numpy as np
12+
cimport numpy as np
13+
from numpy cimport int64_t, int32_t
14+
np.import_array()
15+
16+
17+
# ----------------------------------------------------------------------
18+
# Constants
19+
20+
# Slightly more performant cython lookups than a 2D table
21+
cdef int32_t* days_per_month_array = [
22+
31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31,
23+
31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
24+
25+
# ----------------------------------------------------------------------
26+
27+
28+
@cython.wraparound(False)
29+
@cython.boundscheck(False)
30+
cdef inline int32_t get_days_in_month(int year, int month) nogil:
31+
return days_per_month_array[12 * is_leapyear(year) + month - 1]
32+
33+
34+
@cython.wraparound(False)
35+
@cython.boundscheck(False)
36+
cpdef monthrange(int64_t year, Py_ssize_t month):
37+
cdef:
38+
int32_t days
39+
40+
if month < 1 or month > 12:
41+
raise ValueError("bad month number 0; must be 1-12")
42+
43+
days = get_days_in_month(year, month)
44+
return (dayofweek(year, month, 1), days)
45+
46+
47+
@cython.wraparound(False)
48+
@cython.boundscheck(False)
49+
@cython.cdivision
50+
cdef int dayofweek(int y, int m, int d) nogil:
51+
"""Sakamoto's method, from wikipedia"""
52+
cdef:
53+
int day
54+
int* sakamoto_arr = [0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4]
55+
56+
y -= m < 3
57+
day = (y + y / 4 - y / 100 + y / 400 + sakamoto_arr[m - 1] + d) % 7
58+
# convert to python day
59+
return (day + 6) % 7
60+
61+
62+
cdef int is_leapyear(int64_t year) nogil:
63+
"""Returns 1 if the given year is a leap year, 0 otherwise."""
64+
return ((year & 0x3) == 0 and # year % 4 == 0
65+
((year % 100) != 0 or (year % 400) == 0))

pandas/_libs/tslibs/offsets.pyx

+53-10
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,12 @@ np.import_array()
1717

1818
from util cimport is_string_object, is_integer_object
1919

20-
from pandas._libs.tslib import monthrange
21-
20+
from ccalendar cimport get_days_in_month, monthrange
2221
from conversion cimport tz_convert_single, pydt_to_i8
2322
from frequencies cimport get_freq_code
2423
from nattype cimport NPY_NAT
2524
from np_datetime cimport (pandas_datetimestruct,
26-
dtstruct_to_dt64, dt64_to_dtstruct,
27-
is_leapyear, days_per_month_table)
25+
dtstruct_to_dt64, dt64_to_dtstruct)
2826

2927
# ---------------------------------------------------------------------
3028
# Constants
@@ -425,11 +423,6 @@ class BaseOffset(_BaseOffset):
425423
# ----------------------------------------------------------------------
426424
# RelativeDelta Arithmetic
427425

428-
@cython.wraparound(False)
429-
@cython.boundscheck(False)
430-
cdef inline int get_days_in_month(int year, int month) nogil:
431-
return days_per_month_table[is_leapyear(year)][month - 1]
432-
433426

434427
cdef inline int year_add_months(pandas_datetimestruct dts, int months) nogil:
435428
"""new year number after shifting pandas_datetimestruct number of months"""
@@ -527,8 +520,58 @@ def shift_months(int64_t[:] dtindex, int months, object day=None):
527520

528521
dts.day = get_days_in_month(dts.year, dts.month)
529522
out[i] = dtstruct_to_dt64(&dts)
523+
524+
elif day == 'business_start':
525+
for i in range(count):
526+
if dtindex[i] == NPY_NAT:
527+
out[i] = NPY_NAT
528+
continue
529+
530+
dt64_to_dtstruct(dtindex[i], &dts)
531+
months_to_roll = months
532+
wkday, days_in_month = monthrange(dts.year, dts.month)
533+
compare_day = get_firstbday(wkday, days_in_month)
534+
535+
if months_to_roll > 0 and dts.day < compare_day:
536+
months_to_roll -= 1
537+
elif months_to_roll <= 0 and dts.day > compare_day:
538+
# as if rolled forward already
539+
months_to_roll += 1
540+
541+
dts.year = year_add_months(dts, months_to_roll)
542+
dts.month = month_add_months(dts, months_to_roll)
543+
544+
wkday, days_in_month = monthrange(dts.year, dts.month)
545+
dts.day = get_firstbday(wkday, days_in_month)
546+
out[i] = dtstruct_to_dt64(&dts)
547+
548+
elif day == 'business_end':
549+
for i in range(count):
550+
if dtindex[i] == NPY_NAT:
551+
out[i] = NPY_NAT
552+
continue
553+
554+
dt64_to_dtstruct(dtindex[i], &dts)
555+
months_to_roll = months
556+
wkday, days_in_month = monthrange(dts.year, dts.month)
557+
compare_day = get_lastbday(wkday, days_in_month)
558+
559+
if months_to_roll > 0 and dts.day < compare_day:
560+
months_to_roll -= 1
561+
elif months_to_roll <= 0 and dts.day > compare_day:
562+
# as if rolled forward already
563+
months_to_roll += 1
564+
565+
dts.year = year_add_months(dts, months_to_roll)
566+
dts.month = month_add_months(dts, months_to_roll)
567+
568+
wkday, days_in_month = monthrange(dts.year, dts.month)
569+
dts.day = get_lastbday(wkday, days_in_month)
570+
out[i] = dtstruct_to_dt64(&dts)
571+
530572
else:
531-
raise ValueError("day must be None, 'start' or 'end'")
573+
raise ValueError("day must be None, 'start', 'end', "
574+
"'business_start', or 'business_end'")
532575

533576
return np.asarray(out)
534577

pandas/tseries/offsets.py

+25-35
Original file line numberDiff line numberDiff line change
@@ -924,8 +924,9 @@ def name(self):
924924
if self.isAnchored:
925925
return self.rule_code
926926
else:
927+
month = liboffsets._int_to_month[self.n]
927928
return "{code}-{month}".format(code=self.rule_code,
928-
month=_int_to_month[self.n])
929+
month=month)
929930

930931
def onOffset(self, dt):
931932
if self.normalize and not _is_normalized(dt):
@@ -945,28 +946,23 @@ def apply(self, other):
945946

946947
return shift_month(other, n, self._day_opt)
947948

949+
@apply_index_wraps
950+
def apply_index(self, i):
951+
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
952+
return i._shallow_copy(shifted)
953+
948954

949955
class MonthEnd(MonthOffset):
950956
"""DateOffset of one month end"""
951957
_prefix = 'M'
952958
_day_opt = 'end'
953959

954-
@apply_index_wraps
955-
def apply_index(self, i):
956-
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
957-
return i._shallow_copy(shifted)
958-
959960

960961
class MonthBegin(MonthOffset):
961962
"""DateOffset of one month at beginning"""
962963
_prefix = 'MS'
963964
_day_opt = 'start'
964965

965-
@apply_index_wraps
966-
def apply_index(self, i):
967-
shifted = liboffsets.shift_months(i.asi8, self.n, self._day_opt)
968-
return i._shallow_copy(shifted)
969-
970966

971967
class BusinessMonthEnd(MonthOffset):
972968
"""DateOffset increments between business EOM dates"""
@@ -1202,6 +1198,7 @@ class CustomBusinessMonthEnd(BusinessMixin, MonthOffset):
12021198
_prefix = 'CBM'
12031199

12041200
onOffset = DateOffset.onOffset # override MonthOffset method
1201+
apply_index = DateOffset.apply_index # override MonthOffset method
12051202

12061203
def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
12071204
holidays=None, calendar=None, offset=timedelta(0)):
@@ -1275,6 +1272,7 @@ class CustomBusinessMonthBegin(BusinessMixin, MonthOffset):
12751272
_prefix = 'CBMS'
12761273

12771274
onOffset = DateOffset.onOffset # override MonthOffset method
1275+
apply_index = DateOffset.apply_index # override MonthOffset method
12781276

12791277
def __init__(self, n=1, normalize=False, weekmask='Mon Tue Wed Thu Fri',
12801278
holidays=None, calendar=None, offset=timedelta(0)):
@@ -1590,15 +1588,15 @@ def isAnchored(self):
15901588
def _from_name(cls, suffix=None):
15911589
kwargs = {}
15921590
if suffix:
1593-
kwargs['startingMonth'] = _month_to_int[suffix]
1591+
kwargs['startingMonth'] = liboffsets._month_to_int[suffix]
15941592
else:
15951593
if cls._from_name_startingMonth is not None:
15961594
kwargs['startingMonth'] = cls._from_name_startingMonth
15971595
return cls(**kwargs)
15981596

15991597
@property
16001598
def rule_code(self):
1601-
month = _int_to_month[self.startingMonth]
1599+
month = liboffsets._int_to_month[self.startingMonth]
16021600
return '{prefix}-{month}'.format(prefix=self._prefix, month=month)
16031601

16041602
@apply_wraps
@@ -1618,6 +1616,12 @@ def apply(self, other):
16181616

16191617
return shift_month(other, 3 * n - months_since, self._day_opt)
16201618

1619+
def onOffset(self, dt):
1620+
if self.normalize and not _is_normalized(dt):
1621+
return False
1622+
modMonth = (dt.month - self.startingMonth) % 3
1623+
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1624+
16211625

16221626
class BQuarterEnd(QuarterOffset):
16231627
"""DateOffset increments between business Quarter dates
@@ -1631,16 +1635,6 @@ class BQuarterEnd(QuarterOffset):
16311635
_prefix = 'BQ'
16321636
_day_opt = 'business_end'
16331637

1634-
def onOffset(self, dt):
1635-
if self.normalize and not _is_normalized(dt):
1636-
return False
1637-
modMonth = (dt.month - self.startingMonth) % 3
1638-
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1639-
1640-
1641-
_int_to_month = tslib._MONTH_ALIASES
1642-
_month_to_int = {v: k for k, v in _int_to_month.items()}
1643-
16441638

16451639
# TODO: This is basically the same as BQuarterEnd
16461640
class BQuarterBegin(QuarterOffset):
@@ -1667,12 +1661,6 @@ class QuarterEnd(EndMixin, QuarterOffset):
16671661
def apply_index(self, i):
16681662
return self._end_apply_index(i, self.freqstr)
16691663

1670-
def onOffset(self, dt):
1671-
if self.normalize and not _is_normalized(dt):
1672-
return False
1673-
modMonth = (dt.month - self.startingMonth) % 3
1674-
return modMonth == 0 and dt.day == self._get_offset_day(dt)
1675-
16761664

16771665
class QuarterBegin(BeginMixin, QuarterOffset):
16781666
_outputName = 'QuarterBegin'
@@ -1684,7 +1672,8 @@ class QuarterBegin(BeginMixin, QuarterOffset):
16841672
@apply_index_wraps
16851673
def apply_index(self, i):
16861674
freq_month = 12 if self.startingMonth == 1 else self.startingMonth - 1
1687-
freqstr = 'Q-{month}'.format(month=_int_to_month[freq_month])
1675+
month = liboffsets._int_to_month[freq_month]
1676+
freqstr = 'Q-{month}'.format(month=month)
16881677
return self._beg_apply_index(i, freqstr)
16891678

16901679

@@ -1725,12 +1714,12 @@ def __init__(self, n=1, normalize=False, month=None):
17251714
def _from_name(cls, suffix=None):
17261715
kwargs = {}
17271716
if suffix:
1728-
kwargs['month'] = _month_to_int[suffix]
1717+
kwargs['month'] = liboffsets._month_to_int[suffix]
17291718
return cls(**kwargs)
17301719

17311720
@property
17321721
def rule_code(self):
1733-
month = _int_to_month[self.month]
1722+
month = liboffsets._int_to_month[self.month]
17341723
return '{prefix}-{month}'.format(prefix=self._prefix, month=month)
17351724

17361725

@@ -1771,7 +1760,8 @@ class YearBegin(BeginMixin, YearOffset):
17711760
@apply_index_wraps
17721761
def apply_index(self, i):
17731762
freq_month = 12 if self.month == 1 else self.month - 1
1774-
freqstr = 'A-{month}'.format(month=_int_to_month[freq_month])
1763+
month = liboffsets._int_to_month[freq_month]
1764+
freqstr = 'A-{month}'.format(month=month)
17751765
return self._beg_apply_index(i, freqstr)
17761766

17771767

@@ -1956,7 +1946,7 @@ def _get_suffix_prefix(self):
19561946

19571947
def get_rule_code_suffix(self):
19581948
prefix = self._get_suffix_prefix()
1959-
month = _int_to_month[self.startingMonth]
1949+
month = liboffsets._int_to_month[self.startingMonth]
19601950
weekday = _int_to_weekday[self.weekday]
19611951
return '{prefix}-{month}-{weekday}'.format(prefix=prefix, month=month,
19621952
weekday=weekday)
@@ -1971,7 +1961,7 @@ def _parse_suffix(cls, varion_code, startingMonth_code, weekday_code):
19711961
raise ValueError("Unable to parse varion_code: "
19721962
"{code}".format(code=varion_code))
19731963

1974-
startingMonth = _month_to_int[startingMonth_code]
1964+
startingMonth = liboffsets._month_to_int[startingMonth_code]
19751965
weekday = _weekday_to_int[weekday_code]
19761966

19771967
return {"weekday": weekday,

setup.py

+4
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,7 @@ class CheckSDist(sdist_class):
343343
'pandas/_libs/window.pyx',
344344
'pandas/_libs/sparse.pyx',
345345
'pandas/_libs/parsers.pyx',
346+
'pandas/_libs/tslibs/ccalendar.pyx',
346347
'pandas/_libs/tslibs/strptime.pyx',
347348
'pandas/_libs/tslibs/np_datetime.pyx',
348349
'pandas/_libs/tslibs/timedeltas.pyx',
@@ -558,6 +559,8 @@ def pxd(name):
558559
'_libs/tslibs/nattype'],
559560
'depends': tseries_depends,
560561
'sources': np_datetime_sources},
562+
'_libs.tslibs.ccalendar': {
563+
'pyxfile': '_libs/tslibs/ccalendar'},
561564
'_libs.tslibs.conversion': {
562565
'pyxfile': '_libs/tslibs/conversion',
563566
'pxdfiles': ['_libs/src/util',
@@ -584,6 +587,7 @@ def pxd(name):
584587
'_libs.tslibs.offsets': {
585588
'pyxfile': '_libs/tslibs/offsets',
586589
'pxdfiles': ['_libs/src/util',
590+
'_libs/tslibs/ccalendar',
587591
'_libs/tslibs/conversion',
588592
'_libs/tslibs/frequencies',
589593
'_libs/tslibs/nattype'],

0 commit comments

Comments
 (0)