Skip to content

Commit 498a1e1

Browse files
jbrockmendeljreback
authored andcommitted
simplify+unify offset.apply logic (#18263)
1 parent 54f2a5e commit 498a1e1

File tree

3 files changed

+105
-74
lines changed

3 files changed

+105
-74
lines changed

pandas/_libs/tslibs/offsets.pyx

+47-9
Original file line numberDiff line numberDiff line change
@@ -139,12 +139,45 @@ def apply_index_wraps(func):
139139
# ---------------------------------------------------------------------
140140
# Business Helpers
141141

142-
cpdef int _get_firstbday(int wkday):
142+
cpdef int get_lastbday(int wkday, int days_in_month):
143143
"""
144-
wkday is the result of monthrange(year, month)
144+
Find the last day of the month that is a business day.
145145
146-
If it's a saturday or sunday, increment first business day to reflect this
146+
(wkday, days_in_month) is the output from monthrange(year, month)
147+
148+
Parameters
149+
----------
150+
wkday : int
151+
days_in_month : int
152+
153+
Returns
154+
-------
155+
last_bday : int
147156
"""
157+
return days_in_month - max(((wkday + days_in_month - 1) % 7) - 4, 0)
158+
159+
160+
cpdef int get_firstbday(int wkday, int days_in_month=0):
161+
"""
162+
Find the first day of the month that is a business day.
163+
164+
(wkday, days_in_month) is the output from monthrange(year, month)
165+
166+
Parameters
167+
----------
168+
wkday : int
169+
days_in_month : int, default 0
170+
171+
Returns
172+
-------
173+
first_bday : int
174+
175+
Notes
176+
-----
177+
`days_in_month` arg is a dummy so that this has the same signature as
178+
`get_lastbday`.
179+
"""
180+
cdef int first
148181
first = 1
149182
if wkday == 5: # on Saturday
150183
first = 3
@@ -380,7 +413,6 @@ class BaseOffset(_BaseOffset):
380413
# ----------------------------------------------------------------------
381414
# RelativeDelta Arithmetic
382415

383-
384416
cpdef datetime shift_month(datetime stamp, int months, object day_opt=None):
385417
"""
386418
Given a datetime (or Timestamp) `stamp`, an integer `months` and an
@@ -406,7 +438,7 @@ cpdef datetime shift_month(datetime stamp, int months, object day_opt=None):
406438
"""
407439
cdef:
408440
int year, month, day
409-
int dim, dy
441+
int wkday, days_in_month, dy
410442

411443
dy = (stamp.month + months) // 12
412444
month = (stamp.month + months) % 12
@@ -416,15 +448,21 @@ cpdef datetime shift_month(datetime stamp, int months, object day_opt=None):
416448
dy -= 1
417449
year = stamp.year + dy
418450

419-
dim = monthrange(year, month)[1]
451+
wkday, days_in_month = monthrange(year, month)
420452
if day_opt is None:
421-
day = min(stamp.day, dim)
453+
day = min(stamp.day, days_in_month)
422454
elif day_opt == 'start':
423455
day = 1
424456
elif day_opt == 'end':
425-
day = dim
457+
day = days_in_month
458+
elif day_opt == 'business_start':
459+
# first business day of month
460+
day = get_firstbday(wkday, days_in_month)
461+
elif day_opt == 'business_end':
462+
# last business day of month
463+
day = get_lastbday(wkday, days_in_month)
426464
elif is_integer_object(day_opt):
427-
day = min(day_opt, dim)
465+
day = min(day_opt, days_in_month)
428466
else:
429467
raise ValueError(day_opt)
430468
return stamp.replace(year=year, month=month, day=day)

pandas/tests/tseries/offsets/test_offsets.py

+41-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
to_datetime, DateParseError)
3535
import pandas.tseries.offsets as offsets
3636
from pandas.io.pickle import read_pickle
37-
from pandas._libs.tslibs import timezones
37+
from pandas._libs.tslibs import timezones, offsets as liboffsets
3838
from pandas._libs.tslib import normalize_date, NaT, Timestamp
3939
import pandas._libs.tslib as tslib
4040
import pandas.util.testing as tm
@@ -4683,3 +4683,43 @@ def test_all_offset_classes(self, tup):
46834683
first = Timestamp(test_values[0], tz='US/Eastern') + offset()
46844684
second = Timestamp(test_values[1], tz='US/Eastern')
46854685
assert first == second
4686+
4687+
4688+
def test_get_lastbday():
4689+
dt = datetime(2017, 11, 30)
4690+
assert dt.weekday() == 3 # i.e. this is a business day
4691+
wkday, days_in_month = tslib.monthrange(dt.year, dt.month)
4692+
assert liboffsets.get_lastbday(wkday, days_in_month) == 30
4693+
4694+
dt = datetime(1993, 10, 31)
4695+
assert dt.weekday() == 6 # i.e. this is not a business day
4696+
wkday, days_in_month = tslib.monthrange(dt.year, dt.month)
4697+
assert liboffsets.get_lastbday(wkday, days_in_month) == 29
4698+
4699+
4700+
def test_get_firstbday():
4701+
dt = datetime(2017, 4, 1)
4702+
assert dt.weekday() == 5 # i.e. not a weekday
4703+
wkday, days_in_month = tslib.monthrange(dt.year, dt.month)
4704+
assert liboffsets.get_firstbday(wkday, days_in_month) == 3
4705+
4706+
dt = datetime(1993, 10, 1)
4707+
assert dt.weekday() == 4 # i.e. a business day
4708+
wkday, days_in_month = tslib.monthrange(dt.year, dt.month)
4709+
assert liboffsets.get_firstbday(wkday, days_in_month) == 1
4710+
4711+
4712+
def test_shift_month():
4713+
dt = datetime(2017, 11, 30)
4714+
assert liboffsets.shift_month(dt, 0, 'business_end') == dt
4715+
assert liboffsets.shift_month(dt, 0,
4716+
'business_start') == datetime(2017, 11, 1)
4717+
4718+
ts = Timestamp('1929-05-05')
4719+
assert liboffsets.shift_month(ts, 1, 'start') == Timestamp('1929-06-01')
4720+
assert liboffsets.shift_month(ts, -3, 'end') == Timestamp('1929-02-28')
4721+
4722+
assert liboffsets.shift_month(ts, 25, None) == Timestamp('1931-06-5')
4723+
4724+
# Try to shift to April 31, then shift back to Apr 30 to get a real date
4725+
assert liboffsets.shift_month(ts, -1, 31) == Timestamp('1929-04-30')

pandas/tseries/offsets.py

+17-64
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from pandas._libs.tslibs.offsets import (
1919
ApplyTypeError,
2020
as_datetime, _is_normalized,
21-
_get_firstbday, _get_calendar, _to_dt64, _validate_business_time,
21+
get_firstbday, get_lastbday,
22+
_get_calendar, _to_dt64, _validate_business_time,
2223
_int_to_weekday, _weekday_to_int,
2324
_determine_offset,
2425
apply_index_wraps,
@@ -1181,18 +1182,14 @@ class BusinessMonthEnd(MonthOffset):
11811182
def apply(self, other):
11821183
n = self.n
11831184
wkday, days_in_month = tslib.monthrange(other.year, other.month)
1184-
lastBDay = days_in_month - max(((wkday + days_in_month - 1)
1185-
% 7) - 4, 0)
1185+
lastBDay = get_lastbday(wkday, days_in_month)
11861186

11871187
if n > 0 and not other.day >= lastBDay:
11881188
n = n - 1
11891189
elif n <= 0 and other.day > lastBDay:
11901190
n = n + 1
1191-
other = shift_month(other, n, 'end')
11921191

1193-
if other.weekday() > 4:
1194-
other = other - BDay()
1195-
return other
1192+
return shift_month(other, n, 'business_end')
11961193

11971194

11981195
class BusinessMonthBegin(MonthOffset):
@@ -1203,7 +1200,7 @@ class BusinessMonthBegin(MonthOffset):
12031200
def apply(self, other):
12041201
n = self.n
12051202
wkday, _ = tslib.monthrange(other.year, other.month)
1206-
first = _get_firstbday(wkday)
1203+
first = get_firstbday(wkday)
12071204

12081205
if other.day > first and n <= 0:
12091206
# as if rolled forward already
@@ -1212,24 +1209,13 @@ def apply(self, other):
12121209
other = other + timedelta(days=first - other.day)
12131210
n -= 1
12141211

1215-
other = shift_month(other, n, None)
1216-
wkday, _ = tslib.monthrange(other.year, other.month)
1217-
first = _get_firstbday(wkday)
1218-
result = datetime(other.year, other.month, first,
1219-
other.hour, other.minute,
1220-
other.second, other.microsecond)
1221-
return result
1212+
return shift_month(other, n, 'business_start')
12221213

12231214
def onOffset(self, dt):
12241215
if self.normalize and not _is_normalized(dt):
12251216
return False
12261217
first_weekday, _ = tslib.monthrange(dt.year, dt.month)
1227-
if first_weekday == 5:
1228-
return dt.day == 3
1229-
elif first_weekday == 6:
1230-
return dt.day == 2
1231-
else:
1232-
return dt.day == 1
1218+
return dt.day == get_firstbday(first_weekday)
12331219

12341220

12351221
class CustomBusinessMonthEnd(BusinessMixin, MonthOffset):
@@ -1611,10 +1597,7 @@ def _from_name(cls, suffix=None):
16111597

16121598
class QuarterOffset(DateOffset):
16131599
"""Quarter representation - doesn't call super"""
1614-
1615-
#: default month for __init__
16161600
_default_startingMonth = None
1617-
#: default month in _from_name
16181601
_from_name_startingMonth = None
16191602
_adjust_dst = True
16201603
# TODO: Consider combining QuarterOffset and YearOffset __init__ at some
@@ -1656,21 +1639,15 @@ class BQuarterEnd(QuarterOffset):
16561639
"""
16571640
_outputName = 'BusinessQuarterEnd'
16581641
_default_startingMonth = 3
1659-
# 'BQ'
16601642
_from_name_startingMonth = 12
16611643
_prefix = 'BQ'
16621644

16631645
@apply_wraps
16641646
def apply(self, other):
16651647
n = self.n
1666-
base = other
1667-
other = datetime(other.year, other.month, other.day,
1668-
other.hour, other.minute, other.second,
1669-
other.microsecond)
16701648

16711649
wkday, days_in_month = tslib.monthrange(other.year, other.month)
1672-
lastBDay = days_in_month - max(((wkday + days_in_month - 1)
1673-
% 7) - 4, 0)
1650+
lastBDay = get_lastbday(wkday, days_in_month)
16741651

16751652
monthsToGo = 3 - ((other.month - self.startingMonth) % 3)
16761653
if monthsToGo == 3:
@@ -1681,11 +1658,7 @@ def apply(self, other):
16811658
elif n <= 0 and other.day > lastBDay and monthsToGo == 0:
16821659
n = n + 1
16831660

1684-
other = shift_month(other, monthsToGo + 3 * n, 'end')
1685-
other = tslib._localize_pydatetime(other, base.tzinfo)
1686-
if other.weekday() > 4:
1687-
other = other - BDay()
1688-
return other
1661+
return shift_month(other, monthsToGo + 3 * n, 'business_end')
16891662

16901663
def onOffset(self, dt):
16911664
if self.normalize and not _is_normalized(dt):
@@ -1711,7 +1684,7 @@ def apply(self, other):
17111684
n = self.n
17121685
wkday, _ = tslib.monthrange(other.year, other.month)
17131686

1714-
first = _get_firstbday(wkday)
1687+
first = get_firstbday(wkday)
17151688

17161689
monthsSince = (other.month - self.startingMonth) % 3
17171690

@@ -1725,14 +1698,7 @@ def apply(self, other):
17251698
elif n > 0 and (monthsSince == 0 and other.day < first):
17261699
n = n - 1
17271700

1728-
# get the first bday for result
1729-
other = shift_month(other, 3 * n - monthsSince, None)
1730-
wkday, _ = tslib.monthrange(other.year, other.month)
1731-
first = _get_firstbday(wkday)
1732-
result = datetime(other.year, other.month, first,
1733-
other.hour, other.minute, other.second,
1734-
other.microsecond)
1735-
return result
1701+
return shift_month(other, 3 * n - monthsSince, 'business_start')
17361702

17371703

17381704
class QuarterEnd(EndMixin, QuarterOffset):
@@ -1841,8 +1807,7 @@ class BYearEnd(YearOffset):
18411807
def apply(self, other):
18421808
n = self.n
18431809
wkday, days_in_month = tslib.monthrange(other.year, self.month)
1844-
lastBDay = (days_in_month -
1845-
max(((wkday + days_in_month - 1) % 7) - 4, 0))
1810+
lastBDay = get_lastbday(wkday, days_in_month)
18461811

18471812
years = n
18481813
if n > 0:
@@ -1854,17 +1819,8 @@ def apply(self, other):
18541819
(other.month == self.month and other.day > lastBDay)):
18551820
years += 1
18561821

1857-
other = shift_month(other, 12 * years, None)
1858-
1859-
_, days_in_month = tslib.monthrange(other.year, self.month)
1860-
result = datetime(other.year, self.month, days_in_month,
1861-
other.hour, other.minute, other.second,
1862-
other.microsecond)
1863-
1864-
if result.weekday() > 4:
1865-
result = result - BDay()
1866-
1867-
return result
1822+
months = years * 12 + (self.month - other.month)
1823+
return shift_month(other, months, 'business_end')
18681824

18691825

18701826
class BYearBegin(YearOffset):
@@ -1878,7 +1834,7 @@ def apply(self, other):
18781834
n = self.n
18791835
wkday, days_in_month = tslib.monthrange(other.year, self.month)
18801836

1881-
first = _get_firstbday(wkday)
1837+
first = get_firstbday(wkday)
18821838

18831839
years = n
18841840

@@ -1892,11 +1848,8 @@ def apply(self, other):
18921848
years += 1
18931849

18941850
# set first bday for result
1895-
other = shift_month(other, years * 12, None)
1896-
wkday, days_in_month = tslib.monthrange(other.year, self.month)
1897-
first = _get_firstbday(wkday)
1898-
return datetime(other.year, self.month, first, other.hour,
1899-
other.minute, other.second, other.microsecond)
1851+
months = years * 12 + (self.month - other.month)
1852+
return shift_month(other, months, 'business_start')
19001853

19011854

19021855
class YearEnd(EndMixin, YearOffset):

0 commit comments

Comments
 (0)