diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 4cf8eeb52475b..984c0c702c28d 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -1,5 +1,6 @@ import cython +import operator import time from typing import Any import warnings @@ -1163,7 +1164,140 @@ cdef class BusinessMixin(SingleConstructorOffset): BaseOffset.__setstate__(self, state) -cdef class BusinessHourMixin(BusinessMixin): +cdef class BusinessDay(BusinessMixin): + """ + DateOffset subclass representing possibly n business days. + """ + + _prefix = "B" + _attributes = frozenset(["n", "normalize", "offset"]) + + cpdef __setstate__(self, state): + self.n = state.pop("n") + self.normalize = state.pop("normalize") + if "_offset" in state: + self._offset = state.pop("_offset") + elif "offset" in state: + self._offset = state.pop("offset") + + def __reduce__(self): + tup = (self.n, self.normalize, self.offset) + return type(self), tup + + @property + def _params(self): + # FIXME: using cache_readonly breaks a pytables test + return BaseOffset._params.func(self) + + def _offset_str(self) -> str: + def get_str(td): + off_str = "" + if td.days > 0: + off_str += str(td.days) + "D" + if td.seconds > 0: + s = td.seconds + hrs = int(s / 3600) + if hrs != 0: + off_str += str(hrs) + "H" + s -= hrs * 3600 + mts = int(s / 60) + if mts != 0: + off_str += str(mts) + "Min" + s -= mts * 60 + if s != 0: + off_str += str(s) + "s" + if td.microseconds > 0: + off_str += str(td.microseconds) + "us" + return off_str + + if isinstance(self.offset, timedelta): + zero = timedelta(0, 0, 0) + if self.offset >= zero: + off_str = "+" + get_str(self.offset) + else: + off_str = "-" + get_str(-self.offset) + return off_str + else: + return "+" + repr(self.offset) + + @apply_wraps + def apply(self, other): + if isinstance(other, datetime): + n = self.n + wday = other.weekday() + + # avoid slowness below by operating on weeks first + weeks = n // 5 + if n <= 0 and wday > 4: + # roll forward + n += 1 + + n -= 5 * weeks + + # n is always >= 0 at this point + if n == 0 and wday > 4: + # roll back + days = 4 - wday + elif wday > 4: + # roll forward + days = (7 - wday) + (n - 1) + elif wday + n <= 4: + # shift by n days without leaving the current week + days = n + else: + # shift by n days plus 2 to get past the weekend + days = n + 2 + + result = other + timedelta(days=7 * weeks + days) + if self.offset: + result = result + self.offset + return result + + elif isinstance(other, (timedelta, Tick)): + return BusinessDay( + self.n, offset=self.offset + other, normalize=self.normalize + ) + else: + raise ApplyTypeError( + "Only know how to combine business day with datetime or timedelta." + ) + + @apply_index_wraps + def apply_index(self, dtindex): + time = dtindex.to_perioddelta("D") + # to_period rolls forward to next BDay; track and + # reduce n where it does when rolling forward + asper = dtindex.to_period("B") + + if self.n > 0: + shifted = (dtindex.to_perioddelta("B") - time).asi8 != 0 + + # Integer-array addition is deprecated, so we use + # _time_shift directly + roll = np.where(shifted, self.n - 1, self.n) + shifted = asper._addsub_int_array(roll, operator.add) + else: + # Integer addition is deprecated, so we use _time_shift directly + roll = self.n + shifted = asper._time_shift(roll) + + result = shifted.to_timestamp() + time + return result + + def is_on_offset(self, dt) -> bool: + if self.normalize and not is_normalized(dt): + return False + return dt.weekday() < 5 + + +cdef class BusinessHour(BusinessMixin): + """ + DateOffset subclass representing possibly n business hours. + """ + + _prefix = "BH" + _anchor = 0 + _attributes = frozenset(["n", "normalize", "start", "end", "offset"]) _adjust_dst = False cdef readonly: @@ -1218,6 +1352,18 @@ cdef class BusinessHourMixin(BusinessMixin): self.start = start self.end = end + cpdef __setstate__(self, state): + start = state.pop("start") + start = (start,) if np.ndim(start) == 0 else tuple(start) + end = state.pop("end") + end = (end,) if np.ndim(end) == 0 else tuple(end) + self.start = start + self.end = end + + state.pop("kwds", {}) + state.pop("next_bday", None) + BusinessMixin.__setstate__(self, state) + def __reduce__(self): return type(self), (self.n, self.normalize, self.start, self.end, self.offset) @@ -1262,6 +1408,267 @@ cdef class BusinessHourMixin(BusinessMixin): ) assert False + @cache_readonly + def next_bday(self): + """ + Used for moving to next business day. + """ + if self.n >= 0: + nb_offset = 1 + else: + nb_offset = -1 + if self._prefix.startswith("C"): + # CustomBusinessHour + from pandas.tseries.offsets import CustomBusinessDay + return CustomBusinessDay( + n=nb_offset, + weekmask=self.weekmask, + holidays=self.holidays, + calendar=self.calendar, + ) + else: + return BusinessDay(n=nb_offset) + + def _next_opening_time(self, other, sign=1): + """ + If self.n and sign have the same sign, return the earliest opening time + later than or equal to current time. + Otherwise the latest opening time earlier than or equal to current + time. + + Opening time always locates on BusinessDay. + However, closing time may not if business hour extends over midnight. + + Parameters + ---------- + other : datetime + Current time. + sign : int, default 1. + Either 1 or -1. Going forward in time if it has the same sign as + self.n. Going backward in time otherwise. + + Returns + ------- + result : datetime + Next opening time. + """ + earliest_start = self.start[0] + latest_start = self.start[-1] + + if not self.next_bday.is_on_offset(other): + # today is not business day + other = other + sign * self.next_bday + if self.n * sign >= 0: + hour, minute = earliest_start.hour, earliest_start.minute + else: + hour, minute = latest_start.hour, latest_start.minute + else: + if self.n * sign >= 0: + if latest_start < other.time(): + # current time is after latest starting time in today + other = other + sign * self.next_bday + hour, minute = earliest_start.hour, earliest_start.minute + else: + # find earliest starting time no earlier than current time + for st in self.start: + if other.time() <= st: + hour, minute = st.hour, st.minute + break + else: + if other.time() < earliest_start: + # current time is before earliest starting time in today + other = other + sign * self.next_bday + hour, minute = latest_start.hour, latest_start.minute + else: + # find latest starting time no later than current time + for st in reversed(self.start): + if other.time() >= st: + hour, minute = st.hour, st.minute + break + + return datetime(other.year, other.month, other.day, hour, minute) + + def _prev_opening_time(self, other): + """ + If n is positive, return the latest opening time earlier than or equal + to current time. + Otherwise the earliest opening time later than or equal to current + time. + + Parameters + ---------- + other : datetime + Current time. + + Returns + ------- + result : datetime + Previous opening time. + """ + return self._next_opening_time(other, sign=-1) + + @apply_wraps + def rollback(self, dt): + """ + Roll provided date backward to next offset only if not on offset. + """ + if not self.is_on_offset(dt): + if self.n >= 0: + dt = self._prev_opening_time(dt) + else: + dt = self._next_opening_time(dt) + return self._get_closing_time(dt) + return dt + + @apply_wraps + def rollforward(self, dt): + """ + Roll provided date forward to next offset only if not on offset. + """ + if not self.is_on_offset(dt): + if self.n >= 0: + return self._next_opening_time(dt) + else: + return self._prev_opening_time(dt) + return dt + + @apply_wraps + def apply(self, other): + if isinstance(other, datetime): + # used for detecting edge condition + nanosecond = getattr(other, "nanosecond", 0) + # reset timezone and nanosecond + # other may be a Timestamp, thus not use replace + other = datetime( + other.year, + other.month, + other.day, + other.hour, + other.minute, + other.second, + other.microsecond, + ) + n = self.n + + # adjust other to reduce number of cases to handle + if n >= 0: + if other.time() in self.end or not self._is_on_offset(other): + other = self._next_opening_time(other) + else: + if other.time() in self.start: + # adjustment to move to previous business day + other = other - timedelta(seconds=1) + if not self._is_on_offset(other): + other = self._next_opening_time(other) + other = self._get_closing_time(other) + + # get total business hours by sec in one business day + businesshours = sum( + self._get_business_hours_by_sec(st, en) + for st, en in zip(self.start, self.end) + ) + + bd, r = divmod(abs(n * 60), businesshours // 60) + if n < 0: + bd, r = -bd, -r + + # adjust by business days first + if bd != 0: + if self._prefix.startswith("C"): + # GH#30593 this is a Custom offset + from pandas.tseries.offsets import CustomBusinessDay + skip_bd = CustomBusinessDay( + n=bd, + weekmask=self.weekmask, + holidays=self.holidays, + calendar=self.calendar, + ) + else: + skip_bd = BusinessDay(n=bd) + # midnight business hour may not on BusinessDay + if not self.next_bday.is_on_offset(other): + prev_open = self._prev_opening_time(other) + remain = other - prev_open + other = prev_open + skip_bd + remain + else: + other = other + skip_bd + + # remaining business hours to adjust + bhour_remain = timedelta(minutes=r) + + if n >= 0: + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = ( + self._get_closing_time(self._prev_opening_time(other)) - other + ) + if bhour_remain < bhour: + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) + else: + # go to next business time interval + bhour_remain -= bhour + other = self._next_opening_time(other + bhour) + else: + while bhour_remain != timedelta(0): + # business hour left in this business time interval + bhour = self._next_opening_time(other) - other + if ( + bhour_remain > bhour + or bhour_remain == bhour + and nanosecond != 0 + ): + # finish adjusting if possible + other += bhour_remain + bhour_remain = timedelta(0) + else: + # go to next business time interval + bhour_remain -= bhour + other = self._get_closing_time( + self._next_opening_time( + other + bhour - timedelta(seconds=1) + ) + ) + + return other + else: + raise ApplyTypeError("Only know how to combine business hour with datetime") + + def is_on_offset(self, dt): + if self.normalize and not is_normalized(dt): + return False + + if dt.tzinfo is not None: + dt = datetime( + dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond + ) + # Valid BH can be on the different BusinessDay during midnight + # Distinguish by the time spent from previous opening time + return self._is_on_offset(dt) + + def _is_on_offset(self, dt): + """ + Slight speedups using calculated values. + """ + # if self.normalize and not is_normalized(dt): + # return False + # Valid BH can be on the different BusinessDay during midnight + # Distinguish by the time spent from previous opening time + if self.n >= 0: + op = self._prev_opening_time(dt) + else: + op = self._next_opening_time(dt) + span = (dt - op).total_seconds() + businesshours = 0 + for i, st in enumerate(self.start): + if op.hour == st.hour and op.minute == st.minute: + businesshours = self._get_business_hours_by_sec(st, self.end[i]) + if span <= businesshours: + return True + else: + return False + class CustomMixin: """ diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index c3ad48d5ce34d..5cf70229cd5e7 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -17,6 +17,8 @@ BaseOffset, BQuarterBegin, BQuarterEnd, + BusinessDay, + BusinessHour, BusinessMixin, BusinessMonthBegin, BusinessMonthEnd, @@ -204,385 +206,6 @@ def __add__(date): pass -class BusinessDay(BusinessMixin): - """ - DateOffset subclass representing possibly n business days. - """ - - _prefix = "B" - _attributes = frozenset(["n", "normalize", "offset"]) - - def __reduce__(self): - tup = (self.n, self.normalize, self.offset) - return type(self), tup - - def _offset_str(self) -> str: - def get_str(td): - off_str = "" - if td.days > 0: - off_str += str(td.days) + "D" - if td.seconds > 0: - s = td.seconds - hrs = int(s / 3600) - if hrs != 0: - off_str += str(hrs) + "H" - s -= hrs * 3600 - mts = int(s / 60) - if mts != 0: - off_str += str(mts) + "Min" - s -= mts * 60 - if s != 0: - off_str += str(s) + "s" - if td.microseconds > 0: - off_str += str(td.microseconds) + "us" - return off_str - - if isinstance(self.offset, timedelta): - zero = timedelta(0, 0, 0) - if self.offset >= zero: - off_str = "+" + get_str(self.offset) - else: - off_str = "-" + get_str(-self.offset) - return off_str - else: - return "+" + repr(self.offset) - - @apply_wraps - def apply(self, other): - if isinstance(other, datetime): - n = self.n - wday = other.weekday() - - # avoid slowness below by operating on weeks first - weeks = n // 5 - if n <= 0 and wday > 4: - # roll forward - n += 1 - - n -= 5 * weeks - - # n is always >= 0 at this point - if n == 0 and wday > 4: - # roll back - days = 4 - wday - elif wday > 4: - # roll forward - days = (7 - wday) + (n - 1) - elif wday + n <= 4: - # shift by n days without leaving the current week - days = n - else: - # shift by n days plus 2 to get past the weekend - days = n + 2 - - result = other + timedelta(days=7 * weeks + days) - if self.offset: - result = result + self.offset - return result - - elif isinstance(other, (timedelta, Tick)): - return BDay(self.n, offset=self.offset + other, normalize=self.normalize) - else: - raise ApplyTypeError( - "Only know how to combine business day with datetime or timedelta." - ) - - @apply_index_wraps - def apply_index(self, i): - time = i.to_perioddelta("D") - # to_period rolls forward to next BDay; track and - # reduce n where it does when rolling forward - asper = i.to_period("B") - - if self.n > 0: - shifted = (i.to_perioddelta("B") - time).asi8 != 0 - - # Integer-array addition is deprecated, so we use - # _time_shift directly - roll = np.where(shifted, self.n - 1, self.n) - shifted = asper._addsub_int_array(roll, operator.add) - else: - # Integer addition is deprecated, so we use _time_shift directly - roll = self.n - shifted = asper._time_shift(roll) - - result = shifted.to_timestamp() + time - return result - - def is_on_offset(self, dt: datetime) -> bool: - if self.normalize and not is_normalized(dt): - return False - return dt.weekday() < 5 - - -class BusinessHour(liboffsets.BusinessHourMixin): - """ - DateOffset subclass representing possibly n business hours. - """ - - _prefix = "BH" - _anchor = 0 - _attributes = frozenset(["n", "normalize", "start", "end", "offset"]) - - @cache_readonly - def next_bday(self): - """ - Used for moving to next business day. - """ - if self.n >= 0: - nb_offset = 1 - else: - nb_offset = -1 - if self._prefix.startswith("C"): - # CustomBusinessHour - return CustomBusinessDay( - n=nb_offset, - weekmask=self.weekmask, - holidays=self.holidays, - calendar=self.calendar, - ) - else: - return BusinessDay(n=nb_offset) - - def _next_opening_time(self, other, sign=1): - """ - If self.n and sign have the same sign, return the earliest opening time - later than or equal to current time. - Otherwise the latest opening time earlier than or equal to current - time. - - Opening time always locates on BusinessDay. - However, closing time may not if business hour extends over midnight. - - Parameters - ---------- - other : datetime - Current time. - sign : int, default 1. - Either 1 or -1. Going forward in time if it has the same sign as - self.n. Going backward in time otherwise. - - Returns - ------- - result : datetime - Next opening time. - """ - earliest_start = self.start[0] - latest_start = self.start[-1] - - if not self.next_bday.is_on_offset(other): - # today is not business day - other = other + sign * self.next_bday - if self.n * sign >= 0: - hour, minute = earliest_start.hour, earliest_start.minute - else: - hour, minute = latest_start.hour, latest_start.minute - else: - if self.n * sign >= 0: - if latest_start < other.time(): - # current time is after latest starting time in today - other = other + sign * self.next_bday - hour, minute = earliest_start.hour, earliest_start.minute - else: - # find earliest starting time no earlier than current time - for st in self.start: - if other.time() <= st: - hour, minute = st.hour, st.minute - break - else: - if other.time() < earliest_start: - # current time is before earliest starting time in today - other = other + sign * self.next_bday - hour, minute = latest_start.hour, latest_start.minute - else: - # find latest starting time no later than current time - for st in reversed(self.start): - if other.time() >= st: - hour, minute = st.hour, st.minute - break - - return datetime(other.year, other.month, other.day, hour, minute) - - def _prev_opening_time(self, other): - """ - If n is positive, return the latest opening time earlier than or equal - to current time. - Otherwise the earliest opening time later than or equal to current - time. - - Parameters - ---------- - other : datetime - Current time. - - Returns - ------- - result : datetime - Previous opening time. - """ - return self._next_opening_time(other, sign=-1) - - @apply_wraps - def rollback(self, dt): - """ - Roll provided date backward to next offset only if not on offset. - """ - if not self.is_on_offset(dt): - if self.n >= 0: - dt = self._prev_opening_time(dt) - else: - dt = self._next_opening_time(dt) - return self._get_closing_time(dt) - return dt - - @apply_wraps - def rollforward(self, dt): - """ - Roll provided date forward to next offset only if not on offset. - """ - if not self.is_on_offset(dt): - if self.n >= 0: - return self._next_opening_time(dt) - else: - return self._prev_opening_time(dt) - return dt - - @apply_wraps - def apply(self, other): - if isinstance(other, datetime): - # used for detecting edge condition - nanosecond = getattr(other, "nanosecond", 0) - # reset timezone and nanosecond - # other may be a Timestamp, thus not use replace - other = datetime( - other.year, - other.month, - other.day, - other.hour, - other.minute, - other.second, - other.microsecond, - ) - n = self.n - - # adjust other to reduce number of cases to handle - if n >= 0: - if other.time() in self.end or not self._is_on_offset(other): - other = self._next_opening_time(other) - else: - if other.time() in self.start: - # adjustment to move to previous business day - other = other - timedelta(seconds=1) - if not self._is_on_offset(other): - other = self._next_opening_time(other) - other = self._get_closing_time(other) - - # get total business hours by sec in one business day - businesshours = sum( - self._get_business_hours_by_sec(st, en) - for st, en in zip(self.start, self.end) - ) - - bd, r = divmod(abs(n * 60), businesshours // 60) - if n < 0: - bd, r = -bd, -r - - # adjust by business days first - if bd != 0: - if isinstance(self, CustomMixin): # GH 30593 - skip_bd = CustomBusinessDay( - n=bd, - weekmask=self.weekmask, - holidays=self.holidays, - calendar=self.calendar, - ) - else: - skip_bd = BusinessDay(n=bd) - # midnight business hour may not on BusinessDay - if not self.next_bday.is_on_offset(other): - prev_open = self._prev_opening_time(other) - remain = other - prev_open - other = prev_open + skip_bd + remain - else: - other = other + skip_bd - - # remaining business hours to adjust - bhour_remain = timedelta(minutes=r) - - if n >= 0: - while bhour_remain != timedelta(0): - # business hour left in this business time interval - bhour = ( - self._get_closing_time(self._prev_opening_time(other)) - other - ) - if bhour_remain < bhour: - # finish adjusting if possible - other += bhour_remain - bhour_remain = timedelta(0) - else: - # go to next business time interval - bhour_remain -= bhour - other = self._next_opening_time(other + bhour) - else: - while bhour_remain != timedelta(0): - # business hour left in this business time interval - bhour = self._next_opening_time(other) - other - if ( - bhour_remain > bhour - or bhour_remain == bhour - and nanosecond != 0 - ): - # finish adjusting if possible - other += bhour_remain - bhour_remain = timedelta(0) - else: - # go to next business time interval - bhour_remain -= bhour - other = self._get_closing_time( - self._next_opening_time( - other + bhour - timedelta(seconds=1) - ) - ) - - return other - else: - raise ApplyTypeError("Only know how to combine business hour with datetime") - - def is_on_offset(self, dt): - if self.normalize and not is_normalized(dt): - return False - - if dt.tzinfo is not None: - dt = datetime( - dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.microsecond - ) - # Valid BH can be on the different BusinessDay during midnight - # Distinguish by the time spent from previous opening time - return self._is_on_offset(dt) - - def _is_on_offset(self, dt): - """ - Slight speedups using calculated values. - """ - # if self.normalize and not is_normalized(dt): - # return False - # Valid BH can be on the different BusinessDay during midnight - # Distinguish by the time spent from previous opening time - if self.n >= 0: - op = self._prev_opening_time(dt) - else: - op = self._next_opening_time(dt) - span = (dt - op).total_seconds() - businesshours = 0 - for i, st in enumerate(self.start): - if op.hour == st.hour and op.minute == st.minute: - businesshours = self._get_business_hours_by_sec(st, self.end[i]) - if span <= businesshours: - return True - else: - return False - - class CustomBusinessDay(CustomMixin, BusinessDay): """ DateOffset subclass representing custom business days excluding holidays. @@ -624,6 +247,11 @@ def __init__( BusinessDay.__init__(self, n, normalize, offset) CustomMixin.__init__(self, weekmask, holidays, calendar) + def __setstate__(self, state): + self.holidays = state.pop("holidays") + self.weekmask = state.pop("weekmask") + super().__setstate__(state) + @apply_wraps def apply(self, other): if self.n <= 0: