Skip to content

Commit 88ab693

Browse files
jbrockmendeljreback
authored andcommitted
implement shift_quarters --> apply_index for quarters and years (pandas-dev#18522)
1 parent 34b036c commit 88ab693

File tree

3 files changed

+193
-53
lines changed

3 files changed

+193
-53
lines changed

pandas/_libs/tslibs/offsets.pyx

+154-2
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,6 @@ np.import_array()
1717

1818
from util cimport is_string_object, is_integer_object
1919

20-
from pandas._libs.tslib import monthrange
21-
2220
from conversion cimport tz_convert_single, pydt_to_i8
2321
from frequencies cimport get_freq_code
2422
from nattype cimport NPY_NAT
@@ -471,6 +469,160 @@ cdef inline int month_add_months(pandas_datetimestruct dts, int months) nogil:
471469
return 12 if new_month == 0 else new_month
472470

473471

472+
@cython.wraparound(False)
473+
@cython.boundscheck(False)
474+
def shift_quarters(int64_t[:] dtindex, int quarters,
475+
int q1start_month, object day, int modby=3):
476+
"""
477+
Given an int64 array representing nanosecond timestamps, shift all elements
478+
by the specified number of quarters using DateOffset semantics.
479+
480+
Parameters
481+
----------
482+
dtindex : int64_t[:] timestamps for input dates
483+
quarters : int number of quarters to shift
484+
q1start_month : int month in which Q1 begins by convention
485+
day : {'start', 'end', 'business_start', 'business_end'}
486+
modby : int (3 for quarters, 12 for years)
487+
488+
Returns
489+
-------
490+
out : ndarray[int64_t]
491+
"""
492+
cdef:
493+
Py_ssize_t i
494+
pandas_datetimestruct dts
495+
int count = len(dtindex)
496+
int months_to_roll, months_since, n, compare_day
497+
bint roll_check
498+
int64_t[:] out = np.empty(count, dtype='int64')
499+
500+
if day == 'start':
501+
with nogil:
502+
for i in range(count):
503+
if dtindex[i] == NPY_NAT:
504+
out[i] = NPY_NAT
505+
continue
506+
507+
dt64_to_dtstruct(dtindex[i], &dts)
508+
n = quarters
509+
510+
months_since = (dts.month - q1start_month) % modby
511+
512+
# offset semantics - if on the anchor point and going backwards
513+
# shift to next
514+
if n <= 0 and (months_since != 0 or
515+
(months_since == 0 and dts.day > 1)):
516+
n += 1
517+
518+
dts.year = year_add_months(dts, modby * n - months_since)
519+
dts.month = month_add_months(dts, modby * n - months_since)
520+
dts.day = 1
521+
522+
out[i] = dtstruct_to_dt64(&dts)
523+
524+
elif day == 'end':
525+
with nogil:
526+
for i in range(count):
527+
if dtindex[i] == NPY_NAT:
528+
out[i] = NPY_NAT
529+
continue
530+
531+
dt64_to_dtstruct(dtindex[i], &dts)
532+
n = quarters
533+
534+
months_since = (dts.month - q1start_month) % modby
535+
536+
if n <= 0 and months_since != 0:
537+
# The general case of this condition would be
538+
# `months_since != 0 or (months_since == 0 and
539+
# dts.day > get_days_in_month(dts.year, dts.month))`
540+
# but the get_days_in_month inequality would never hold.
541+
n += 1
542+
elif n > 0 and (months_since == 0 and
543+
dts.day < get_days_in_month(dts.year,
544+
dts.month)):
545+
n -= 1
546+
547+
dts.year = year_add_months(dts, modby * n - months_since)
548+
dts.month = month_add_months(dts, modby * n - months_since)
549+
dts.day = get_days_in_month(dts.year, dts.month)
550+
551+
out[i] = dtstruct_to_dt64(&dts)
552+
553+
elif day == 'business_start':
554+
with nogil:
555+
for i in range(count):
556+
if dtindex[i] == NPY_NAT:
557+
out[i] = NPY_NAT
558+
continue
559+
560+
dt64_to_dtstruct(dtindex[i], &dts)
561+
n = quarters
562+
563+
months_since = (dts.month - q1start_month) % modby
564+
compare_month = dts.month - months_since
565+
compare_month = compare_month or 12
566+
# compare_day is only relevant for comparison in the case
567+
# where months_since == 0.
568+
compare_day = get_firstbday(dts.year, compare_month)
569+
570+
if n <= 0 and (months_since != 0 or
571+
(months_since == 0 and dts.day > compare_day)):
572+
# make sure to roll forward, so negate
573+
n += 1
574+
elif n > 0 and (months_since == 0 and dts.day < compare_day):
575+
# pretend to roll back if on same month but
576+
# before compare_day
577+
n -= 1
578+
579+
dts.year = year_add_months(dts, modby * n - months_since)
580+
dts.month = month_add_months(dts, modby * n - months_since)
581+
582+
dts.day = get_firstbday(dts.year, dts.month)
583+
584+
out[i] = dtstruct_to_dt64(&dts)
585+
586+
elif day == 'business_end':
587+
with nogil:
588+
for i in range(count):
589+
if dtindex[i] == NPY_NAT:
590+
out[i] = NPY_NAT
591+
continue
592+
593+
dt64_to_dtstruct(dtindex[i], &dts)
594+
n = quarters
595+
596+
months_since = (dts.month - q1start_month) % modby
597+
compare_month = dts.month - months_since
598+
compare_month = compare_month or 12
599+
# compare_day is only relevant for comparison in the case
600+
# where months_since == 0.
601+
compare_day = get_lastbday(dts.year, compare_month)
602+
603+
if n <= 0 and (months_since != 0 or
604+
(months_since == 0 and dts.day > compare_day)):
605+
# make sure to roll forward, so negate
606+
n += 1
607+
elif n > 0 and (months_since == 0 and dts.day < compare_day):
608+
# pretend to roll back if on same month but
609+
# before compare_day
610+
n -= 1
611+
612+
dts.year = year_add_months(dts, modby * n - months_since)
613+
dts.month = month_add_months(dts, modby * n - months_since)
614+
615+
dts.day = get_lastbday(dts.year, dts.month)
616+
617+
out[i] = dtstruct_to_dt64(&dts)
618+
619+
else:
620+
raise ValueError("day must be None, 'start', 'end', "
621+
"'business_start', or 'business_end'")
622+
623+
return np.asarray(out)
624+
625+
474626
@cython.wraparound(False)
475627
@cython.boundscheck(False)
476628
def shift_months(int64_t[:] dtindex, int months, object day=None):

pandas/tests/tseries/offsets/test_yqm_offsets.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,15 @@ def test_quarterly_dont_normalize():
3333
assert (result.time() == date.time())
3434

3535

36-
@pytest.mark.parametrize('offset', [MonthBegin(), MonthEnd(),
37-
BMonthBegin(), BMonthEnd()])
38-
def test_apply_index(offset):
36+
@pytest.mark.parametrize('n', [-2, 1])
37+
@pytest.mark.parametrize('cls', [MonthBegin, MonthEnd,
38+
BMonthBegin, BMonthEnd,
39+
QuarterBegin, QuarterEnd,
40+
BQuarterBegin, BQuarterEnd,
41+
YearBegin, YearEnd,
42+
BYearBegin, BYearEnd])
43+
def test_apply_index(cls, n):
44+
offset = cls(n=n)
3945
rng = pd.date_range(start='1/1/2000', periods=100000, freq='T')
4046
ser = pd.Series(rng)
4147

pandas/tseries/offsets.py

+30-48
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
apply_index_wraps,
2828
roll_yearday,
2929
shift_month,
30-
BeginMixin, EndMixin,
30+
EndMixin,
3131
BaseOffset)
3232

3333

@@ -1028,10 +1028,7 @@ def cbday(self):
10281028

10291029
@cache_readonly
10301030
def m_offset(self):
1031-
kwds = self.kwds
1032-
kwds = {key: kwds[key] for key in kwds
1033-
if key not in ['calendar', 'weekmask', 'holidays', 'offset']}
1034-
return MonthEnd(n=1, normalize=self.normalize, **kwds)
1031+
return MonthEnd(n=1, normalize=self.normalize)
10351032

10361033
@apply_wraps
10371034
def apply(self, other):
@@ -1106,10 +1103,7 @@ def cbday(self):
11061103

11071104
@cache_readonly
11081105
def m_offset(self):
1109-
kwds = self.kwds
1110-
kwds = {key: kwds[key] for key in kwds
1111-
if key not in ['calendar', 'weekmask', 'holidays', 'offset']}
1112-
return MonthBegin(n=1, normalize=self.normalize, **kwds)
1106+
return MonthBegin(n=1, normalize=self.normalize)
11131107

11141108
@apply_wraps
11151109
def apply(self, other):
@@ -1254,12 +1248,9 @@ def onOffset(self, dt):
12541248

12551249
def _apply(self, n, other):
12561250
# if other.day is not day_of_month move to day_of_month and update n
1257-
if other.day < self.day_of_month:
1258-
other = other.replace(day=self.day_of_month)
1259-
if n > 0:
1260-
n -= 1
1251+
if n > 0 and other.day < self.day_of_month:
1252+
n -= 1
12611253
elif other.day > self.day_of_month:
1262-
other = other.replace(day=self.day_of_month)
12631254
n += 1
12641255

12651256
months = n // 2
@@ -1309,12 +1300,9 @@ def onOffset(self, dt):
13091300
def _apply(self, n, other):
13101301
# if other.day is not day_of_month move to day_of_month and update n
13111302
if other.day < self.day_of_month:
1312-
other = other.replace(day=self.day_of_month)
13131303
n -= 1
1314-
elif other.day > self.day_of_month:
1315-
other = other.replace(day=self.day_of_month)
1316-
if n <= 0:
1317-
n += 1
1304+
elif n <= 0 and other.day > self.day_of_month:
1305+
n += 1
13181306

13191307
months = n // 2 + n % 2
13201308
day = 1 if n % 2 else self.day_of_month
@@ -1471,6 +1459,7 @@ def apply(self, other):
14711459
def getOffsetOfMonth(self, dt):
14721460
w = Week(weekday=self.weekday)
14731461
d = datetime(dt.year, dt.month, 1, tzinfo=dt.tzinfo)
1462+
# TODO: Is this DST-safe?
14741463
d = w.rollforward(d)
14751464
return d + timedelta(weeks=self.week)
14761465

@@ -1550,6 +1539,7 @@ def getOffsetOfMonth(self, dt):
15501539
d = datetime(dt.year, dt.month, 1, dt.hour, dt.minute,
15511540
dt.second, dt.microsecond, tzinfo=dt.tzinfo)
15521541
eom = m.rollforward(d)
1542+
# TODO: Is this DST-safe?
15531543
w = Week(weekday=self.weekday)
15541544
return w.rollback(eom)
15551545

@@ -1635,6 +1625,12 @@ def onOffset(self, dt):
16351625
modMonth = (dt.month - self.startingMonth) % 3
16361626
return modMonth == 0 and dt.day == self._get_offset_day(dt)
16371627

1628+
@apply_index_wraps
1629+
def apply_index(self, dtindex):
1630+
shifted = liboffsets.shift_quarters(dtindex.asi8, self.n,
1631+
self.startingMonth, self._day_opt)
1632+
return dtindex._shallow_copy(shifted)
1633+
16381634

16391635
class BQuarterEnd(QuarterOffset):
16401636
"""DateOffset increments between business Quarter dates
@@ -1659,7 +1655,7 @@ class BQuarterBegin(QuarterOffset):
16591655
_day_opt = 'business_start'
16601656

16611657

1662-
class QuarterEnd(EndMixin, QuarterOffset):
1658+
class QuarterEnd(QuarterOffset):
16631659
"""DateOffset increments between business Quarter dates
16641660
startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
16651661
startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
@@ -1670,25 +1666,14 @@ class QuarterEnd(EndMixin, QuarterOffset):
16701666
_prefix = 'Q'
16711667
_day_opt = 'end'
16721668

1673-
@apply_index_wraps
1674-
def apply_index(self, i):
1675-
return self._end_apply_index(i, self.freqstr)
1676-
16771669

1678-
class QuarterBegin(BeginMixin, QuarterOffset):
1670+
class QuarterBegin(QuarterOffset):
16791671
_outputName = 'QuarterBegin'
16801672
_default_startingMonth = 3
16811673
_from_name_startingMonth = 1
16821674
_prefix = 'QS'
16831675
_day_opt = 'start'
16841676

1685-
@apply_index_wraps
1686-
def apply_index(self, i):
1687-
freq_month = 12 if self.startingMonth == 1 else self.startingMonth - 1
1688-
month = liboffsets._int_to_month[freq_month]
1689-
freqstr = 'Q-{month}'.format(month=month)
1690-
return self._beg_apply_index(i, freqstr)
1691-
16921677

16931678
# ---------------------------------------------------------------------
16941679
# Year-Based Offset Classes
@@ -1709,6 +1694,13 @@ def apply(self, other):
17091694
months = years * 12 + (self.month - other.month)
17101695
return shift_month(other, months, self._day_opt)
17111696

1697+
@apply_index_wraps
1698+
def apply_index(self, dtindex):
1699+
shifted = liboffsets.shift_quarters(dtindex.asi8, self.n,
1700+
self.month, self._day_opt,
1701+
modby=12)
1702+
return dtindex._shallow_copy(shifted)
1703+
17121704
def onOffset(self, dt):
17131705
if self.normalize and not _is_normalized(dt):
17141706
return False
@@ -1752,31 +1744,19 @@ class BYearBegin(YearOffset):
17521744
_day_opt = 'business_start'
17531745

17541746

1755-
class YearEnd(EndMixin, YearOffset):
1747+
class YearEnd(YearOffset):
17561748
"""DateOffset increments between calendar year ends"""
17571749
_default_month = 12
17581750
_prefix = 'A'
17591751
_day_opt = 'end'
17601752

1761-
@apply_index_wraps
1762-
def apply_index(self, i):
1763-
# convert month anchor to annual period tuple
1764-
return self._end_apply_index(i, self.freqstr)
17651753

1766-
1767-
class YearBegin(BeginMixin, YearOffset):
1754+
class YearBegin(YearOffset):
17681755
"""DateOffset increments between calendar year begin dates"""
17691756
_default_month = 1
17701757
_prefix = 'AS'
17711758
_day_opt = 'start'
17721759

1773-
@apply_index_wraps
1774-
def apply_index(self, i):
1775-
freq_month = 12 if self.month == 1 else self.month - 1
1776-
month = liboffsets._int_to_month[freq_month]
1777-
freqstr = 'A-{month}'.format(month=month)
1778-
return self._beg_apply_index(i, freqstr)
1779-
17801760

17811761
# ---------------------------------------------------------------------
17821762
# Special Offset Classes
@@ -2245,7 +2225,8 @@ def __eq__(self, other):
22452225
if isinstance(other, Tick):
22462226
return self.delta == other.delta
22472227
else:
2248-
return DateOffset.__eq__(self, other)
2228+
# TODO: Are there cases where this should raise TypeError?
2229+
return False
22492230

22502231
# This is identical to DateOffset.__hash__, but has to be redefined here
22512232
# for Python 3, because we've redefined __eq__.
@@ -2261,7 +2242,8 @@ def __ne__(self, other):
22612242
if isinstance(other, Tick):
22622243
return self.delta != other.delta
22632244
else:
2264-
return DateOffset.__ne__(self, other)
2245+
# TODO: Are there cases where this should raise TypeError?
2246+
return True
22652247

22662248
@property
22672249
def delta(self):

0 commit comments

Comments
 (0)