-
-
Notifications
You must be signed in to change notification settings - Fork 18.6k
ENH: Add SemiMonthEnd and SemiMonthBegin offsets #1543 #13315
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,7 +4,8 @@ | |
import numpy as np | ||
|
||
from pandas.tseries.tools import to_datetime, normalize_date | ||
from pandas.core.common import ABCSeries, ABCDatetimeIndex, ABCPeriod | ||
from pandas.core.common import (ABCSeries, ABCDatetimeIndex, ABCPeriod, | ||
AbstractMethodError) | ||
|
||
# import after tools, dateutil check | ||
from dateutil.relativedelta import relativedelta, weekday | ||
|
@@ -18,6 +19,7 @@ | |
__all__ = ['Day', 'BusinessDay', 'BDay', 'CustomBusinessDay', 'CDay', | ||
'CBMonthEnd', 'CBMonthBegin', | ||
'MonthBegin', 'BMonthBegin', 'MonthEnd', 'BMonthEnd', | ||
'SemiMonthEnd', 'SemiMonthBegin', | ||
'BusinessHour', 'CustomBusinessHour', | ||
'YearBegin', 'BYearBegin', 'YearEnd', 'BYearEnd', | ||
'QuarterBegin', 'BQuarterBegin', 'QuarterEnd', 'BQuarterEnd', | ||
|
@@ -1160,6 +1162,214 @@ def onOffset(self, dt): | |
_prefix = 'MS' | ||
|
||
|
||
class SemiMonthOffset(DateOffset): | ||
_adjust_dst = True | ||
_default_day_of_month = 15 | ||
_min_day_of_month = 2 | ||
|
||
def __init__(self, n=1, day_of_month=None, normalize=False, **kwds): | ||
if day_of_month is None: | ||
self.day_of_month = self._default_day_of_month | ||
else: | ||
self.day_of_month = int(day_of_month) | ||
if not self._min_day_of_month <= self.day_of_month <= 27: | ||
raise ValueError('day_of_month must be ' | ||
'{}<=day_of_month<=27, got {}'.format( | ||
self._min_day_of_month, self.day_of_month)) | ||
self.n = int(n) | ||
self.normalize = normalize | ||
self.kwds = kwds | ||
self.kwds['day_of_month'] = self.day_of_month | ||
|
||
@classmethod | ||
def _from_name(cls, suffix=None): | ||
return cls(day_of_month=suffix) | ||
|
||
@property | ||
def rule_code(self): | ||
suffix = '-{}'.format(self.day_of_month) | ||
return self._prefix + suffix | ||
|
||
@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 += relativedelta(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 += relativedelta(day=self.day_of_month) | ||
if n < 0: | ||
# rollforward in the negative direction so add 1 | ||
n += 1 | ||
elif n == 0: | ||
n = 1 | ||
|
||
return self._apply(n, other) | ||
|
||
def _apply(self, n, other): | ||
"""Handle specific apply logic for child classes""" | ||
raise AbstractMethodError(self) | ||
|
||
@apply_index_wraps | ||
def apply_index(self, i): | ||
# determine how many days away from the 1st of the month we are | ||
days_from_start = i.to_perioddelta('M').asi8 | ||
delta = Timedelta(days=self.day_of_month - 1).value | ||
|
||
# get boolean array for each element before the day_of_month | ||
before_day_of_month = days_from_start < delta | ||
|
||
# get boolean array for each element after the day_of_month | ||
after_day_of_month = days_from_start > delta | ||
|
||
# determine the correct n for each date in i | ||
roll = self._get_roll(i, before_day_of_month, after_day_of_month) | ||
|
||
# isolate the time since it will be striped away one the next line | ||
time = i.to_perioddelta('D') | ||
|
||
# apply the correct number of months | ||
i = (i.to_period('M') + (roll // 2)).to_timestamp() | ||
|
||
# apply the correct day | ||
i = self._apply_index_days(i, roll) | ||
|
||
return i + time | ||
|
||
def _get_roll(self, i, before_day_of_month, after_day_of_month): | ||
"""Return an array with the correct n for each date in i. | ||
|
||
The roll array is based on the fact that i gets rolled back to | ||
the first day of the month. | ||
""" | ||
raise AbstractMethodError(self) | ||
|
||
def _apply_index_days(self, i, roll): | ||
"""Apply the correct day for each date in i""" | ||
raise AbstractMethodError(self) | ||
|
||
|
||
class SemiMonthEnd(SemiMonthOffset): | ||
""" | ||
Two DateOffset's per month repeating on the last | ||
day of the month and day_of_month. | ||
|
||
.. versionadded:: 0.18.2 | ||
|
||
Parameters | ||
---------- | ||
n: int | ||
normalize : bool, default False | ||
day_of_month: int, {1, 3,...,27}, default 15 | ||
""" | ||
_prefix = 'SM' | ||
_min_day_of_month = 1 | ||
|
||
def onOffset(self, dt): | ||
if self.normalize and not _is_normalized(dt): | ||
return False | ||
_, days_in_month = tslib.monthrange(dt.year, dt.month) | ||
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 other.day < self.day_of_month: | ||
other += relativedelta(day=self.day_of_month) | ||
if n > 0: | ||
n -= 1 | ||
elif other.day > self.day_of_month: | ||
other += relativedelta(day=self.day_of_month) | ||
if n == 0: | ||
n = 1 | ||
else: | ||
n += 1 | ||
|
||
months = n // 2 | ||
day = 31 if n % 2 else self.day_of_month | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do you have a tests that goes thru all of the months in a single year and verifies correctness? what about leap year for feb? |
||
return other + relativedelta(months=months, day=day) | ||
|
||
def _get_roll(self, i, before_day_of_month, after_day_of_month): | ||
n = self.n | ||
is_month_end = i.is_month_end | ||
if n > 0: | ||
roll_end = np.where(is_month_end, 1, 0) | ||
roll_before = np.where(before_day_of_month, n, n + 1) | ||
roll = roll_end + roll_before | ||
elif n == 0: | ||
roll_after = np.where(after_day_of_month, 2, 0) | ||
roll_before = np.where(~after_day_of_month, 1, 0) | ||
roll = roll_before + roll_after | ||
else: | ||
roll = np.where(after_day_of_month, n + 2, n + 1) | ||
return roll | ||
|
||
def _apply_index_days(self, i, roll): | ||
i += (roll % 2) * Timedelta(days=self.day_of_month).value | ||
return i + Timedelta(days=-1) | ||
|
||
|
||
class SemiMonthBegin(SemiMonthOffset): | ||
""" | ||
Two DateOffset's per month repeating on the first | ||
day of the month and day_of_month. | ||
|
||
.. versionadded:: 0.18.2 | ||
|
||
Parameters | ||
---------- | ||
n: int | ||
normalize : bool, default False | ||
day_of_month: int, {2, 3,...,27}, default 15 | ||
""" | ||
_prefix = 'SMS' | ||
|
||
def onOffset(self, dt): | ||
if self.normalize and not _is_normalized(dt): | ||
return False | ||
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: | ||
other += relativedelta(day=self.day_of_month) | ||
if n == 0: | ||
n = -1 | ||
else: | ||
n -= 1 | ||
elif other.day > self.day_of_month: | ||
other += relativedelta(day=self.day_of_month) | ||
if n == 0: | ||
n = 1 | ||
elif n < 0: | ||
n += 1 | ||
|
||
months = n // 2 + n % 2 | ||
day = 1 if n % 2 else self.day_of_month | ||
return other + relativedelta(months=months, day=day) | ||
|
||
def _get_roll(self, i, before_day_of_month, after_day_of_month): | ||
n = self.n | ||
is_month_start = i.is_month_start | ||
if n > 0: | ||
roll = np.where(before_day_of_month, n, n + 1) | ||
elif n == 0: | ||
roll_start = np.where(is_month_start, 0, 1) | ||
roll_after = np.where(after_day_of_month, 1, 0) | ||
roll = roll_start + roll_after | ||
else: | ||
roll_after = np.where(after_day_of_month, n + 2, n + 1) | ||
roll_start = np.where(is_month_start, -1, 0) | ||
roll = roll_after + roll_start | ||
return roll | ||
|
||
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""" | ||
|
||
|
@@ -2720,6 +2930,8 @@ def generate_range(start=None, end=None, periods=None, | |
CustomBusinessHour, # 'CBH' | ||
MonthEnd, # 'M' | ||
MonthBegin, # 'MS' | ||
SemiMonthEnd, # 'SM' | ||
SemiMonthBegin, # 'SMS' | ||
Week, # 'W' | ||
Second, # 'S' | ||
Minute, # 'T' | ||
|
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe shorten the examples and put 2 (or 3) for each sub-section.