4596 lines
140 KiB
Cython
4596 lines
140 KiB
Cython
import re
|
|
import time
|
|
|
|
cimport cython
|
|
from cpython.datetime cimport (
|
|
PyDate_Check,
|
|
PyDateTime_Check,
|
|
PyDelta_Check,
|
|
date,
|
|
datetime,
|
|
import_datetime,
|
|
time as dt_time,
|
|
timedelta,
|
|
)
|
|
|
|
import_datetime()
|
|
|
|
from dateutil.easter import easter
|
|
from dateutil.relativedelta import relativedelta
|
|
import numpy as np
|
|
|
|
cimport numpy as cnp
|
|
from numpy cimport (
|
|
int64_t,
|
|
ndarray,
|
|
)
|
|
|
|
cnp.import_array()
|
|
|
|
# TODO: formalize having _libs.properties "above" tslibs in the dependency structure
|
|
|
|
from pandas._libs.properties import cache_readonly
|
|
|
|
from pandas._libs.tslibs cimport util
|
|
from pandas._libs.tslibs.util cimport (
|
|
is_datetime64_object,
|
|
is_float_object,
|
|
is_integer_object,
|
|
)
|
|
|
|
from pandas._libs.tslibs.ccalendar import (
|
|
MONTH_ALIASES,
|
|
MONTH_TO_CAL_NUM,
|
|
int_to_weekday,
|
|
weekday_to_int,
|
|
)
|
|
|
|
from pandas._libs.tslibs.ccalendar cimport (
|
|
dayofweek,
|
|
get_days_in_month,
|
|
get_firstbday,
|
|
get_lastbday,
|
|
)
|
|
from pandas._libs.tslibs.conversion cimport localize_pydatetime
|
|
from pandas._libs.tslibs.dtypes cimport periods_per_day
|
|
from pandas._libs.tslibs.nattype cimport (
|
|
NPY_NAT,
|
|
c_NaT as NaT,
|
|
)
|
|
from pandas._libs.tslibs.np_datetime cimport (
|
|
NPY_DATETIMEUNIT,
|
|
get_unit_from_dtype,
|
|
npy_datetimestruct,
|
|
npy_datetimestruct_to_datetime,
|
|
pandas_datetime_to_datetimestruct,
|
|
pydate_to_dtstruct,
|
|
)
|
|
|
|
from .dtypes cimport PeriodDtypeCode
|
|
from .timedeltas cimport (
|
|
_Timedelta,
|
|
delta_to_nanoseconds,
|
|
is_any_td_scalar,
|
|
)
|
|
|
|
from .timedeltas import Timedelta
|
|
|
|
from .timestamps cimport _Timestamp
|
|
|
|
from .timestamps import Timestamp
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Misc Helpers
|
|
|
|
cdef bint is_offset_object(object obj):
|
|
return isinstance(obj, BaseOffset)
|
|
|
|
|
|
cdef bint is_tick_object(object obj):
|
|
return isinstance(obj, Tick)
|
|
|
|
|
|
cdef datetime _as_datetime(datetime obj):
|
|
if isinstance(obj, _Timestamp):
|
|
return obj.to_pydatetime()
|
|
return obj
|
|
|
|
|
|
cdef bint _is_normalized(datetime dt):
|
|
if dt.hour != 0 or dt.minute != 0 or dt.second != 0 or dt.microsecond != 0:
|
|
# Regardless of whether dt is datetime vs Timestamp
|
|
return False
|
|
if isinstance(dt, _Timestamp):
|
|
return dt.nanosecond == 0
|
|
return True
|
|
|
|
|
|
def apply_wrapper_core(func, self, other) -> ndarray:
|
|
result = func(self, other)
|
|
result = np.asarray(result)
|
|
|
|
if self.normalize:
|
|
# TODO: Avoid circular/runtime import
|
|
from .vectorized import normalize_i8_timestamps
|
|
reso = get_unit_from_dtype(other.dtype)
|
|
result = normalize_i8_timestamps(result.view("i8"), None, reso=reso)
|
|
|
|
return result
|
|
|
|
|
|
def apply_array_wraps(func):
|
|
# Note: normally we would use `@functools.wraps(func)`, but this does
|
|
# not play nicely with cython class methods
|
|
def wrapper(self, other) -> np.ndarray:
|
|
# other is a DatetimeArray
|
|
result = apply_wrapper_core(func, self, other)
|
|
return result
|
|
|
|
# do @functools.wraps(func) manually since it doesn't work on cdef funcs
|
|
wrapper.__name__ = func.__name__
|
|
wrapper.__doc__ = func.__doc__
|
|
return wrapper
|
|
|
|
|
|
def apply_wraps(func):
|
|
# Note: normally we would use `@functools.wraps(func)`, but this does
|
|
# not play nicely with cython class methods
|
|
|
|
def wrapper(self, other):
|
|
|
|
if other is NaT:
|
|
return NaT
|
|
elif (
|
|
isinstance(other, BaseOffset)
|
|
or PyDelta_Check(other)
|
|
or util.is_timedelta64_object(other)
|
|
):
|
|
# timedelta path
|
|
return func(self, other)
|
|
elif is_datetime64_object(other) or PyDate_Check(other):
|
|
# PyDate_Check includes date, datetime
|
|
other = Timestamp(other)
|
|
else:
|
|
# This will end up returning NotImplemented back in __add__
|
|
raise ApplyTypeError
|
|
|
|
tz = other.tzinfo
|
|
nano = other.nanosecond
|
|
|
|
if self._adjust_dst:
|
|
other = other.tz_localize(None)
|
|
|
|
result = func(self, other)
|
|
|
|
result2 = Timestamp(result).as_unit(other.unit)
|
|
if result == result2:
|
|
# i.e. the conversion is non-lossy, not the case for e.g.
|
|
# test_milliseconds_combination
|
|
result = result2
|
|
|
|
if self._adjust_dst:
|
|
result = result.tz_localize(tz)
|
|
|
|
if self.normalize:
|
|
result = result.normalize()
|
|
|
|
# If the offset object does not have a nanoseconds component,
|
|
# the result's nanosecond component may be lost.
|
|
if not self.normalize and nano != 0 and not hasattr(self, "nanoseconds"):
|
|
if result.nanosecond != nano:
|
|
if result.tz is not None:
|
|
# convert to UTC
|
|
res = result.tz_localize(None)
|
|
else:
|
|
res = result
|
|
value = res.as_unit("ns")._value
|
|
result = Timestamp(value + nano)
|
|
|
|
if tz is not None and result.tzinfo is None:
|
|
result = result.tz_localize(tz)
|
|
|
|
return result
|
|
|
|
# do @functools.wraps(func) manually since it doesn't work on cdef funcs
|
|
wrapper.__name__ = func.__name__
|
|
wrapper.__doc__ = func.__doc__
|
|
return wrapper
|
|
|
|
|
|
cdef _wrap_timedelta_result(result):
|
|
"""
|
|
Tick operations dispatch to their Timedelta counterparts. Wrap the result
|
|
of these operations in a Tick if possible.
|
|
|
|
Parameters
|
|
----------
|
|
result : object
|
|
|
|
Returns
|
|
-------
|
|
object
|
|
"""
|
|
if PyDelta_Check(result):
|
|
# convert Timedelta back to a Tick
|
|
return delta_to_tick(result)
|
|
|
|
return result
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Business Helpers
|
|
|
|
|
|
cdef _get_calendar(weekmask, holidays, calendar):
|
|
"""
|
|
Generate busdaycalendar
|
|
"""
|
|
if isinstance(calendar, np.busdaycalendar):
|
|
if not holidays:
|
|
holidays = tuple(calendar.holidays)
|
|
elif not isinstance(holidays, tuple):
|
|
holidays = tuple(holidays)
|
|
else:
|
|
# trust that calendar.holidays and holidays are
|
|
# consistent
|
|
pass
|
|
return calendar, holidays
|
|
|
|
if holidays is None:
|
|
holidays = []
|
|
try:
|
|
holidays = holidays + calendar.holidays().tolist()
|
|
except AttributeError:
|
|
pass
|
|
holidays = [_to_dt64D(dt) for dt in holidays]
|
|
holidays = tuple(sorted(holidays))
|
|
|
|
kwargs = {"weekmask": weekmask}
|
|
if holidays:
|
|
kwargs["holidays"] = holidays
|
|
|
|
busdaycalendar = np.busdaycalendar(**kwargs)
|
|
return busdaycalendar, holidays
|
|
|
|
|
|
cdef _to_dt64D(dt):
|
|
# Currently
|
|
# > np.datetime64(dt.datetime(2013,5,1),dtype='datetime64[D]')
|
|
# numpy.datetime64('2013-05-01T02:00:00.000000+0200')
|
|
# Thus astype is needed to cast datetime to datetime64[D]
|
|
if getattr(dt, "tzinfo", None) is not None:
|
|
# Get the nanosecond timestamp,
|
|
# equiv `Timestamp(dt).value` or `dt.timestamp() * 10**9`
|
|
# The `naive` must be the `dt` naive wall time
|
|
# instead of the naive absolute time (GH#49441)
|
|
naive = dt.replace(tzinfo=None)
|
|
dt = np.datetime64(naive, "D")
|
|
else:
|
|
dt = np.datetime64(dt)
|
|
if dt.dtype.name != "datetime64[D]":
|
|
dt = dt.astype("datetime64[D]")
|
|
return dt
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Validation
|
|
|
|
|
|
cdef _validate_business_time(t_input):
|
|
if isinstance(t_input, str):
|
|
try:
|
|
t = time.strptime(t_input, "%H:%M")
|
|
return dt_time(hour=t.tm_hour, minute=t.tm_min)
|
|
except ValueError:
|
|
raise ValueError("time data must match '%H:%M' format")
|
|
elif isinstance(t_input, dt_time):
|
|
if t_input.second != 0 or t_input.microsecond != 0:
|
|
raise ValueError(
|
|
"time data must be specified only with hour and minute")
|
|
return t_input
|
|
else:
|
|
raise ValueError("time data must be string or datetime.time")
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Constructor Helpers
|
|
|
|
_relativedelta_kwds = {"years", "months", "weeks", "days", "year", "month",
|
|
"day", "weekday", "hour", "minute", "second",
|
|
"microsecond", "millisecond", "nanosecond",
|
|
"nanoseconds", "hours", "minutes", "seconds",
|
|
"milliseconds", "microseconds"}
|
|
|
|
|
|
cdef _determine_offset(kwds):
|
|
if not kwds:
|
|
# GH 45643/45890: (historically) defaults to 1 day
|
|
return timedelta(days=1), False
|
|
|
|
if "millisecond" in kwds:
|
|
raise NotImplementedError(
|
|
"Using DateOffset to replace `millisecond` component in "
|
|
"datetime object is not supported. Use "
|
|
"`microsecond=timestamp.microsecond % 1000 + ms * 1000` "
|
|
"instead."
|
|
)
|
|
|
|
nanos = {"nanosecond", "nanoseconds"}
|
|
|
|
# nanos are handled by apply_wraps
|
|
if all(k in nanos for k in kwds):
|
|
return timedelta(days=0), False
|
|
|
|
kwds_no_nanos = {k: v for k, v in kwds.items() if k not in nanos}
|
|
|
|
kwds_use_relativedelta = {
|
|
"year", "month", "day", "hour", "minute",
|
|
"second", "microsecond", "weekday", "years", "months", "weeks", "days",
|
|
"hours", "minutes", "seconds", "microseconds"
|
|
}
|
|
|
|
# "weeks" and "days" are left out despite being valid args for timedelta,
|
|
# because (historically) timedelta is used only for sub-daily.
|
|
kwds_use_timedelta = {
|
|
"seconds", "microseconds", "milliseconds", "minutes", "hours",
|
|
}
|
|
|
|
if all(k in kwds_use_timedelta for k in kwds_no_nanos):
|
|
# Sub-daily offset - use timedelta (tz-aware)
|
|
# This also handles "milliseconds" (plur): see GH 49897
|
|
return timedelta(**kwds_no_nanos), False
|
|
|
|
# convert milliseconds to microseconds, so relativedelta can parse it
|
|
if "milliseconds" in kwds_no_nanos:
|
|
micro = kwds_no_nanos.pop("milliseconds") * 1000
|
|
kwds_no_nanos["microseconds"] = kwds_no_nanos.get("microseconds", 0) + micro
|
|
|
|
if all(k in kwds_use_relativedelta for k in kwds_no_nanos):
|
|
return relativedelta(**kwds_no_nanos), True
|
|
|
|
raise ValueError(
|
|
f"Invalid argument/s or bad combination of arguments: {list(kwds.keys())}"
|
|
)
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Mixins & Singletons
|
|
|
|
|
|
class ApplyTypeError(TypeError):
|
|
# sentinel class for catching the apply error to return NotImplemented
|
|
pass
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Base Classes
|
|
|
|
cdef class BaseOffset:
|
|
"""
|
|
Base class for DateOffset methods that are not overridden by subclasses.
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
Number of multiples of the frequency.
|
|
|
|
normalize : bool
|
|
Whether the frequency can align with midnight.
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.offsets.Hour(5).n
|
|
5
|
|
>>> pd.offsets.Hour(5).normalize
|
|
False
|
|
"""
|
|
# ensure that reversed-ops with numpy scalars return NotImplemented
|
|
__array_priority__ = 1000
|
|
|
|
_day_opt = None
|
|
_attributes = tuple(["n", "normalize"])
|
|
_use_relativedelta = False
|
|
_adjust_dst = True
|
|
|
|
# cdef readonly:
|
|
# int64_t n
|
|
# bint normalize
|
|
# dict _cache
|
|
|
|
def __init__(self, n=1, normalize=False):
|
|
n = self._validate_n(n)
|
|
self.n = n
|
|
self.normalize = normalize
|
|
self._cache = {}
|
|
|
|
def __eq__(self, other) -> bool:
|
|
if isinstance(other, str):
|
|
try:
|
|
# GH#23524 if to_offset fails, we are dealing with an
|
|
# incomparable type so == is False and != is True
|
|
other = to_offset(other)
|
|
except ValueError:
|
|
# e.g. "infer"
|
|
return False
|
|
try:
|
|
return self._params == other._params
|
|
except AttributeError:
|
|
# other is not a DateOffset object
|
|
return False
|
|
|
|
def __ne__(self, other):
|
|
return not self == other
|
|
|
|
def __hash__(self) -> int:
|
|
return hash(self._params)
|
|
|
|
@cache_readonly
|
|
def _params(self):
|
|
"""
|
|
Returns a tuple containing all of the attributes needed to evaluate
|
|
equality between two DateOffset objects.
|
|
"""
|
|
d = getattr(self, "__dict__", {})
|
|
all_paras = d.copy()
|
|
all_paras["n"] = self.n
|
|
all_paras["normalize"] = self.normalize
|
|
for attr in self._attributes:
|
|
if hasattr(self, attr) and attr not in d:
|
|
# cython attributes are not in __dict__
|
|
all_paras[attr] = getattr(self, attr)
|
|
|
|
if "holidays" in all_paras and not all_paras["holidays"]:
|
|
all_paras.pop("holidays")
|
|
exclude = ["kwds", "name", "calendar"]
|
|
attrs = [(k, v) for k, v in all_paras.items()
|
|
if (k not in exclude) and (k[0] != "_")]
|
|
attrs = sorted(set(attrs))
|
|
params = tuple([str(type(self))] + attrs)
|
|
return params
|
|
|
|
@property
|
|
def kwds(self) -> dict:
|
|
"""
|
|
Return a dict of extra parameters for the offset.
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.DateOffset(5).kwds
|
|
{}
|
|
|
|
>>> pd.offsets.FY5253Quarter().kwds
|
|
{'weekday': 0,
|
|
'startingMonth': 1,
|
|
'qtr_with_extra_week': 1,
|
|
'variation': 'nearest'}
|
|
"""
|
|
# for backwards-compatibility
|
|
kwds = {name: getattr(self, name, None) for name in self._attributes
|
|
if name not in ["n", "normalize"]}
|
|
return {name: kwds[name] for name in kwds if kwds[name] is not None}
|
|
|
|
@property
|
|
def base(self):
|
|
"""
|
|
Returns a copy of the calling offset object with n=1 and all other
|
|
attributes equal.
|
|
"""
|
|
return type(self)(n=1, normalize=self.normalize, **self.kwds)
|
|
|
|
def __add__(self, other):
|
|
if not isinstance(self, BaseOffset):
|
|
# cython semantics; this is __radd__
|
|
# TODO(cython3): remove this, this moved to __radd__
|
|
return other.__add__(self)
|
|
|
|
elif util.is_array(other) and other.dtype == object:
|
|
return np.array([self + x for x in other])
|
|
|
|
try:
|
|
return self._apply(other)
|
|
except ApplyTypeError:
|
|
return NotImplemented
|
|
|
|
def __radd__(self, other):
|
|
return self.__add__(other)
|
|
|
|
def __sub__(self, other):
|
|
if PyDateTime_Check(other):
|
|
raise TypeError("Cannot subtract datetime from offset.")
|
|
elif type(other) == type(self):
|
|
return type(self)(self.n - other.n, normalize=self.normalize,
|
|
**self.kwds)
|
|
elif not isinstance(self, BaseOffset):
|
|
# TODO(cython3): remove, this moved to __rsub__
|
|
# cython semantics, this is __rsub__
|
|
return (-other).__add__(self)
|
|
else:
|
|
# e.g. PeriodIndex
|
|
return NotImplemented
|
|
|
|
def __rsub__(self, other):
|
|
return (-self).__add__(other)
|
|
|
|
def __mul__(self, other):
|
|
if util.is_array(other):
|
|
return np.array([self * x for x in other])
|
|
elif is_integer_object(other):
|
|
return type(self)(n=other * self.n, normalize=self.normalize,
|
|
**self.kwds)
|
|
elif not isinstance(self, BaseOffset):
|
|
# TODO(cython3): remove this, this moved to __rmul__
|
|
# cython semantics, this is __rmul__
|
|
return other.__mul__(self)
|
|
return NotImplemented
|
|
|
|
def __rmul__(self, other):
|
|
return self.__mul__(other)
|
|
|
|
def __neg__(self):
|
|
# Note: we are deferring directly to __mul__ instead of __rmul__, as
|
|
# that allows us to use methods that can go in a `cdef class`
|
|
return self * -1
|
|
|
|
def copy(self):
|
|
# Note: we are deferring directly to __mul__ instead of __rmul__, as
|
|
# that allows us to use methods that can go in a `cdef class`
|
|
"""
|
|
Return a copy of the frequency.
|
|
|
|
Examples
|
|
--------
|
|
>>> freq = pd.DateOffset(1)
|
|
>>> freq_copy = freq.copy()
|
|
>>> freq is freq_copy
|
|
False
|
|
"""
|
|
return self * 1
|
|
|
|
# ------------------------------------------------------------------
|
|
# Name and Rendering Methods
|
|
|
|
def __repr__(self) -> str:
|
|
# _output_name used by B(Year|Quarter)(End|Begin) to
|
|
# expand "B" -> "Business"
|
|
class_name = getattr(self, "_output_name", type(self).__name__)
|
|
|
|
if abs(self.n) != 1:
|
|
plural = "s"
|
|
else:
|
|
plural = ""
|
|
|
|
n_str = ""
|
|
if self.n != 1:
|
|
n_str = f"{self.n} * "
|
|
|
|
out = f"<{n_str}{class_name}{plural}{self._repr_attrs()}>"
|
|
return out
|
|
|
|
def _repr_attrs(self) -> str:
|
|
exclude = {"n", "inc", "normalize"}
|
|
attrs = []
|
|
for attr in sorted(self._attributes):
|
|
# _attributes instead of __dict__ because cython attrs are not in __dict__
|
|
if attr.startswith("_") or attr == "kwds" or not hasattr(self, attr):
|
|
# DateOffset may not have some of these attributes
|
|
continue
|
|
elif attr not in exclude:
|
|
value = getattr(self, attr)
|
|
attrs.append(f"{attr}={value}")
|
|
|
|
out = ""
|
|
if attrs:
|
|
out += ": " + ", ".join(attrs)
|
|
return out
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
"""
|
|
Return a string representing the base frequency.
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.offsets.Hour().name
|
|
'H'
|
|
|
|
>>> pd.offsets.Hour(5).name
|
|
'H'
|
|
"""
|
|
return self.rule_code
|
|
|
|
@property
|
|
def _prefix(self) -> str:
|
|
raise NotImplementedError("Prefix not defined")
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
return self._prefix
|
|
|
|
@cache_readonly
|
|
def freqstr(self) -> str:
|
|
"""
|
|
Return a string representing the frequency.
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.DateOffset(5).freqstr
|
|
'<5 * DateOffsets>'
|
|
|
|
>>> pd.offsets.BusinessHour(2).freqstr
|
|
'2BH'
|
|
|
|
>>> pd.offsets.Nano().freqstr
|
|
'N'
|
|
|
|
>>> pd.offsets.Nano(-3).freqstr
|
|
'-3N'
|
|
"""
|
|
try:
|
|
code = self.rule_code
|
|
except NotImplementedError:
|
|
return str(repr(self))
|
|
|
|
if self.n != 1:
|
|
fstr = f"{self.n}{code}"
|
|
else:
|
|
fstr = code
|
|
|
|
try:
|
|
if self._offset:
|
|
fstr += self._offset_str()
|
|
except AttributeError:
|
|
# TODO: standardize `_offset` vs `offset` naming convention
|
|
pass
|
|
|
|
return fstr
|
|
|
|
def _offset_str(self) -> str:
|
|
return ""
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def _apply(self, other):
|
|
raise NotImplementedError("implemented by subclasses")
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
raise NotImplementedError(
|
|
f"DateOffset subclass {type(self).__name__} "
|
|
"does not have a vectorized implementation"
|
|
)
|
|
|
|
def rollback(self, dt) -> datetime:
|
|
"""
|
|
Roll provided date backward to next offset only if not on offset.
|
|
|
|
Returns
|
|
-------
|
|
TimeStamp
|
|
Rolled timestamp if not on offset, otherwise unchanged timestamp.
|
|
"""
|
|
dt = Timestamp(dt)
|
|
if not self.is_on_offset(dt):
|
|
dt = dt - type(self)(1, normalize=self.normalize, **self.kwds)
|
|
return dt
|
|
|
|
def rollforward(self, dt) -> datetime:
|
|
"""
|
|
Roll provided date forward to next offset only if not on offset.
|
|
|
|
Returns
|
|
-------
|
|
TimeStamp
|
|
Rolled timestamp if not on offset, otherwise unchanged timestamp.
|
|
"""
|
|
dt = Timestamp(dt)
|
|
if not self.is_on_offset(dt):
|
|
dt = dt + type(self)(1, normalize=self.normalize, **self.kwds)
|
|
return dt
|
|
|
|
def _get_offset_day(self, other: datetime) -> int:
|
|
# subclass must implement `_day_opt`; calling from the base class
|
|
# will implicitly assume day_opt = "business_end", see get_day_of_month.
|
|
cdef:
|
|
npy_datetimestruct dts
|
|
pydate_to_dtstruct(other, &dts)
|
|
return get_day_of_month(&dts, self._day_opt)
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
"""
|
|
Return boolean whether a timestamp intersects with this frequency.
|
|
|
|
Parameters
|
|
----------
|
|
dt : datetime.datetime
|
|
Timestamp to check intersections with frequency.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Day(1)
|
|
>>> freq.is_on_offset(ts)
|
|
True
|
|
|
|
>>> ts = pd.Timestamp(2022, 8, 6)
|
|
>>> ts.day_name()
|
|
'Saturday'
|
|
>>> freq = pd.offsets.BusinessDay(1)
|
|
>>> freq.is_on_offset(ts)
|
|
False
|
|
"""
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
|
|
# Default (slow) method for determining if some date is a member of the
|
|
# date range generated by this offset. Subclasses may have this
|
|
# re-implemented in a nicer way.
|
|
a = dt
|
|
b = (dt + self) - self
|
|
return a == b
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
# Staticmethod so we can call from Tick.__init__, will be unnecessary
|
|
# once BaseOffset is a cdef class and is inherited by Tick
|
|
@staticmethod
|
|
def _validate_n(n) -> int:
|
|
"""
|
|
Require that `n` be an integer.
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
|
|
Returns
|
|
-------
|
|
nint : int
|
|
|
|
Raises
|
|
------
|
|
TypeError if `int(n)` raises
|
|
ValueError if n != int(n)
|
|
"""
|
|
if util.is_timedelta64_object(n):
|
|
raise TypeError(f"`n` argument must be an integer, got {type(n)}")
|
|
try:
|
|
nint = int(n)
|
|
except (ValueError, TypeError):
|
|
raise TypeError(f"`n` argument must be an integer, got {type(n)}")
|
|
if n != nint:
|
|
raise ValueError(f"`n` argument must be an integer, got {n}")
|
|
return nint
|
|
|
|
def __setstate__(self, state):
|
|
"""
|
|
Reconstruct an instance from a pickled state
|
|
"""
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self._cache = state.pop("_cache", {})
|
|
# At this point we expect state to be empty
|
|
|
|
def __getstate__(self):
|
|
"""
|
|
Return a pickleable state
|
|
"""
|
|
state = {}
|
|
state["n"] = self.n
|
|
state["normalize"] = self.normalize
|
|
|
|
# we don't want to actually pickle the calendar object
|
|
# as its a np.busyday; we recreate on deserialization
|
|
state.pop("calendar", None)
|
|
if "kwds" in state:
|
|
state["kwds"].pop("calendar", None)
|
|
|
|
return state
|
|
|
|
@property
|
|
def nanos(self):
|
|
raise ValueError(f"{self} is a non-fixed frequency")
|
|
|
|
def is_anchored(self) -> bool:
|
|
# TODO: Does this make sense for the general case? It would help
|
|
# if there were a canonical docstring for what is_anchored means.
|
|
"""
|
|
Return boolean whether the frequency is a unit frequency (n=1).
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.DateOffset().is_anchored()
|
|
True
|
|
>>> pd.DateOffset(2).is_anchored()
|
|
False
|
|
"""
|
|
return self.n == 1
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
def is_month_start(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the month start.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_month_start(ts)
|
|
True
|
|
"""
|
|
return ts._get_start_end_field("is_month_start", self)
|
|
|
|
def is_month_end(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the month end.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_month_end(ts)
|
|
False
|
|
"""
|
|
return ts._get_start_end_field("is_month_end", self)
|
|
|
|
def is_quarter_start(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the quarter start.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_quarter_start(ts)
|
|
True
|
|
"""
|
|
return ts._get_start_end_field("is_quarter_start", self)
|
|
|
|
def is_quarter_end(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the quarter end.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_quarter_end(ts)
|
|
False
|
|
"""
|
|
return ts._get_start_end_field("is_quarter_end", self)
|
|
|
|
def is_year_start(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the year start.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_year_start(ts)
|
|
True
|
|
"""
|
|
return ts._get_start_end_field("is_year_start", self)
|
|
|
|
def is_year_end(self, _Timestamp ts):
|
|
"""
|
|
Return boolean whether a timestamp occurs on the year end.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> freq = pd.offsets.Hour(5)
|
|
>>> freq.is_year_end(ts)
|
|
False
|
|
"""
|
|
return ts._get_start_end_field("is_year_end", self)
|
|
|
|
|
|
cdef class SingleConstructorOffset(BaseOffset):
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
# default _from_name calls cls with no args
|
|
if suffix:
|
|
raise ValueError(f"Bad freq suffix {suffix}")
|
|
return cls()
|
|
|
|
def __reduce__(self):
|
|
# This __reduce__ implementation is for all BaseOffset subclasses
|
|
# except for RelativeDeltaOffset
|
|
# np.busdaycalendar objects do not pickle nicely, but we can reconstruct
|
|
# from attributes that do get pickled.
|
|
tup = tuple(
|
|
getattr(self, attr) if attr != "calendar" else None
|
|
for attr in self._attributes
|
|
)
|
|
return type(self), tup
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Tick Offsets
|
|
|
|
cdef class Tick(SingleConstructorOffset):
|
|
_adjust_dst = False
|
|
_prefix = "undefined"
|
|
_td64_unit = "undefined"
|
|
_attributes = tuple(["n", "normalize"])
|
|
|
|
def __init__(self, n=1, normalize=False):
|
|
n = self._validate_n(n)
|
|
self.n = n
|
|
self.normalize = False
|
|
self._cache = {}
|
|
if normalize:
|
|
# GH#21427
|
|
raise ValueError(
|
|
"Tick offset with `normalize=True` are not allowed."
|
|
)
|
|
|
|
# Note: Without making this cpdef, we get AttributeError when calling
|
|
# from __mul__
|
|
cpdef Tick _next_higher_resolution(Tick self):
|
|
if type(self) is Day:
|
|
return Hour(self.n * 24)
|
|
if type(self) is Hour:
|
|
return Minute(self.n * 60)
|
|
if type(self) is Minute:
|
|
return Second(self.n * 60)
|
|
if type(self) is Second:
|
|
return Milli(self.n * 1000)
|
|
if type(self) is Milli:
|
|
return Micro(self.n * 1000)
|
|
if type(self) is Micro:
|
|
return Nano(self.n * 1000)
|
|
raise ValueError("Could not convert to integer offset at any resolution")
|
|
|
|
# --------------------------------------------------------------------
|
|
|
|
def _repr_attrs(self) -> str:
|
|
# Since cdef classes have no __dict__, we need to override
|
|
return ""
|
|
|
|
@property
|
|
def delta(self):
|
|
return self.n * Timedelta(self._nanos_inc)
|
|
|
|
@property
|
|
def nanos(self) -> int64_t:
|
|
"""
|
|
Return an integer of the total number of nanoseconds.
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If the frequency is non-fixed.
|
|
|
|
Examples
|
|
--------
|
|
>>> pd.offsets.Hour(5).nanos
|
|
18000000000000
|
|
"""
|
|
return self.n * self._nanos_inc
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
return True
|
|
|
|
def is_anchored(self) -> bool:
|
|
return False
|
|
|
|
# This is identical to BaseOffset.__hash__, but has to be redefined here
|
|
# for Python 3, because we've redefined __eq__.
|
|
def __hash__(self) -> int:
|
|
return hash(self._params)
|
|
|
|
# --------------------------------------------------------------------
|
|
# Comparison and Arithmetic Methods
|
|
|
|
def __eq__(self, other):
|
|
if isinstance(other, str):
|
|
try:
|
|
# GH#23524 if to_offset fails, we are dealing with an
|
|
# incomparable type so == is False and != is True
|
|
other = to_offset(other)
|
|
except ValueError:
|
|
# e.g. "infer"
|
|
return False
|
|
return self.delta == other
|
|
|
|
def __ne__(self, other):
|
|
return not (self == other)
|
|
|
|
def __le__(self, other):
|
|
return self.delta.__le__(other)
|
|
|
|
def __lt__(self, other):
|
|
return self.delta.__lt__(other)
|
|
|
|
def __ge__(self, other):
|
|
return self.delta.__ge__(other)
|
|
|
|
def __gt__(self, other):
|
|
return self.delta.__gt__(other)
|
|
|
|
def __mul__(self, other):
|
|
if not isinstance(self, Tick):
|
|
# TODO(cython3), remove this, this moved to __rmul__
|
|
# cython semantics, this is __rmul__
|
|
return other.__mul__(self)
|
|
if is_float_object(other):
|
|
n = other * self.n
|
|
# If the new `n` is an integer, we can represent it using the
|
|
# same Tick subclass as self, otherwise we need to move up
|
|
# to a higher-resolution subclass
|
|
if np.isclose(n % 1, 0):
|
|
return type(self)(int(n))
|
|
new_self = self._next_higher_resolution()
|
|
return new_self * other
|
|
return BaseOffset.__mul__(self, other)
|
|
|
|
def __rmul__(self, other):
|
|
return self.__mul__(other)
|
|
|
|
def __truediv__(self, other):
|
|
if not isinstance(self, Tick):
|
|
# cython semantics mean the args are sometimes swapped
|
|
result = other.delta.__rtruediv__(self)
|
|
else:
|
|
result = self.delta.__truediv__(other)
|
|
return _wrap_timedelta_result(result)
|
|
|
|
def __rtruediv__(self, other):
|
|
result = self.delta.__rtruediv__(other)
|
|
return _wrap_timedelta_result(result)
|
|
|
|
def __add__(self, other):
|
|
if not isinstance(self, Tick):
|
|
# cython semantics; this is __radd__
|
|
# TODO(cython3): remove this, this moved to __radd__
|
|
return other.__add__(self)
|
|
|
|
if isinstance(other, Tick):
|
|
if type(self) == type(other):
|
|
return type(self)(self.n + other.n)
|
|
else:
|
|
return delta_to_tick(self.delta + other.delta)
|
|
try:
|
|
return self._apply(other)
|
|
except ApplyTypeError:
|
|
# Includes pd.Period
|
|
return NotImplemented
|
|
except OverflowError as err:
|
|
raise OverflowError(
|
|
f"the add operation between {self} and {other} will overflow"
|
|
) from err
|
|
|
|
def __radd__(self, other):
|
|
return self.__add__(other)
|
|
|
|
def _apply(self, other):
|
|
# Timestamp can handle tz and nano sec, thus no need to use apply_wraps
|
|
if isinstance(other, _Timestamp):
|
|
# GH#15126
|
|
return other + self.delta
|
|
elif other is NaT:
|
|
return NaT
|
|
elif is_datetime64_object(other) or PyDate_Check(other):
|
|
# PyDate_Check includes date, datetime
|
|
return Timestamp(other) + self
|
|
|
|
if util.is_timedelta64_object(other) or PyDelta_Check(other):
|
|
return other + self.delta
|
|
|
|
raise ApplyTypeError(f"Unhandled type: {type(other).__name__}")
|
|
|
|
# --------------------------------------------------------------------
|
|
# Pickle Methods
|
|
|
|
def __setstate__(self, state):
|
|
self.n = state["n"]
|
|
self.normalize = False
|
|
|
|
|
|
cdef class Day(Tick):
|
|
_nanos_inc = 24 * 3600 * 1_000_000_000
|
|
_prefix = "D"
|
|
_td64_unit = "D"
|
|
_period_dtype_code = PeriodDtypeCode.D
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_D
|
|
|
|
|
|
cdef class Hour(Tick):
|
|
_nanos_inc = 3600 * 1_000_000_000
|
|
_prefix = "H"
|
|
_td64_unit = "h"
|
|
_period_dtype_code = PeriodDtypeCode.H
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_h
|
|
|
|
|
|
cdef class Minute(Tick):
|
|
_nanos_inc = 60 * 1_000_000_000
|
|
_prefix = "T"
|
|
_td64_unit = "m"
|
|
_period_dtype_code = PeriodDtypeCode.T
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_m
|
|
|
|
|
|
cdef class Second(Tick):
|
|
_nanos_inc = 1_000_000_000
|
|
_prefix = "S"
|
|
_td64_unit = "s"
|
|
_period_dtype_code = PeriodDtypeCode.S
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_s
|
|
|
|
|
|
cdef class Milli(Tick):
|
|
_nanos_inc = 1_000_000
|
|
_prefix = "L"
|
|
_td64_unit = "ms"
|
|
_period_dtype_code = PeriodDtypeCode.L
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_ms
|
|
|
|
|
|
cdef class Micro(Tick):
|
|
_nanos_inc = 1000
|
|
_prefix = "U"
|
|
_td64_unit = "us"
|
|
_period_dtype_code = PeriodDtypeCode.U
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_us
|
|
|
|
|
|
cdef class Nano(Tick):
|
|
_nanos_inc = 1
|
|
_prefix = "N"
|
|
_td64_unit = "ns"
|
|
_period_dtype_code = PeriodDtypeCode.N
|
|
_creso = NPY_DATETIMEUNIT.NPY_FR_ns
|
|
|
|
|
|
def delta_to_tick(delta: timedelta) -> Tick:
|
|
if delta.microseconds == 0 and getattr(delta, "nanoseconds", 0) == 0:
|
|
# nanoseconds only for pd.Timedelta
|
|
if delta.seconds == 0:
|
|
return Day(delta.days)
|
|
else:
|
|
seconds = delta.days * 86400 + delta.seconds
|
|
if seconds % 3600 == 0:
|
|
return Hour(seconds / 3600)
|
|
elif seconds % 60 == 0:
|
|
return Minute(seconds / 60)
|
|
else:
|
|
return Second(seconds)
|
|
else:
|
|
nanos = delta_to_nanoseconds(delta)
|
|
if nanos % 1_000_000 == 0:
|
|
return Milli(nanos // 1_000_000)
|
|
elif nanos % 1000 == 0:
|
|
return Micro(nanos // 1000)
|
|
else: # pragma: no cover
|
|
return Nano(nanos)
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
|
|
cdef class RelativeDeltaOffset(BaseOffset):
|
|
"""
|
|
DateOffset subclass backed by a dateutil relativedelta object.
|
|
"""
|
|
_attributes = tuple(["n", "normalize"] + list(_relativedelta_kwds))
|
|
_adjust_dst = False
|
|
|
|
def __init__(self, n=1, normalize=False, **kwds):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
off, use_rd = _determine_offset(kwds)
|
|
object.__setattr__(self, "_offset", off)
|
|
object.__setattr__(self, "_use_relativedelta", use_rd)
|
|
for key in kwds:
|
|
val = kwds[key]
|
|
object.__setattr__(self, key, val)
|
|
|
|
def __getstate__(self):
|
|
"""
|
|
Return a pickleable state
|
|
"""
|
|
# RelativeDeltaOffset (technically DateOffset) is the only non-cdef
|
|
# class, so the only one with __dict__
|
|
state = self.__dict__.copy()
|
|
state["n"] = self.n
|
|
state["normalize"] = self.normalize
|
|
return state
|
|
|
|
def __setstate__(self, state):
|
|
"""
|
|
Reconstruct an instance from a pickled state
|
|
"""
|
|
|
|
if "offset" in state:
|
|
# Older (<0.22.0) versions have offset attribute instead of _offset
|
|
if "_offset" in state: # pragma: no cover
|
|
raise AssertionError("Unexpected key `_offset`")
|
|
state["_offset"] = state.pop("offset")
|
|
state["kwds"]["offset"] = state["_offset"]
|
|
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self._cache = state.pop("_cache", {})
|
|
|
|
self.__dict__.update(state)
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
if self._use_relativedelta:
|
|
other = _as_datetime(other)
|
|
|
|
if len(self.kwds) > 0:
|
|
tzinfo = getattr(other, "tzinfo", None)
|
|
if tzinfo is not None and self._use_relativedelta:
|
|
# perform calculation in UTC
|
|
other = other.replace(tzinfo=None)
|
|
|
|
if hasattr(self, "nanoseconds"):
|
|
td_nano = Timedelta(nanoseconds=self.nanoseconds)
|
|
else:
|
|
td_nano = Timedelta(0)
|
|
|
|
if self.n > 0:
|
|
for i in range(self.n):
|
|
other = other + self._offset + td_nano
|
|
else:
|
|
for i in range(-self.n):
|
|
other = other - self._offset - td_nano
|
|
|
|
if tzinfo is not None and self._use_relativedelta:
|
|
# bring tz back from UTC calculation
|
|
other = localize_pydatetime(other, tzinfo)
|
|
|
|
return Timestamp(other)
|
|
else:
|
|
return other + timedelta(self.n)
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
dt64other = np.asarray(dtarr)
|
|
kwds = self.kwds
|
|
relativedelta_fast = {
|
|
"years",
|
|
"months",
|
|
"weeks",
|
|
"days",
|
|
"hours",
|
|
"minutes",
|
|
"seconds",
|
|
"microseconds",
|
|
}
|
|
# relativedelta/_offset path only valid for base DateOffset
|
|
if self._use_relativedelta and set(kwds).issubset(relativedelta_fast):
|
|
|
|
months = (kwds.get("years", 0) * 12 + kwds.get("months", 0)) * self.n
|
|
if months:
|
|
shifted = shift_months(dt64other.view("i8"), months, reso=reso)
|
|
dt64other = shifted.view(dtarr.dtype)
|
|
|
|
weeks = kwds.get("weeks", 0) * self.n
|
|
if weeks:
|
|
delta = Timedelta(days=7 * weeks)
|
|
td = (<_Timedelta>delta)._as_creso(reso)
|
|
dt64other = dt64other + td
|
|
|
|
timedelta_kwds = {
|
|
k: v
|
|
for k, v in kwds.items()
|
|
if k in ["days", "hours", "minutes", "seconds", "microseconds"]
|
|
}
|
|
if timedelta_kwds:
|
|
delta = Timedelta(**timedelta_kwds)
|
|
td = (<_Timedelta>delta)._as_creso(reso)
|
|
dt64other = dt64other + (self.n * td)
|
|
return dt64other
|
|
elif not self._use_relativedelta and hasattr(self, "_offset"):
|
|
# timedelta
|
|
num_nano = getattr(self, "nanoseconds", 0)
|
|
if num_nano != 0:
|
|
rem_nano = Timedelta(nanoseconds=num_nano)
|
|
delta = Timedelta((self._offset + rem_nano) * self.n)
|
|
else:
|
|
delta = Timedelta(self._offset * self.n)
|
|
td = (<_Timedelta>delta)._as_creso(reso)
|
|
return dt64other + td
|
|
else:
|
|
# relativedelta with other keywords
|
|
kwd = set(kwds) - relativedelta_fast
|
|
raise NotImplementedError(
|
|
"DateOffset with relativedelta "
|
|
f"keyword(s) {kwd} not able to be "
|
|
"applied vectorized"
|
|
)
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return True
|
|
|
|
|
|
class OffsetMeta(type):
|
|
"""
|
|
Metaclass that allows us to pretend that all BaseOffset subclasses
|
|
inherit from DateOffset (which is needed for backward-compatibility).
|
|
"""
|
|
|
|
@classmethod
|
|
def __instancecheck__(cls, obj) -> bool:
|
|
return isinstance(obj, BaseOffset)
|
|
|
|
@classmethod
|
|
def __subclasscheck__(cls, obj) -> bool:
|
|
return issubclass(obj, BaseOffset)
|
|
|
|
|
|
# TODO: figure out a way to use a metaclass with a cdef class
|
|
class DateOffset(RelativeDeltaOffset, metaclass=OffsetMeta):
|
|
"""
|
|
Standard kind of date increment used for a date range.
|
|
|
|
Works exactly like the keyword argument form of relativedelta.
|
|
Note that the positional argument form of relativedelata is not
|
|
supported. Use of the keyword n is discouraged-- you would be better
|
|
off specifying n in the keywords you use, but regardless it is
|
|
there for you. n is needed for DateOffset subclasses.
|
|
|
|
DateOffset works as follows. Each offset specify a set of dates
|
|
that conform to the DateOffset. For example, Bday defines this
|
|
set to be the set of dates that are weekdays (M-F). To test if a
|
|
date is in the set of a DateOffset dateOffset we can use the
|
|
is_on_offset method: dateOffset.is_on_offset(date).
|
|
|
|
If a date is not on a valid date, the rollback and rollforward
|
|
methods can be used to roll the date to the nearest valid date
|
|
before/after the date.
|
|
|
|
DateOffsets can be created to move dates forward a given number of
|
|
valid dates. For example, Bday(2) can be added to a date to move
|
|
it two business days forward. If the date does not start on a
|
|
valid date, first it is moved to a valid date. Thus pseudo code
|
|
is::
|
|
|
|
def __add__(date):
|
|
date = rollback(date) # does nothing if date is valid
|
|
return date + <n number of periods>
|
|
|
|
When a date offset is created for a negative number of periods,
|
|
the date is first rolled forward. The pseudo code is::
|
|
|
|
def __add__(date):
|
|
date = rollforward(date) # does nothing if date is valid
|
|
return date + <n number of periods>
|
|
|
|
Zero presents a problem. Should it roll forward or back? We
|
|
arbitrarily have it rollforward:
|
|
|
|
date + BDay(0) == BDay.rollforward(date)
|
|
|
|
Since 0 is a bit weird, we suggest avoiding its use.
|
|
|
|
Besides, adding a DateOffsets specified by the singular form of the date
|
|
component can be used to replace certain component of the timestamp.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of time periods the offset represents.
|
|
If specified without a temporal pattern, defaults to n days.
|
|
normalize : bool, default False
|
|
Whether to round the result of a DateOffset addition down to the
|
|
previous midnight.
|
|
**kwds
|
|
Temporal parameter that add to or replace the offset value.
|
|
|
|
Parameters that **add** to the offset (like Timedelta):
|
|
|
|
- years
|
|
- months
|
|
- weeks
|
|
- days
|
|
- hours
|
|
- minutes
|
|
- seconds
|
|
- milliseconds
|
|
- microseconds
|
|
- nanoseconds
|
|
|
|
Parameters that **replace** the offset value:
|
|
|
|
- year
|
|
- month
|
|
- day
|
|
- weekday
|
|
- hour
|
|
- minute
|
|
- second
|
|
- microsecond
|
|
- nanosecond.
|
|
|
|
See Also
|
|
--------
|
|
dateutil.relativedelta.relativedelta : The relativedelta type is designed
|
|
to be applied to an existing datetime an can replace specific components of
|
|
that datetime, or represents an interval of time.
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import DateOffset
|
|
>>> ts = pd.Timestamp('2017-01-01 09:10:11')
|
|
>>> ts + DateOffset(months=3)
|
|
Timestamp('2017-04-01 09:10:11')
|
|
|
|
>>> ts = pd.Timestamp('2017-01-01 09:10:11')
|
|
>>> ts + DateOffset(months=2)
|
|
Timestamp('2017-03-01 09:10:11')
|
|
>>> ts + DateOffset(day=31)
|
|
Timestamp('2017-01-31 09:10:11')
|
|
|
|
>>> ts + pd.DateOffset(hour=8)
|
|
Timestamp('2017-01-01 08:10:11')
|
|
"""
|
|
def __setattr__(self, name, value):
|
|
raise AttributeError("DateOffset objects are immutable.")
|
|
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
cdef class BusinessMixin(SingleConstructorOffset):
|
|
"""
|
|
Mixin to business types to provide related functions.
|
|
"""
|
|
|
|
cdef readonly:
|
|
timedelta _offset
|
|
# Only Custom subclasses use weekmask, holiday, calendar
|
|
object weekmask, holidays, calendar
|
|
|
|
def __init__(self, n=1, normalize=False, offset=timedelta(0)):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
self._offset = offset
|
|
|
|
cpdef _init_custom(self, weekmask, holidays, calendar):
|
|
"""
|
|
Additional __init__ for Custom subclasses.
|
|
"""
|
|
calendar, holidays = _get_calendar(
|
|
weekmask=weekmask, holidays=holidays, calendar=calendar
|
|
)
|
|
# Custom offset instances are identified by the
|
|
# following two attributes. See DateOffset._params()
|
|
# holidays, weekmask
|
|
self.weekmask = weekmask
|
|
self.holidays = holidays
|
|
self.calendar = calendar
|
|
|
|
@property
|
|
def offset(self):
|
|
"""
|
|
Alias for self._offset.
|
|
"""
|
|
# Alias for backward compat
|
|
return self._offset
|
|
|
|
def _repr_attrs(self) -> str:
|
|
if self.offset:
|
|
attrs = [f"offset={repr(self.offset)}"]
|
|
else:
|
|
attrs = []
|
|
out = ""
|
|
if attrs:
|
|
out += ": " + ", ".join(attrs)
|
|
return out
|
|
|
|
cpdef __setstate__(self, state):
|
|
# We need to use a cdef/cpdef method to set the readonly _offset attribute
|
|
if "_offset" in state:
|
|
self._offset = state.pop("_offset")
|
|
elif "offset" in state:
|
|
# Older (<0.22.0) versions have offset attribute instead of _offset
|
|
self._offset = state.pop("offset")
|
|
|
|
if self._prefix.startswith("C"):
|
|
# i.e. this is a Custom class
|
|
weekmask = state.pop("weekmask")
|
|
holidays = state.pop("holidays")
|
|
calendar, holidays = _get_calendar(weekmask=weekmask,
|
|
holidays=holidays,
|
|
calendar=None)
|
|
self.weekmask = weekmask
|
|
self.calendar = calendar
|
|
self.holidays = holidays
|
|
|
|
BaseOffset.__setstate__(self, state)
|
|
|
|
|
|
cdef class BusinessDay(BusinessMixin):
|
|
"""
|
|
DateOffset subclass representing possibly n business days.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of days represented.
|
|
normalize : bool, default False
|
|
Normalize start/end dates to midnight.
|
|
|
|
Examples
|
|
--------
|
|
You can use the parameter ``n`` to represent a shift of n business days.
|
|
|
|
>>> ts = pd.Timestamp(2022, 12, 9, 15)
|
|
>>> ts.strftime('%a %d %b %Y %H:%M')
|
|
'Fri 09 Dec 2022 15:00'
|
|
>>> (ts + pd.offsets.BusinessDay(n=5)).strftime('%a %d %b %Y %H:%M')
|
|
'Fri 16 Dec 2022 15:00'
|
|
|
|
Passing the parameter ``normalize`` equal to True, you shift the start
|
|
of the next business day to midnight.
|
|
|
|
>>> ts = pd.Timestamp(2022, 12, 9, 15)
|
|
>>> ts + pd.offsets.BusinessDay(normalize=True)
|
|
Timestamp('2022-12-12 00:00:00')
|
|
"""
|
|
_period_dtype_code = PeriodDtypeCode.B
|
|
_prefix = "B"
|
|
_attributes = tuple(["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")
|
|
self._cache = state.pop("_cache", {})
|
|
|
|
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 PyDelta_Check(self.offset):
|
|
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 PyDateTime_Check(other):
|
|
n = self.n
|
|
wday = other.weekday()
|
|
|
|
# avoid slowness below by operating on weeks first
|
|
weeks = n // 5
|
|
days = self._adjust_ndays(wday, weeks)
|
|
|
|
result = other + timedelta(days=7 * weeks + days)
|
|
if self.offset:
|
|
result = result + self.offset
|
|
return result
|
|
|
|
elif is_any_td_scalar(other):
|
|
td = Timedelta(self.offset) + other
|
|
return BusinessDay(
|
|
self.n, offset=td.to_pytimedelta(), normalize=self.normalize
|
|
)
|
|
else:
|
|
raise ApplyTypeError(
|
|
"Only know how to combine business day with datetime or timedelta."
|
|
)
|
|
|
|
@cython.wraparound(False)
|
|
@cython.boundscheck(False)
|
|
cdef ndarray _shift_bdays(
|
|
self,
|
|
ndarray i8other,
|
|
NPY_DATETIMEUNIT reso=NPY_DATETIMEUNIT.NPY_FR_ns,
|
|
):
|
|
"""
|
|
Implementation of BusinessDay.apply_offset.
|
|
|
|
Parameters
|
|
----------
|
|
i8other : const int64_t[:]
|
|
reso : NPY_DATETIMEUNIT, default NPY_FR_ns
|
|
|
|
Returns
|
|
-------
|
|
ndarray[int64_t]
|
|
"""
|
|
cdef:
|
|
int periods = self.n
|
|
Py_ssize_t i, n = i8other.size
|
|
ndarray result = cnp.PyArray_EMPTY(
|
|
i8other.ndim, i8other.shape, cnp.NPY_INT64, 0
|
|
)
|
|
int64_t val, res_val
|
|
int wday, days
|
|
npy_datetimestruct dts
|
|
int64_t DAY_PERIODS = periods_per_day(reso)
|
|
cnp.broadcast mi = cnp.PyArray_MultiIterNew2(result, i8other)
|
|
|
|
for i in range(n):
|
|
# Analogous to: val = i8other[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
else:
|
|
# The rest of this is effectively a copy of BusinessDay.apply
|
|
weeks = periods // 5
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
wday = dayofweek(dts.year, dts.month, dts.day)
|
|
|
|
days = self._adjust_ndays(wday, weeks)
|
|
res_val = val + (7 * weeks + days) * DAY_PERIODS
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
return result
|
|
|
|
cdef int _adjust_ndays(self, int wday, int weeks):
|
|
cdef:
|
|
int n = self.n
|
|
int days
|
|
|
|
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
|
|
return days
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
i8other = dtarr.view("i8")
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
res = self._shift_bdays(i8other, reso=reso)
|
|
if self.offset:
|
|
res = res.view(dtarr.dtype) + Timedelta(self.offset)
|
|
res = res.view("i8")
|
|
return res
|
|
|
|
def is_on_offset(self, dt: datetime) -> 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.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of hours represented.
|
|
normalize : bool, default False
|
|
Normalize start/end dates to midnight before generating date range.
|
|
start : str, time, or list of str/time, default "09:00"
|
|
Start time of your custom business hour in 24h format.
|
|
end : str, time, or list of str/time, default: "17:00"
|
|
End time of your custom business hour in 24h format.
|
|
|
|
Examples
|
|
--------
|
|
You can use the parameter ``n`` to represent a shift of n hours.
|
|
|
|
>>> ts = pd.Timestamp(2022, 12, 9, 8)
|
|
>>> ts + pd.offsets.BusinessHour(n=5)
|
|
Timestamp('2022-12-09 14:00:00')
|
|
|
|
You can also change the start and the end of business hours.
|
|
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 16)
|
|
>>> ts + pd.offsets.BusinessHour(start="11:00")
|
|
Timestamp('2022-08-08 11:00:00')
|
|
|
|
>>> from datetime import time as dt_time
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 22)
|
|
>>> ts + pd.offsets.BusinessHour(end=dt_time(19, 0))
|
|
Timestamp('2022-08-08 10:00:00')
|
|
|
|
Passing the parameter ``normalize`` equal to True, you shift the start
|
|
of the next business hour to midnight.
|
|
|
|
>>> ts = pd.Timestamp(2022, 12, 9, 8)
|
|
>>> ts + pd.offsets.BusinessHour(normalize=True)
|
|
Timestamp('2022-12-09 00:00:00')
|
|
|
|
You can divide your business day hours into several parts.
|
|
|
|
>>> import datetime as dt
|
|
>>> freq = pd.offsets.BusinessHour(start=["06:00", "10:00", "15:00"],
|
|
... end=["08:00", "12:00", "17:00"])
|
|
>>> pd.date_range(dt.datetime(2022, 12, 9), dt.datetime(2022, 12, 13), freq=freq)
|
|
DatetimeIndex(['2022-12-09 06:00:00', '2022-12-09 07:00:00',
|
|
'2022-12-09 10:00:00', '2022-12-09 11:00:00',
|
|
'2022-12-09 15:00:00', '2022-12-09 16:00:00',
|
|
'2022-12-12 06:00:00', '2022-12-12 07:00:00',
|
|
'2022-12-12 10:00:00', '2022-12-12 11:00:00',
|
|
'2022-12-12 15:00:00', '2022-12-12 16:00:00'],
|
|
dtype='datetime64[ns]', freq='BH')
|
|
"""
|
|
|
|
_prefix = "BH"
|
|
_anchor = 0
|
|
_attributes = tuple(["n", "normalize", "start", "end", "offset"])
|
|
_adjust_dst = False
|
|
|
|
cdef readonly:
|
|
tuple start, end
|
|
|
|
def __init__(
|
|
self, n=1, normalize=False, start="09:00", end="17:00", offset=timedelta(0)
|
|
):
|
|
BusinessMixin.__init__(self, n, normalize, offset)
|
|
|
|
# must be validated here to equality check
|
|
if np.ndim(start) == 0:
|
|
# i.e. not is_list_like
|
|
start = [start]
|
|
if not len(start):
|
|
raise ValueError("Must include at least 1 start time")
|
|
|
|
if np.ndim(end) == 0:
|
|
# i.e. not is_list_like
|
|
end = [end]
|
|
if not len(end):
|
|
raise ValueError("Must include at least 1 end time")
|
|
|
|
start = np.array([_validate_business_time(x) for x in start])
|
|
end = np.array([_validate_business_time(x) for x in end])
|
|
|
|
# Validation of input
|
|
if len(start) != len(end):
|
|
raise ValueError("number of starting time and ending time must be the same")
|
|
num_openings = len(start)
|
|
|
|
# sort starting and ending time by starting time
|
|
index = np.argsort(start)
|
|
|
|
# convert to tuple so that start and end are hashable
|
|
start = tuple(start[index])
|
|
end = tuple(end[index])
|
|
|
|
total_secs = 0
|
|
for i in range(num_openings):
|
|
total_secs += self._get_business_hours_by_sec(start[i], end[i])
|
|
total_secs += self._get_business_hours_by_sec(
|
|
end[i], start[(i + 1) % num_openings]
|
|
)
|
|
if total_secs != 24 * 60 * 60:
|
|
raise ValueError(
|
|
"invalid starting and ending time(s): "
|
|
"opening hours should not touch or overlap with "
|
|
"one another"
|
|
)
|
|
|
|
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 _repr_attrs(self) -> str:
|
|
out = super()._repr_attrs()
|
|
# Use python string formatting to be faster than strftime
|
|
hours = ",".join(
|
|
f"{st.hour:02d}:{st.minute:02d}-{en.hour:02d}:{en.minute:02d}"
|
|
for st, en in zip(self.start, self.end)
|
|
)
|
|
attrs = [f"{self._prefix}={hours}"]
|
|
out += ": " + ", ".join(attrs)
|
|
return out
|
|
|
|
def _get_business_hours_by_sec(self, start, end):
|
|
"""
|
|
Return business hours in a day by seconds.
|
|
"""
|
|
# create dummy datetime to calculate business hours in a day
|
|
dtstart = datetime(2014, 4, 1, start.hour, start.minute)
|
|
day = 1 if start < end else 2
|
|
until = datetime(2014, 4, day, end.hour, end.minute)
|
|
return int((until - dtstart).total_seconds())
|
|
|
|
def _get_closing_time(self, dt: datetime) -> datetime:
|
|
"""
|
|
Get the closing time of a business hour interval by its opening time.
|
|
|
|
Parameters
|
|
----------
|
|
dt : datetime
|
|
Opening time of a business hour interval.
|
|
|
|
Returns
|
|
-------
|
|
result : datetime
|
|
Corresponding closing time.
|
|
"""
|
|
for i, st in enumerate(self.start):
|
|
if st.hour == dt.hour and st.minute == dt.minute:
|
|
return dt + timedelta(
|
|
seconds=self._get_business_hours_by_sec(st, self.end[i])
|
|
)
|
|
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
|
|
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 self.n == 0:
|
|
is_same_sign = sign > 0
|
|
else:
|
|
is_same_sign = self.n * sign >= 0
|
|
|
|
if not self.next_bday.is_on_offset(other):
|
|
# today is not business day
|
|
other = other + sign * self.next_bday
|
|
if is_same_sign:
|
|
hour, minute = earliest_start.hour, earliest_start.minute
|
|
else:
|
|
hour, minute = latest_start.hour, latest_start.minute
|
|
else:
|
|
if is_same_sign:
|
|
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: datetime) -> datetime:
|
|
"""
|
|
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: datetime) -> datetime:
|
|
"""
|
|
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: datetime) -> datetime:
|
|
"""
|
|
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: datetime) -> 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
|
|
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
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
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: datetime) -> bool:
|
|
"""
|
|
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
|
|
|
|
|
|
cdef class WeekOfMonthMixin(SingleConstructorOffset):
|
|
"""
|
|
Mixin for methods common to WeekOfMonth and LastWeekOfMonth.
|
|
"""
|
|
|
|
cdef readonly:
|
|
int weekday, week
|
|
|
|
def __init__(self, n=1, normalize=False, weekday=0):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
self.weekday = weekday
|
|
|
|
if weekday < 0 or weekday > 6:
|
|
raise ValueError(f"Day must be 0<=day<=6, got {weekday}")
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
compare_day = self._get_offset_day(other)
|
|
|
|
months = self.n
|
|
months = roll_convention(other.day, months, compare_day)
|
|
|
|
shifted = shift_month(other, months, "start")
|
|
to_day = self._get_offset_day(shifted)
|
|
return _shift_day(shifted, to_day - shifted.day)
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return dt.day == self._get_offset_day(dt)
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
weekday = int_to_weekday.get(self.weekday, "")
|
|
if self.week == -1:
|
|
# LastWeekOfMonth
|
|
return f"{self._prefix}-{weekday}"
|
|
return f"{self._prefix}-{self.week + 1}{weekday}"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Year-Based Offset Classes
|
|
|
|
cdef class YearOffset(SingleConstructorOffset):
|
|
"""
|
|
DateOffset that just needs a month.
|
|
"""
|
|
_attributes = tuple(["n", "normalize", "month"])
|
|
|
|
# FIXME(cython#4446): python annotation here gives compile-time errors
|
|
# _default_month: int
|
|
|
|
cdef readonly:
|
|
int month
|
|
|
|
def __init__(self, n=1, normalize=False, month=None):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
|
|
month = month if month is not None else self._default_month
|
|
self.month = month
|
|
|
|
if month < 1 or month > 12:
|
|
raise ValueError("Month must go from 1 to 12")
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.month = state.pop("month")
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self._cache = {}
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
kwargs = {}
|
|
if suffix:
|
|
kwargs["month"] = MONTH_TO_CAL_NUM[suffix]
|
|
return cls(**kwargs)
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
month = MONTH_ALIASES[self.month]
|
|
return f"{self._prefix}-{month}"
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return dt.month == self.month and dt.day == self._get_offset_day(dt)
|
|
|
|
def _get_offset_day(self, other: datetime) -> int:
|
|
# override BaseOffset method to use self.month instead of other.month
|
|
cdef:
|
|
npy_datetimestruct dts
|
|
pydate_to_dtstruct(other, &dts)
|
|
dts.month = self.month
|
|
return get_day_of_month(&dts, self._day_opt)
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
years = roll_qtrday(other, self.n, self.month, self._day_opt, modby=12)
|
|
months = years * 12 + (self.month - other.month)
|
|
return shift_month(other, months, self._day_opt)
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
shifted = shift_quarters(
|
|
dtarr.view("i8"), self.n, self.month, self._day_opt, modby=12, reso=reso
|
|
)
|
|
return shifted
|
|
|
|
|
|
cdef class BYearEnd(YearOffset):
|
|
"""
|
|
DateOffset increments between the last business day of the year.
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import BYearEnd
|
|
>>> ts = pd.Timestamp('2020-05-24 05:01:15')
|
|
>>> ts - BYearEnd()
|
|
Timestamp('2019-12-31 05:01:15')
|
|
>>> ts + BYearEnd()
|
|
Timestamp('2020-12-31 05:01:15')
|
|
>>> ts + BYearEnd(3)
|
|
Timestamp('2022-12-30 05:01:15')
|
|
>>> ts + BYearEnd(-3)
|
|
Timestamp('2017-12-29 05:01:15')
|
|
>>> ts + BYearEnd(month=11)
|
|
Timestamp('2020-11-30 05:01:15')
|
|
"""
|
|
|
|
_outputName = "BusinessYearEnd"
|
|
_default_month = 12
|
|
_prefix = "BA"
|
|
_day_opt = "business_end"
|
|
|
|
|
|
cdef class BYearBegin(YearOffset):
|
|
"""
|
|
DateOffset increments between the first business day of the year.
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import BYearBegin
|
|
>>> ts = pd.Timestamp('2020-05-24 05:01:15')
|
|
>>> ts + BYearBegin()
|
|
Timestamp('2021-01-01 05:01:15')
|
|
>>> ts - BYearBegin()
|
|
Timestamp('2020-01-01 05:01:15')
|
|
>>> ts + BYearBegin(-1)
|
|
Timestamp('2020-01-01 05:01:15')
|
|
>>> ts + BYearBegin(2)
|
|
Timestamp('2022-01-03 05:01:15')
|
|
"""
|
|
|
|
_outputName = "BusinessYearBegin"
|
|
_default_month = 1
|
|
_prefix = "BAS"
|
|
_day_opt = "business_start"
|
|
|
|
|
|
cdef class YearEnd(YearOffset):
|
|
"""
|
|
DateOffset increments between calendar year ends.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.YearEnd()
|
|
Timestamp('2022-12-31 00:00:00')
|
|
"""
|
|
|
|
_default_month = 12
|
|
_prefix = "A"
|
|
_day_opt = "end"
|
|
|
|
cdef readonly:
|
|
int _period_dtype_code
|
|
|
|
def __init__(self, n=1, normalize=False, month=None):
|
|
# Because YearEnd can be the freq for a Period, define its
|
|
# _period_dtype_code at construction for performance
|
|
YearOffset.__init__(self, n, normalize, month)
|
|
self._period_dtype_code = PeriodDtypeCode.A + self.month % 12
|
|
|
|
|
|
cdef class YearBegin(YearOffset):
|
|
"""
|
|
DateOffset of one year at beginning.
|
|
|
|
YearBegin goes to the next date which is a start of the year.
|
|
|
|
See Also
|
|
--------
|
|
:class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 12, 1)
|
|
>>> ts + pd.offsets.YearBegin()
|
|
Timestamp('2023-01-01 00:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2023, 1, 1)
|
|
>>> ts + pd.offsets.YearBegin()
|
|
Timestamp('2024-01-01 00:00:00')
|
|
|
|
If you want to get the start of the current year:
|
|
|
|
>>> ts = pd.Timestamp(2023, 1, 1)
|
|
>>> pd.offsets.YearBegin().rollback(ts)
|
|
Timestamp('2023-01-01 00:00:00')
|
|
"""
|
|
|
|
_default_month = 1
|
|
_prefix = "AS"
|
|
_day_opt = "start"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Quarter-Based Offset Classes
|
|
|
|
cdef class QuarterOffset(SingleConstructorOffset):
|
|
_attributes = tuple(["n", "normalize", "startingMonth"])
|
|
# TODO: Consider combining QuarterOffset and YearOffset __init__ at some
|
|
# point. Also apply_index, is_on_offset, rule_code if
|
|
# startingMonth vs month attr names are resolved
|
|
|
|
# FIXME(cython#4446): python annotation here gives compile-time errors
|
|
# _default_starting_month: int
|
|
# _from_name_starting_month: int
|
|
|
|
cdef readonly:
|
|
int startingMonth
|
|
|
|
def __init__(self, n=1, normalize=False, startingMonth=None):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
|
|
if startingMonth is None:
|
|
startingMonth = self._default_starting_month
|
|
self.startingMonth = startingMonth
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.startingMonth = state.pop("startingMonth")
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
kwargs = {}
|
|
if suffix:
|
|
kwargs["startingMonth"] = MONTH_TO_CAL_NUM[suffix]
|
|
else:
|
|
if cls._from_name_starting_month is not None:
|
|
kwargs["startingMonth"] = cls._from_name_starting_month
|
|
return cls(**kwargs)
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
month = MONTH_ALIASES[self.startingMonth]
|
|
return f"{self._prefix}-{month}"
|
|
|
|
def is_anchored(self) -> bool:
|
|
return self.n == 1 and self.startingMonth is not None
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
mod_month = (dt.month - self.startingMonth) % 3
|
|
return mod_month == 0 and dt.day == self._get_offset_day(dt)
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
# months_since: find the calendar quarter containing other.month,
|
|
# e.g. if other.month == 8, the calendar quarter is [Jul, Aug, Sep].
|
|
# Then find the month in that quarter containing an is_on_offset date for
|
|
# self. `months_since` is the number of months to shift other.month
|
|
# to get to this on-offset month.
|
|
months_since = other.month % 3 - self.startingMonth % 3
|
|
qtrs = roll_qtrday(
|
|
other, self.n, self.startingMonth, day_opt=self._day_opt, modby=3
|
|
)
|
|
months = qtrs * 3 - months_since
|
|
return shift_month(other, months, self._day_opt)
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
shifted = shift_quarters(
|
|
dtarr.view("i8"),
|
|
self.n,
|
|
self.startingMonth,
|
|
self._day_opt,
|
|
modby=3,
|
|
reso=reso,
|
|
)
|
|
return shifted
|
|
|
|
|
|
cdef class BQuarterEnd(QuarterOffset):
|
|
"""
|
|
DateOffset increments between the last business day of each Quarter.
|
|
|
|
startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
|
|
startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
|
|
startingMonth = 3 corresponds to dates like 3/30/2007, 6/29/2007, ...
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import BQuarterEnd
|
|
>>> ts = pd.Timestamp('2020-05-24 05:01:15')
|
|
>>> ts + BQuarterEnd()
|
|
Timestamp('2020-06-30 05:01:15')
|
|
>>> ts + BQuarterEnd(2)
|
|
Timestamp('2020-09-30 05:01:15')
|
|
>>> ts + BQuarterEnd(1, startingMonth=2)
|
|
Timestamp('2020-05-29 05:01:15')
|
|
>>> ts + BQuarterEnd(startingMonth=2)
|
|
Timestamp('2020-05-29 05:01:15')
|
|
"""
|
|
_output_name = "BusinessQuarterEnd"
|
|
_default_starting_month = 3
|
|
_from_name_starting_month = 12
|
|
_prefix = "BQ"
|
|
_day_opt = "business_end"
|
|
|
|
|
|
cdef class BQuarterBegin(QuarterOffset):
|
|
"""
|
|
DateOffset increments between the first business day of each Quarter.
|
|
|
|
startingMonth = 1 corresponds to dates like 1/01/2007, 4/01/2007, ...
|
|
startingMonth = 2 corresponds to dates like 2/01/2007, 5/01/2007, ...
|
|
startingMonth = 3 corresponds to dates like 3/01/2007, 6/01/2007, ...
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import BQuarterBegin
|
|
>>> ts = pd.Timestamp('2020-05-24 05:01:15')
|
|
>>> ts + BQuarterBegin()
|
|
Timestamp('2020-06-01 05:01:15')
|
|
>>> ts + BQuarterBegin(2)
|
|
Timestamp('2020-09-01 05:01:15')
|
|
>>> ts + BQuarterBegin(startingMonth=2)
|
|
Timestamp('2020-08-03 05:01:15')
|
|
>>> ts + BQuarterBegin(-1)
|
|
Timestamp('2020-03-02 05:01:15')
|
|
"""
|
|
_output_name = "BusinessQuarterBegin"
|
|
_default_starting_month = 3
|
|
_from_name_starting_month = 1
|
|
_prefix = "BQS"
|
|
_day_opt = "business_start"
|
|
|
|
|
|
cdef class QuarterEnd(QuarterOffset):
|
|
"""
|
|
DateOffset increments between Quarter end dates.
|
|
|
|
startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
|
|
startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
|
|
startingMonth = 3 corresponds to dates like 3/31/2007, 6/30/2007, ...
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.QuarterEnd()
|
|
Timestamp('2022-03-31 00:00:00')
|
|
"""
|
|
_default_starting_month = 3
|
|
_prefix = "Q"
|
|
_day_opt = "end"
|
|
|
|
cdef readonly:
|
|
int _period_dtype_code
|
|
|
|
def __init__(self, n=1, normalize=False, startingMonth=None):
|
|
# Because QuarterEnd can be the freq for a Period, define its
|
|
# _period_dtype_code at construction for performance
|
|
QuarterOffset.__init__(self, n, normalize, startingMonth)
|
|
self._period_dtype_code = PeriodDtypeCode.Q_DEC + self.startingMonth % 12
|
|
|
|
|
|
cdef class QuarterBegin(QuarterOffset):
|
|
"""
|
|
DateOffset increments between Quarter start dates.
|
|
|
|
startingMonth = 1 corresponds to dates like 1/01/2007, 4/01/2007, ...
|
|
startingMonth = 2 corresponds to dates like 2/01/2007, 5/01/2007, ...
|
|
startingMonth = 3 corresponds to dates like 3/01/2007, 6/01/2007, ...
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.QuarterBegin()
|
|
Timestamp('2022-03-01 00:00:00')
|
|
"""
|
|
_default_starting_month = 3
|
|
_from_name_starting_month = 1
|
|
_prefix = "QS"
|
|
_day_opt = "start"
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Month-Based Offset Classes
|
|
|
|
cdef class MonthOffset(SingleConstructorOffset):
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return dt.day == self._get_offset_day(dt)
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
compare_day = self._get_offset_day(other)
|
|
n = roll_convention(other.day, self.n, compare_day)
|
|
return shift_month(other, n, self._day_opt)
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
shifted = shift_months(dtarr.view("i8"), self.n, self._day_opt, reso=reso)
|
|
return shifted
|
|
|
|
cpdef __setstate__(self, state):
|
|
state.pop("_use_relativedelta", False)
|
|
state.pop("offset", None)
|
|
state.pop("_offset", None)
|
|
state.pop("kwds", {})
|
|
|
|
BaseOffset.__setstate__(self, state)
|
|
|
|
|
|
cdef class MonthEnd(MonthOffset):
|
|
"""
|
|
DateOffset of one month end.
|
|
|
|
MonthEnd goes to the next date which is an end of the month.
|
|
|
|
See Also
|
|
--------
|
|
:class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 30)
|
|
>>> ts + pd.offsets.MonthEnd()
|
|
Timestamp('2022-01-31 00:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2022, 1, 31)
|
|
>>> ts + pd.offsets.MonthEnd()
|
|
Timestamp('2022-02-28 00:00:00')
|
|
|
|
If you want to get the end of the current month:
|
|
|
|
>>> ts = pd.Timestamp(2022, 1, 31)
|
|
>>> pd.offsets.MonthEnd().rollforward(ts)
|
|
Timestamp('2022-01-31 00:00:00')
|
|
"""
|
|
_period_dtype_code = PeriodDtypeCode.M
|
|
_prefix = "M"
|
|
_day_opt = "end"
|
|
|
|
|
|
cdef class MonthBegin(MonthOffset):
|
|
"""
|
|
DateOffset of one month at beginning.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.MonthBegin()
|
|
Timestamp('2022-02-01 00:00:00')
|
|
"""
|
|
_prefix = "MS"
|
|
_day_opt = "start"
|
|
|
|
|
|
cdef class BusinessMonthEnd(MonthOffset):
|
|
"""
|
|
DateOffset increments between the last business day of the month.
|
|
|
|
BusinessMonthEnd goes to the next date which is the last business day of the month.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 11, 29)
|
|
>>> ts + pd.offsets.BMonthEnd()
|
|
Timestamp('2022-11-30 00:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2022, 11, 30)
|
|
>>> ts + pd.offsets.BMonthEnd()
|
|
Timestamp('2022-12-30 00:00:00')
|
|
|
|
If you want to get the end of the current business month:
|
|
|
|
>>> ts = pd.Timestamp(2022, 11, 30)
|
|
>>> pd.offsets.BMonthEnd().rollforward(ts)
|
|
Timestamp('2022-11-30 00:00:00')
|
|
"""
|
|
_prefix = "BM"
|
|
_day_opt = "business_end"
|
|
|
|
|
|
cdef class BusinessMonthBegin(MonthOffset):
|
|
"""
|
|
DateOffset of one month at the first business day.
|
|
|
|
Examples
|
|
--------
|
|
>>> from pandas.tseries.offsets import BMonthBegin
|
|
>>> ts=pd.Timestamp('2020-05-24 05:01:15')
|
|
>>> ts + BMonthBegin()
|
|
Timestamp('2020-06-01 05:01:15')
|
|
>>> ts + BMonthBegin(2)
|
|
Timestamp('2020-07-01 05:01:15')
|
|
>>> ts + BMonthBegin(-3)
|
|
Timestamp('2020-03-02 05:01:15')
|
|
"""
|
|
_prefix = "BMS"
|
|
_day_opt = "business_start"
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Semi-Month Based Offsets
|
|
|
|
cdef class SemiMonthOffset(SingleConstructorOffset):
|
|
_default_day_of_month = 15
|
|
_min_day_of_month = 2
|
|
_attributes = tuple(["n", "normalize", "day_of_month"])
|
|
|
|
cdef readonly:
|
|
int day_of_month
|
|
|
|
def __init__(self, n=1, normalize=False, day_of_month=None):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
|
|
if day_of_month is None:
|
|
day_of_month = self._default_day_of_month
|
|
|
|
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 "
|
|
f"{self._min_day_of_month}<=day_of_month<=27, "
|
|
f"got {self.day_of_month}"
|
|
)
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self.day_of_month = state.pop("day_of_month")
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
return cls(day_of_month=suffix)
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
suffix = f"-{self.day_of_month}"
|
|
return self._prefix + suffix
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
is_start = isinstance(self, SemiMonthBegin)
|
|
|
|
# shift `other` to self.day_of_month, incrementing `n` if necessary
|
|
n = roll_convention(other.day, self.n, self.day_of_month)
|
|
|
|
days_in_month = get_days_in_month(other.year, other.month)
|
|
# For SemiMonthBegin on other.day == 1 and
|
|
# SemiMonthEnd on other.day == days_in_month,
|
|
# shifting `other` to `self.day_of_month` _always_ requires
|
|
# incrementing/decrementing `n`, regardless of whether it is
|
|
# initially positive.
|
|
if is_start and (self.n <= 0 and other.day == 1):
|
|
n -= 1
|
|
elif (not is_start) and (self.n > 0 and other.day == days_in_month):
|
|
n += 1
|
|
|
|
if is_start:
|
|
months = n // 2 + n % 2
|
|
to_day = 1 if n % 2 else self.day_of_month
|
|
else:
|
|
months = n // 2
|
|
to_day = 31 if n % 2 else self.day_of_month
|
|
|
|
return shift_month(other, months, to_day)
|
|
|
|
@apply_array_wraps
|
|
@cython.wraparound(False)
|
|
@cython.boundscheck(False)
|
|
def _apply_array(self, dtarr):
|
|
cdef:
|
|
ndarray i8other = dtarr.view("i8")
|
|
Py_ssize_t i, count = dtarr.size
|
|
int64_t val, res_val
|
|
ndarray out = cnp.PyArray_EMPTY(
|
|
i8other.ndim, i8other.shape, cnp.NPY_INT64, 0
|
|
)
|
|
npy_datetimestruct dts
|
|
int months, to_day, nadj, n = self.n
|
|
int days_in_month, day, anchor_dom = self.day_of_month
|
|
bint is_start = isinstance(self, SemiMonthBegin)
|
|
NPY_DATETIMEUNIT reso = get_unit_from_dtype(dtarr.dtype)
|
|
cnp.broadcast mi = cnp.PyArray_MultiIterNew2(out, i8other)
|
|
|
|
with nogil:
|
|
for i in range(count):
|
|
# Analogous to: val = i8other[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
|
|
else:
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
day = dts.day
|
|
|
|
# Adjust so that we are always looking at self.day_of_month,
|
|
# incrementing/decrementing n if necessary.
|
|
nadj = roll_convention(day, n, anchor_dom)
|
|
|
|
days_in_month = get_days_in_month(dts.year, dts.month)
|
|
# For SemiMonthBegin on other.day == 1 and
|
|
# SemiMonthEnd on other.day == days_in_month,
|
|
# shifting `other` to `self.day_of_month` _always_ requires
|
|
# incrementing/decrementing `n`, regardless of whether it is
|
|
# initially positive.
|
|
if is_start and (n <= 0 and day == 1):
|
|
nadj -= 1
|
|
elif (not is_start) and (n > 0 and day == days_in_month):
|
|
nadj += 1
|
|
|
|
if is_start:
|
|
# See also: SemiMonthBegin._apply
|
|
months = nadj // 2 + nadj % 2
|
|
to_day = 1 if nadj % 2 else anchor_dom
|
|
|
|
else:
|
|
# See also: SemiMonthEnd._apply
|
|
months = nadj // 2
|
|
to_day = 31 if nadj % 2 else anchor_dom
|
|
|
|
dts.year = year_add_months(dts, months)
|
|
dts.month = month_add_months(dts, months)
|
|
days_in_month = get_days_in_month(dts.year, dts.month)
|
|
dts.day = min(to_day, days_in_month)
|
|
|
|
res_val = npy_datetimestruct_to_datetime(reso, &dts)
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
return out
|
|
|
|
|
|
cdef class SemiMonthEnd(SemiMonthOffset):
|
|
"""
|
|
Two DateOffset's per month repeating on the last day of the month & day_of_month.
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
normalize : bool, default False
|
|
day_of_month : int, {1, 3,...,27}, default 15
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 14)
|
|
>>> ts + pd.offsets.SemiMonthEnd()
|
|
Timestamp('2022-01-15 00:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2022, 1, 15)
|
|
>>> ts + pd.offsets.SemiMonthEnd()
|
|
Timestamp('2022-01-31 00:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2022, 1, 31)
|
|
>>> ts + pd.offsets.SemiMonthEnd()
|
|
Timestamp('2022-02-15 00:00:00')
|
|
|
|
If you want to get the result for the current month:
|
|
|
|
>>> ts = pd.Timestamp(2022, 1, 15)
|
|
>>> pd.offsets.SemiMonthEnd().rollforward(ts)
|
|
Timestamp('2022-01-15 00:00:00')
|
|
"""
|
|
_prefix = "SM"
|
|
_min_day_of_month = 1
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
days_in_month = get_days_in_month(dt.year, dt.month)
|
|
return dt.day in (self.day_of_month, days_in_month)
|
|
|
|
|
|
cdef class SemiMonthBegin(SemiMonthOffset):
|
|
"""
|
|
Two DateOffset's per month repeating on the first day of the month & day_of_month.
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
normalize : bool, default False
|
|
day_of_month : int, {2, 3,...,27}, default 15
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.SemiMonthBegin()
|
|
Timestamp('2022-01-15 00:00:00')
|
|
"""
|
|
|
|
_prefix = "SMS"
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return dt.day in (1, self.day_of_month)
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Week-Based Offset Classes
|
|
|
|
|
|
cdef class Week(SingleConstructorOffset):
|
|
"""
|
|
Weekly offset.
|
|
|
|
Parameters
|
|
----------
|
|
weekday : int or None, default None
|
|
Always generate specific day of week.
|
|
0 for Monday and 6 for Sunday.
|
|
|
|
See Also
|
|
--------
|
|
pd.tseries.offsets.WeekOfMonth :
|
|
Describes monthly dates like, the Tuesday of the
|
|
2nd week of each month.
|
|
|
|
Examples
|
|
--------
|
|
|
|
>>> date_object = pd.Timestamp("2023-01-13")
|
|
>>> date_object
|
|
Timestamp('2023-01-13 00:00:00')
|
|
|
|
>>> date_plus_one_week = date_object + pd.tseries.offsets.Week(n=1)
|
|
>>> date_plus_one_week
|
|
Timestamp('2023-01-20 00:00:00')
|
|
|
|
>>> date_next_monday = date_object + pd.tseries.offsets.Week(weekday=0)
|
|
>>> date_next_monday
|
|
Timestamp('2023-01-16 00:00:00')
|
|
|
|
>>> date_next_sunday = date_object + pd.tseries.offsets.Week(weekday=6)
|
|
>>> date_next_sunday
|
|
Timestamp('2023-01-15 00:00:00')
|
|
"""
|
|
|
|
_inc = timedelta(weeks=1)
|
|
_prefix = "W"
|
|
_attributes = tuple(["n", "normalize", "weekday"])
|
|
|
|
cdef readonly:
|
|
object weekday # int or None
|
|
int _period_dtype_code
|
|
|
|
def __init__(self, n=1, normalize=False, weekday=None):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
self.weekday = weekday
|
|
|
|
if self.weekday is not None:
|
|
if self.weekday < 0 or self.weekday > 6:
|
|
raise ValueError(f"Day must be 0<=day<=6, got {self.weekday}")
|
|
|
|
self._period_dtype_code = PeriodDtypeCode.W_SUN + (weekday + 1) % 7
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self.weekday = state.pop("weekday")
|
|
self._cache = state.pop("_cache", {})
|
|
|
|
def is_anchored(self) -> bool:
|
|
return self.n == 1 and self.weekday is not None
|
|
|
|
@apply_wraps
|
|
def _apply(self, other):
|
|
if self.weekday is None:
|
|
return other + self.n * self._inc
|
|
|
|
if not PyDateTime_Check(other):
|
|
raise TypeError(
|
|
f"Cannot add {type(other).__name__} to {type(self).__name__}"
|
|
)
|
|
|
|
k = self.n
|
|
otherDay = other.weekday()
|
|
if otherDay != self.weekday:
|
|
other = other + timedelta((self.weekday - otherDay) % 7)
|
|
if k > 0:
|
|
k -= 1
|
|
|
|
return other + timedelta(weeks=k)
|
|
|
|
@apply_array_wraps
|
|
def _apply_array(self, dtarr):
|
|
if self.weekday is None:
|
|
td = timedelta(days=7 * self.n)
|
|
td64 = np.timedelta64(td, "ns")
|
|
return dtarr + td64
|
|
else:
|
|
reso = get_unit_from_dtype(dtarr.dtype)
|
|
i8other = dtarr.view("i8")
|
|
return self._end_apply_index(i8other, reso=reso)
|
|
|
|
@cython.wraparound(False)
|
|
@cython.boundscheck(False)
|
|
cdef ndarray _end_apply_index(self, ndarray i8other, NPY_DATETIMEUNIT reso):
|
|
"""
|
|
Add self to the given DatetimeIndex, specialized for case where
|
|
self.weekday is non-null.
|
|
|
|
Parameters
|
|
----------
|
|
i8other : const int64_t[:]
|
|
reso : NPY_DATETIMEUNIT
|
|
|
|
Returns
|
|
-------
|
|
ndarray[int64_t]
|
|
"""
|
|
cdef:
|
|
Py_ssize_t i, count = i8other.size
|
|
int64_t val, res_val
|
|
ndarray out = cnp.PyArray_EMPTY(
|
|
i8other.ndim, i8other.shape, cnp.NPY_INT64, 0
|
|
)
|
|
npy_datetimestruct dts
|
|
int wday, days, weeks, n = self.n
|
|
int anchor_weekday = self.weekday
|
|
int64_t DAY_PERIODS = periods_per_day(reso)
|
|
cnp.broadcast mi = cnp.PyArray_MultiIterNew2(out, i8other)
|
|
|
|
with nogil:
|
|
for i in range(count):
|
|
# Analogous to: val = i8other[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
else:
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
wday = dayofweek(dts.year, dts.month, dts.day)
|
|
|
|
days = 0
|
|
weeks = n
|
|
if wday != anchor_weekday:
|
|
days = (anchor_weekday - wday) % 7
|
|
if weeks > 0:
|
|
weeks -= 1
|
|
|
|
res_val = val + (7 * weeks + days) * DAY_PERIODS
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
return out
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
elif self.weekday is None:
|
|
return True
|
|
return dt.weekday() == self.weekday
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
suffix = ""
|
|
if self.weekday is not None:
|
|
weekday = int_to_weekday[self.weekday]
|
|
suffix = f"-{weekday}"
|
|
return self._prefix + suffix
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
if not suffix:
|
|
weekday = None
|
|
else:
|
|
weekday = weekday_to_int[suffix]
|
|
return cls(weekday=weekday)
|
|
|
|
|
|
cdef class WeekOfMonth(WeekOfMonthMixin):
|
|
"""
|
|
Describes monthly dates like "the Tuesday of the 2nd week of each month".
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
week : int {0, 1, 2, 3, ...}, default 0
|
|
A specific integer for the week of the month.
|
|
e.g. 0 is 1st week of month, 1 is the 2nd week, etc.
|
|
weekday : int {0, 1, ..., 6}, default 0
|
|
A specific integer for the day of the week.
|
|
|
|
- 0 is Monday
|
|
- 1 is Tuesday
|
|
- 2 is Wednesday
|
|
- 3 is Thursday
|
|
- 4 is Friday
|
|
- 5 is Saturday
|
|
- 6 is Sunday.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.WeekOfMonth()
|
|
Timestamp('2022-01-03 00:00:00')
|
|
"""
|
|
|
|
_prefix = "WOM"
|
|
_attributes = tuple(["n", "normalize", "week", "weekday"])
|
|
|
|
def __init__(self, n=1, normalize=False, week=0, weekday=0):
|
|
WeekOfMonthMixin.__init__(self, n, normalize, weekday)
|
|
self.week = week
|
|
|
|
if self.week < 0 or self.week > 3:
|
|
raise ValueError(f"Week must be 0<=week<=3, got {self.week}")
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self.weekday = state.pop("weekday")
|
|
self.week = state.pop("week")
|
|
|
|
def _get_offset_day(self, other: datetime) -> int:
|
|
"""
|
|
Find the day in the same month as other that has the same
|
|
weekday as self.weekday and is the self.week'th such day in the month.
|
|
|
|
Parameters
|
|
----------
|
|
other : datetime
|
|
|
|
Returns
|
|
-------
|
|
day : int
|
|
"""
|
|
mstart = datetime(other.year, other.month, 1)
|
|
wday = mstart.weekday()
|
|
shift_days = (self.weekday - wday) % 7
|
|
return 1 + shift_days + self.week * 7
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
if not suffix:
|
|
raise ValueError(f"Prefix {repr(cls._prefix)} requires a suffix.")
|
|
# only one digit weeks (1 --> week 0, 2 --> week 1, etc.)
|
|
week = int(suffix[0]) - 1
|
|
weekday = weekday_to_int[suffix[1:]]
|
|
return cls(week=week, weekday=weekday)
|
|
|
|
|
|
cdef class LastWeekOfMonth(WeekOfMonthMixin):
|
|
"""
|
|
Describes monthly dates in last week of month.
|
|
|
|
For example "the last Tuesday of each month".
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
weekday : int {0, 1, ..., 6}, default 0
|
|
A specific integer for the day of the week.
|
|
|
|
- 0 is Monday
|
|
- 1 is Tuesday
|
|
- 2 is Wednesday
|
|
- 3 is Thursday
|
|
- 4 is Friday
|
|
- 5 is Saturday
|
|
- 6 is Sunday.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.LastWeekOfMonth()
|
|
Timestamp('2022-01-31 00:00:00')
|
|
"""
|
|
|
|
_prefix = "LWOM"
|
|
_attributes = tuple(["n", "normalize", "weekday"])
|
|
|
|
def __init__(self, n=1, normalize=False, weekday=0):
|
|
WeekOfMonthMixin.__init__(self, n, normalize, weekday)
|
|
self.week = -1
|
|
|
|
if self.n == 0:
|
|
raise ValueError("N cannot be 0")
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self.weekday = state.pop("weekday")
|
|
self.week = -1
|
|
|
|
def _get_offset_day(self, other: datetime) -> int:
|
|
"""
|
|
Find the day in the same month as other that has the same
|
|
weekday as self.weekday and is the last such day in the month.
|
|
|
|
Parameters
|
|
----------
|
|
other: datetime
|
|
|
|
Returns
|
|
-------
|
|
day: int
|
|
"""
|
|
dim = get_days_in_month(other.year, other.month)
|
|
mend = datetime(other.year, other.month, dim)
|
|
wday = mend.weekday()
|
|
shift_days = (wday - self.weekday) % 7
|
|
return dim - shift_days
|
|
|
|
@classmethod
|
|
def _from_name(cls, suffix=None):
|
|
if not suffix:
|
|
raise ValueError(f"Prefix {repr(cls._prefix)} requires a suffix.")
|
|
weekday = weekday_to_int[suffix]
|
|
return cls(weekday=weekday)
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Special Offset Classes
|
|
|
|
cdef class FY5253Mixin(SingleConstructorOffset):
|
|
cdef readonly:
|
|
int startingMonth
|
|
int weekday
|
|
str variation
|
|
|
|
def __init__(
|
|
self, n=1, normalize=False, weekday=0, startingMonth=1, variation="nearest"
|
|
):
|
|
BaseOffset.__init__(self, n, normalize)
|
|
self.startingMonth = startingMonth
|
|
self.weekday = weekday
|
|
self.variation = variation
|
|
|
|
if self.n == 0:
|
|
raise ValueError("N cannot be 0")
|
|
|
|
if self.variation not in ["nearest", "last"]:
|
|
raise ValueError(f"{self.variation} is not a valid variation")
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
self.weekday = state.pop("weekday")
|
|
self.variation = state.pop("variation")
|
|
|
|
def is_anchored(self) -> bool:
|
|
return (
|
|
self.n == 1 and self.startingMonth is not None and self.weekday is not None
|
|
)
|
|
|
|
# --------------------------------------------------------------------
|
|
# Name-related methods
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
prefix = self._prefix
|
|
suffix = self.get_rule_code_suffix()
|
|
return f"{prefix}-{suffix}"
|
|
|
|
def _get_suffix_prefix(self) -> str:
|
|
if self.variation == "nearest":
|
|
return "N"
|
|
else:
|
|
return "L"
|
|
|
|
def get_rule_code_suffix(self) -> str:
|
|
prefix = self._get_suffix_prefix()
|
|
month = MONTH_ALIASES[self.startingMonth]
|
|
weekday = int_to_weekday[self.weekday]
|
|
return f"{prefix}-{month}-{weekday}"
|
|
|
|
|
|
cdef class FY5253(FY5253Mixin):
|
|
"""
|
|
Describes 52-53 week fiscal year. This is also known as a 4-4-5 calendar.
|
|
|
|
It is used by companies that desire that their
|
|
fiscal year always end on the same day of the week.
|
|
|
|
It is a method of managing accounting periods.
|
|
It is a common calendar structure for some industries,
|
|
such as retail, manufacturing and parking industry.
|
|
|
|
For more information see:
|
|
https://en.wikipedia.org/wiki/4-4-5_calendar
|
|
|
|
The year may either:
|
|
|
|
- end on the last X day of the Y month.
|
|
- end on the last X day closest to the last day of the Y month.
|
|
|
|
X is a specific day of the week.
|
|
Y is a certain month of the year
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
weekday : int {0, 1, ..., 6}, default 0
|
|
A specific integer for the day of the week.
|
|
|
|
- 0 is Monday
|
|
- 1 is Tuesday
|
|
- 2 is Wednesday
|
|
- 3 is Thursday
|
|
- 4 is Friday
|
|
- 5 is Saturday
|
|
- 6 is Sunday.
|
|
|
|
startingMonth : int {1, 2, ... 12}, default 1
|
|
The month in which the fiscal year ends.
|
|
|
|
variation : str, default "nearest"
|
|
Method of employing 4-4-5 calendar.
|
|
|
|
There are two options:
|
|
|
|
- "nearest" means year end is **weekday** closest to last day of month in year.
|
|
- "last" means year end is final **weekday** of the final month in fiscal year.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.FY5253()
|
|
Timestamp('2022-01-31 00:00:00')
|
|
"""
|
|
|
|
_prefix = "RE"
|
|
_attributes = tuple(["n", "normalize", "weekday", "startingMonth", "variation"])
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
dt = datetime(dt.year, dt.month, dt.day)
|
|
year_end = self.get_year_end(dt)
|
|
|
|
if self.variation == "nearest":
|
|
# We have to check the year end of "this" cal year AND the previous
|
|
return year_end == dt or self.get_year_end(shift_month(dt, -1, None)) == dt
|
|
else:
|
|
return year_end == dt
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
norm = Timestamp(other).normalize()
|
|
|
|
n = self.n
|
|
prev_year = self.get_year_end(datetime(other.year - 1, self.startingMonth, 1))
|
|
cur_year = self.get_year_end(datetime(other.year, self.startingMonth, 1))
|
|
next_year = self.get_year_end(datetime(other.year + 1, self.startingMonth, 1))
|
|
|
|
prev_year = localize_pydatetime(prev_year, other.tzinfo)
|
|
cur_year = localize_pydatetime(cur_year, other.tzinfo)
|
|
next_year = localize_pydatetime(next_year, other.tzinfo)
|
|
|
|
# Note: next_year.year == other.year + 1, so we will always
|
|
# have other < next_year
|
|
if norm == prev_year:
|
|
n -= 1
|
|
elif norm == cur_year:
|
|
pass
|
|
elif n > 0:
|
|
if norm < prev_year:
|
|
n -= 2
|
|
elif prev_year < norm < cur_year:
|
|
n -= 1
|
|
elif cur_year < norm < next_year:
|
|
pass
|
|
else:
|
|
if cur_year < norm < next_year:
|
|
n += 1
|
|
elif prev_year < norm < cur_year:
|
|
pass
|
|
elif (
|
|
norm.year == prev_year.year
|
|
and norm < prev_year
|
|
and prev_year - norm <= timedelta(6)
|
|
):
|
|
# GH#14774, error when next_year.year == cur_year.year
|
|
# e.g. prev_year == datetime(2004, 1, 3),
|
|
# other == datetime(2004, 1, 1)
|
|
n -= 1
|
|
else:
|
|
assert False
|
|
|
|
shifted = datetime(other.year + n, self.startingMonth, 1)
|
|
result = self.get_year_end(shifted)
|
|
result = datetime(
|
|
result.year,
|
|
result.month,
|
|
result.day,
|
|
other.hour,
|
|
other.minute,
|
|
other.second,
|
|
other.microsecond,
|
|
)
|
|
return result
|
|
|
|
def get_year_end(self, dt: datetime) -> datetime:
|
|
assert dt.tzinfo is None
|
|
|
|
dim = get_days_in_month(dt.year, self.startingMonth)
|
|
target_date = datetime(dt.year, self.startingMonth, dim)
|
|
wkday_diff = self.weekday - target_date.weekday()
|
|
if wkday_diff == 0:
|
|
# year_end is the same for "last" and "nearest" cases
|
|
return target_date
|
|
|
|
if self.variation == "last":
|
|
days_forward = (wkday_diff % 7) - 7
|
|
|
|
# days_forward is always negative, so we always end up
|
|
# in the same year as dt
|
|
return target_date + timedelta(days=days_forward)
|
|
else:
|
|
# variation == "nearest":
|
|
days_forward = wkday_diff % 7
|
|
if days_forward <= 3:
|
|
# The upcoming self.weekday is closer than the previous one
|
|
return target_date + timedelta(days_forward)
|
|
else:
|
|
# The previous self.weekday is closer than the upcoming one
|
|
return target_date + timedelta(days_forward - 7)
|
|
|
|
@classmethod
|
|
def _parse_suffix(cls, varion_code, startingMonth_code, weekday_code):
|
|
if varion_code == "N":
|
|
variation = "nearest"
|
|
elif varion_code == "L":
|
|
variation = "last"
|
|
else:
|
|
raise ValueError(f"Unable to parse varion_code: {varion_code}")
|
|
|
|
startingMonth = MONTH_TO_CAL_NUM[startingMonth_code]
|
|
weekday = weekday_to_int[weekday_code]
|
|
|
|
return {
|
|
"weekday": weekday,
|
|
"startingMonth": startingMonth,
|
|
"variation": variation,
|
|
}
|
|
|
|
@classmethod
|
|
def _from_name(cls, *args):
|
|
return cls(**cls._parse_suffix(*args))
|
|
|
|
|
|
cdef class FY5253Quarter(FY5253Mixin):
|
|
"""
|
|
DateOffset increments between business quarter dates for 52-53 week fiscal year.
|
|
|
|
Also known as a 4-4-5 calendar.
|
|
|
|
It is used by companies that desire that their
|
|
fiscal year always end on the same day of the week.
|
|
|
|
It is a method of managing accounting periods.
|
|
It is a common calendar structure for some industries,
|
|
such as retail, manufacturing and parking industry.
|
|
|
|
For more information see:
|
|
https://en.wikipedia.org/wiki/4-4-5_calendar
|
|
|
|
The year may either:
|
|
|
|
- end on the last X day of the Y month.
|
|
- end on the last X day closest to the last day of the Y month.
|
|
|
|
X is a specific day of the week.
|
|
Y is a certain month of the year
|
|
|
|
startingMonth = 1 corresponds to dates like 1/31/2007, 4/30/2007, ...
|
|
startingMonth = 2 corresponds to dates like 2/28/2007, 5/31/2007, ...
|
|
startingMonth = 3 corresponds to dates like 3/30/2007, 6/29/2007, ...
|
|
|
|
Parameters
|
|
----------
|
|
n : int
|
|
weekday : int {0, 1, ..., 6}, default 0
|
|
A specific integer for the day of the week.
|
|
|
|
- 0 is Monday
|
|
- 1 is Tuesday
|
|
- 2 is Wednesday
|
|
- 3 is Thursday
|
|
- 4 is Friday
|
|
- 5 is Saturday
|
|
- 6 is Sunday.
|
|
|
|
startingMonth : int {1, 2, ..., 12}, default 1
|
|
The month in which fiscal years end.
|
|
|
|
qtr_with_extra_week : int {1, 2, 3, 4}, default 1
|
|
The quarter number that has the leap or 14 week when needed.
|
|
|
|
variation : str, default "nearest"
|
|
Method of employing 4-4-5 calendar.
|
|
|
|
There are two options:
|
|
|
|
- "nearest" means year end is **weekday** closest to last day of month in year.
|
|
- "last" means year end is final **weekday** of the final month in fiscal year.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.FY5253Quarter()
|
|
Timestamp('2022-01-31 00:00:00')
|
|
"""
|
|
|
|
_prefix = "REQ"
|
|
_attributes = tuple(
|
|
[
|
|
"n",
|
|
"normalize",
|
|
"weekday",
|
|
"startingMonth",
|
|
"qtr_with_extra_week",
|
|
"variation",
|
|
]
|
|
)
|
|
|
|
cdef readonly:
|
|
int qtr_with_extra_week
|
|
|
|
def __init__(
|
|
self,
|
|
n=1,
|
|
normalize=False,
|
|
weekday=0,
|
|
startingMonth=1,
|
|
qtr_with_extra_week=1,
|
|
variation="nearest",
|
|
):
|
|
FY5253Mixin.__init__(
|
|
self, n, normalize, weekday, startingMonth, variation
|
|
)
|
|
self.qtr_with_extra_week = qtr_with_extra_week
|
|
|
|
cpdef __setstate__(self, state):
|
|
FY5253Mixin.__setstate__(self, state)
|
|
self.qtr_with_extra_week = state.pop("qtr_with_extra_week")
|
|
|
|
@cache_readonly
|
|
def _offset(self):
|
|
return FY5253(
|
|
startingMonth=self.startingMonth,
|
|
weekday=self.weekday,
|
|
variation=self.variation,
|
|
)
|
|
|
|
def _rollback_to_year(self, other: datetime):
|
|
"""
|
|
Roll `other` back to the most recent date that was on a fiscal year
|
|
end.
|
|
|
|
Return the date of that year-end, the number of full quarters
|
|
elapsed between that year-end and other, and the remaining Timedelta
|
|
since the most recent quarter-end.
|
|
|
|
Parameters
|
|
----------
|
|
other : datetime or Timestamp
|
|
|
|
Returns
|
|
-------
|
|
tuple of
|
|
prev_year_end : Timestamp giving most recent fiscal year end
|
|
num_qtrs : int
|
|
tdelta : Timedelta
|
|
"""
|
|
num_qtrs = 0
|
|
|
|
norm = Timestamp(other).tz_localize(None)
|
|
start = self._offset.rollback(norm)
|
|
# Note: start <= norm and self._offset.is_on_offset(start)
|
|
|
|
if start < norm:
|
|
# roll adjustment
|
|
qtr_lens = self.get_weeks(norm)
|
|
|
|
# check that qtr_lens is consistent with self._offset addition
|
|
end = _shift_day(start, days=7 * sum(qtr_lens))
|
|
assert self._offset.is_on_offset(end), (start, end, qtr_lens)
|
|
|
|
tdelta = norm - start
|
|
for qlen in qtr_lens:
|
|
if qlen * 7 <= tdelta.days:
|
|
num_qtrs += 1
|
|
tdelta -= (
|
|
<_Timedelta>Timedelta(days=qlen * 7)
|
|
)._as_creso(norm._creso)
|
|
else:
|
|
break
|
|
else:
|
|
tdelta = Timedelta(0)
|
|
|
|
# Note: we always have tdelta._value>= 0
|
|
return start, num_qtrs, tdelta
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
# Note: self.n == 0 is not allowed.
|
|
|
|
n = self.n
|
|
|
|
prev_year_end, num_qtrs, tdelta = self._rollback_to_year(other)
|
|
res = prev_year_end
|
|
n += num_qtrs
|
|
if self.n <= 0 and tdelta._value > 0:
|
|
n += 1
|
|
|
|
# Possible speedup by handling years first.
|
|
years = n // 4
|
|
if years:
|
|
res += self._offset * years
|
|
n -= years * 4
|
|
|
|
# Add an extra day to make *sure* we are getting the quarter lengths
|
|
# for the upcoming year, not the previous year
|
|
qtr_lens = self.get_weeks(res + Timedelta(days=1))
|
|
|
|
# Note: we always have 0 <= n < 4
|
|
weeks = sum(qtr_lens[:n])
|
|
if weeks:
|
|
res = _shift_day(res, days=weeks * 7)
|
|
|
|
return res
|
|
|
|
def get_weeks(self, dt: datetime):
|
|
ret = [13] * 4
|
|
|
|
year_has_extra_week = self.year_has_extra_week(dt)
|
|
|
|
if year_has_extra_week:
|
|
ret[self.qtr_with_extra_week - 1] = 14
|
|
|
|
return ret
|
|
|
|
def year_has_extra_week(self, dt: datetime) -> bool:
|
|
# Avoid round-down errors --> normalize to get
|
|
# e.g. '370D' instead of '360D23H'
|
|
norm = Timestamp(dt).normalize().tz_localize(None)
|
|
|
|
next_year_end = self._offset.rollforward(norm)
|
|
prev_year_end = norm - self._offset
|
|
weeks_in_year = (next_year_end - prev_year_end).days / 7
|
|
assert weeks_in_year in [52, 53], weeks_in_year
|
|
return weeks_in_year == 53
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
if self._offset.is_on_offset(dt):
|
|
return True
|
|
|
|
next_year_end = dt - self._offset
|
|
|
|
qtr_lens = self.get_weeks(dt)
|
|
|
|
current = next_year_end
|
|
for qtr_len in qtr_lens:
|
|
current = _shift_day(current, days=qtr_len * 7)
|
|
if dt == current:
|
|
return True
|
|
return False
|
|
|
|
@property
|
|
def rule_code(self) -> str:
|
|
suffix = FY5253Mixin.rule_code.__get__(self)
|
|
qtr = self.qtr_with_extra_week
|
|
return f"{suffix}-{qtr}"
|
|
|
|
@classmethod
|
|
def _from_name(cls, *args):
|
|
return cls(
|
|
**dict(FY5253._parse_suffix(*args[:-1]), qtr_with_extra_week=int(args[-1]))
|
|
)
|
|
|
|
|
|
cdef class Easter(SingleConstructorOffset):
|
|
"""
|
|
DateOffset for the Easter holiday using logic defined in dateutil.
|
|
|
|
Right now uses the revised method which is valid in years 1583-4099.
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 1, 1)
|
|
>>> ts + pd.offsets.Easter()
|
|
Timestamp('2022-04-17 00:00:00')
|
|
"""
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.n = state.pop("n")
|
|
self.normalize = state.pop("normalize")
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
current_easter = easter(other.year)
|
|
current_easter = datetime(
|
|
current_easter.year, current_easter.month, current_easter.day
|
|
)
|
|
current_easter = localize_pydatetime(current_easter, other.tzinfo)
|
|
|
|
n = self.n
|
|
if n >= 0 and other < current_easter:
|
|
n -= 1
|
|
elif n < 0 and other > current_easter:
|
|
n += 1
|
|
# TODO: Why does this handle the 0 case the opposite of others?
|
|
|
|
# NOTE: easter returns a datetime.date so we have to convert to type of
|
|
# other
|
|
new = easter(other.year + n)
|
|
new = datetime(
|
|
new.year,
|
|
new.month,
|
|
new.day,
|
|
other.hour,
|
|
other.minute,
|
|
other.second,
|
|
other.microsecond,
|
|
)
|
|
return new
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
return date(dt.year, dt.month, dt.day) == easter(dt.year)
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# Custom Offset classes
|
|
|
|
|
|
cdef class CustomBusinessDay(BusinessDay):
|
|
"""
|
|
DateOffset subclass representing custom business days excluding holidays.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of days represented.
|
|
normalize : bool, default False
|
|
Normalize start/end dates to midnight before generating date range.
|
|
weekmask : str, Default 'Mon Tue Wed Thu Fri'
|
|
Weekmask of valid business days, passed to ``numpy.busdaycalendar``.
|
|
holidays : list
|
|
List/array of dates to exclude from the set of valid business days,
|
|
passed to ``numpy.busdaycalendar``.
|
|
calendar : np.busdaycalendar
|
|
offset : timedelta, default timedelta(0)
|
|
|
|
Examples
|
|
--------
|
|
>>> ts = pd.Timestamp(2022, 8, 5)
|
|
>>> ts + pd.offsets.CustomBusinessDay(1)
|
|
Timestamp('2022-08-08 00:00:00')
|
|
"""
|
|
|
|
_prefix = "C"
|
|
_attributes = tuple(
|
|
["n", "normalize", "weekmask", "holidays", "calendar", "offset"]
|
|
)
|
|
|
|
_apply_array = BaseOffset._apply_array
|
|
|
|
def __init__(
|
|
self,
|
|
n=1,
|
|
normalize=False,
|
|
weekmask="Mon Tue Wed Thu Fri",
|
|
holidays=None,
|
|
calendar=None,
|
|
offset=timedelta(0),
|
|
):
|
|
BusinessDay.__init__(self, n, normalize, offset)
|
|
self._init_custom(weekmask, holidays, calendar)
|
|
|
|
cpdef __setstate__(self, state):
|
|
self.holidays = state.pop("holidays")
|
|
self.weekmask = state.pop("weekmask")
|
|
BusinessDay.__setstate__(self, state)
|
|
|
|
@apply_wraps
|
|
def _apply(self, other):
|
|
if self.n <= 0:
|
|
roll = "forward"
|
|
else:
|
|
roll = "backward"
|
|
|
|
if PyDateTime_Check(other):
|
|
date_in = other
|
|
np_dt = np.datetime64(date_in.date())
|
|
|
|
np_incr_dt = np.busday_offset(
|
|
np_dt, self.n, roll=roll, busdaycal=self.calendar
|
|
)
|
|
|
|
dt_date = np_incr_dt.astype(datetime)
|
|
result = datetime.combine(dt_date, date_in.time())
|
|
|
|
if self.offset:
|
|
result = result + self.offset
|
|
return result
|
|
|
|
elif is_any_td_scalar(other):
|
|
td = Timedelta(self.offset) + other
|
|
return BDay(self.n, offset=td.to_pytimedelta(), normalize=self.normalize)
|
|
else:
|
|
raise ApplyTypeError(
|
|
"Only know how to combine trading day with "
|
|
"datetime, datetime64 or timedelta."
|
|
)
|
|
|
|
def is_on_offset(self, dt: datetime) -> bool:
|
|
if self.normalize and not _is_normalized(dt):
|
|
return False
|
|
day64 = _to_dt64D(dt)
|
|
return np.is_busday(day64, busdaycal=self.calendar)
|
|
|
|
|
|
cdef class CustomBusinessHour(BusinessHour):
|
|
"""
|
|
DateOffset subclass representing possibly n custom business days.
|
|
|
|
In CustomBusinessHour we can use custom weekmask, holidays, and calendar.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of hours represented.
|
|
normalize : bool, default False
|
|
Normalize start/end dates to midnight before generating date range.
|
|
weekmask : str, Default 'Mon Tue Wed Thu Fri'
|
|
Weekmask of valid business days, passed to ``numpy.busdaycalendar``.
|
|
holidays : list
|
|
List/array of dates to exclude from the set of valid business days,
|
|
passed to ``numpy.busdaycalendar``.
|
|
calendar : np.busdaycalendar
|
|
Calendar to integrate.
|
|
start : str, time, or list of str/time, default "09:00"
|
|
Start time of your custom business hour in 24h format.
|
|
end : str, time, or list of str/time, default: "17:00"
|
|
End time of your custom business hour in 24h format.
|
|
|
|
Examples
|
|
--------
|
|
In the example below the default parameters give the next business hour.
|
|
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 16)
|
|
>>> ts + pd.offsets.CustomBusinessHour()
|
|
Timestamp('2022-08-08 09:00:00')
|
|
|
|
We can also change the start and the end of business hours.
|
|
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 16)
|
|
>>> ts + pd.offsets.CustomBusinessHour(start="11:00")
|
|
Timestamp('2022-08-08 11:00:00')
|
|
|
|
>>> from datetime import time as dt_time
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 16)
|
|
>>> ts + pd.offsets.CustomBusinessHour(end=dt_time(19, 0))
|
|
Timestamp('2022-08-05 17:00:00')
|
|
|
|
>>> ts = pd.Timestamp(2022, 8, 5, 22)
|
|
>>> ts + pd.offsets.CustomBusinessHour(end=dt_time(19, 0))
|
|
Timestamp('2022-08-08 10:00:00')
|
|
|
|
You can divide your business day hours into several parts.
|
|
|
|
>>> import datetime as dt
|
|
>>> freq = pd.offsets.CustomBusinessHour(start=["06:00", "10:00", "15:00"],
|
|
... end=["08:00", "12:00", "17:00"])
|
|
>>> pd.date_range(dt.datetime(2022, 12, 9), dt.datetime(2022, 12, 13), freq=freq)
|
|
DatetimeIndex(['2022-12-09 06:00:00', '2022-12-09 07:00:00',
|
|
'2022-12-09 10:00:00', '2022-12-09 11:00:00',
|
|
'2022-12-09 15:00:00', '2022-12-09 16:00:00',
|
|
'2022-12-12 06:00:00', '2022-12-12 07:00:00',
|
|
'2022-12-12 10:00:00', '2022-12-12 11:00:00',
|
|
'2022-12-12 15:00:00', '2022-12-12 16:00:00'],
|
|
dtype='datetime64[ns]', freq='CBH')
|
|
|
|
Business days can be specified by ``weekmask`` parameter. To convert
|
|
the returned datetime object to its string representation
|
|
the function strftime() is used in the next example.
|
|
|
|
>>> import datetime as dt
|
|
>>> freq = pd.offsets.CustomBusinessHour(weekmask="Mon Wed Fri",
|
|
... start="10:00", end="13:00")
|
|
>>> pd.date_range(dt.datetime(2022, 12, 10), dt.datetime(2022, 12, 18),
|
|
... freq=freq).strftime('%a %d %b %Y %H:%M')
|
|
Index(['Mon 12 Dec 2022 10:00', 'Mon 12 Dec 2022 11:00',
|
|
'Mon 12 Dec 2022 12:00', 'Wed 14 Dec 2022 10:00',
|
|
'Wed 14 Dec 2022 11:00', 'Wed 14 Dec 2022 12:00',
|
|
'Fri 16 Dec 2022 10:00', 'Fri 16 Dec 2022 11:00',
|
|
'Fri 16 Dec 2022 12:00'],
|
|
dtype='object')
|
|
|
|
Using NumPy business day calendar you can define custom holidays.
|
|
|
|
>>> import datetime as dt
|
|
>>> bdc = np.busdaycalendar(holidays=['2022-12-12', '2022-12-14'])
|
|
>>> freq = pd.offsets.CustomBusinessHour(calendar=bdc, start="10:00", end="13:00")
|
|
>>> pd.date_range(dt.datetime(2022, 12, 10), dt.datetime(2022, 12, 18), freq=freq)
|
|
DatetimeIndex(['2022-12-13 10:00:00', '2022-12-13 11:00:00',
|
|
'2022-12-13 12:00:00', '2022-12-15 10:00:00',
|
|
'2022-12-15 11:00:00', '2022-12-15 12:00:00',
|
|
'2022-12-16 10:00:00', '2022-12-16 11:00:00',
|
|
'2022-12-16 12:00:00'],
|
|
dtype='datetime64[ns]', freq='CBH')
|
|
"""
|
|
|
|
_prefix = "CBH"
|
|
_anchor = 0
|
|
_attributes = tuple(
|
|
["n", "normalize", "weekmask", "holidays", "calendar", "start", "end", "offset"]
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
n=1,
|
|
normalize=False,
|
|
weekmask="Mon Tue Wed Thu Fri",
|
|
holidays=None,
|
|
calendar=None,
|
|
start="09:00",
|
|
end="17:00",
|
|
offset=timedelta(0),
|
|
):
|
|
BusinessHour.__init__(self, n, normalize, start=start, end=end, offset=offset)
|
|
self._init_custom(weekmask, holidays, calendar)
|
|
|
|
|
|
cdef class _CustomBusinessMonth(BusinessMixin):
|
|
"""
|
|
DateOffset subclass representing custom business month(s).
|
|
|
|
Increments between beginning/end of month dates.
|
|
|
|
Parameters
|
|
----------
|
|
n : int, default 1
|
|
The number of months represented.
|
|
normalize : bool, default False
|
|
Normalize start/end dates to midnight before generating date range.
|
|
weekmask : str, Default 'Mon Tue Wed Thu Fri'
|
|
Weekmask of valid business days, passed to ``numpy.busdaycalendar``.
|
|
holidays : list
|
|
List/array of dates to exclude from the set of valid business days,
|
|
passed to ``numpy.busdaycalendar``.
|
|
calendar : np.busdaycalendar
|
|
Calendar to integrate.
|
|
offset : timedelta, default timedelta(0)
|
|
Time offset to apply.
|
|
"""
|
|
|
|
_attributes = tuple(
|
|
["n", "normalize", "weekmask", "holidays", "calendar", "offset"]
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
n=1,
|
|
normalize=False,
|
|
weekmask="Mon Tue Wed Thu Fri",
|
|
holidays=None,
|
|
calendar=None,
|
|
offset=timedelta(0),
|
|
):
|
|
BusinessMixin.__init__(self, n, normalize, offset)
|
|
self._init_custom(weekmask, holidays, calendar)
|
|
|
|
@cache_readonly
|
|
def cbday_roll(self):
|
|
"""
|
|
Define default roll function to be called in apply method.
|
|
"""
|
|
cbday_kwds = self.kwds.copy()
|
|
cbday_kwds["offset"] = timedelta(0)
|
|
|
|
cbday = CustomBusinessDay(n=1, normalize=False, **cbday_kwds)
|
|
|
|
if self._prefix.endswith("S"):
|
|
# MonthBegin
|
|
roll_func = cbday.rollforward
|
|
else:
|
|
# MonthEnd
|
|
roll_func = cbday.rollback
|
|
return roll_func
|
|
|
|
@cache_readonly
|
|
def m_offset(self):
|
|
if self._prefix.endswith("S"):
|
|
# MonthBegin
|
|
moff = MonthBegin(n=1, normalize=False)
|
|
else:
|
|
# MonthEnd
|
|
moff = MonthEnd(n=1, normalize=False)
|
|
return moff
|
|
|
|
@cache_readonly
|
|
def month_roll(self):
|
|
"""
|
|
Define default roll function to be called in apply method.
|
|
"""
|
|
if self._prefix.endswith("S"):
|
|
# MonthBegin
|
|
roll_func = self.m_offset.rollback
|
|
else:
|
|
# MonthEnd
|
|
roll_func = self.m_offset.rollforward
|
|
return roll_func
|
|
|
|
@apply_wraps
|
|
def _apply(self, other: datetime) -> datetime:
|
|
# First move to month offset
|
|
cur_month_offset_date = self.month_roll(other)
|
|
|
|
# Find this custom month offset
|
|
compare_date = self.cbday_roll(cur_month_offset_date)
|
|
n = roll_convention(other.day, self.n, compare_date.day)
|
|
|
|
new = cur_month_offset_date + n * self.m_offset
|
|
result = self.cbday_roll(new)
|
|
|
|
if self.offset:
|
|
result = result + self.offset
|
|
return result
|
|
|
|
|
|
cdef class CustomBusinessMonthEnd(_CustomBusinessMonth):
|
|
_prefix = "CBM"
|
|
|
|
|
|
cdef class CustomBusinessMonthBegin(_CustomBusinessMonth):
|
|
_prefix = "CBMS"
|
|
|
|
|
|
BDay = BusinessDay
|
|
BMonthEnd = BusinessMonthEnd
|
|
BMonthBegin = BusinessMonthBegin
|
|
CBMonthEnd = CustomBusinessMonthEnd
|
|
CBMonthBegin = CustomBusinessMonthBegin
|
|
CDay = CustomBusinessDay
|
|
|
|
# ----------------------------------------------------------------------
|
|
# to_offset helpers
|
|
|
|
prefix_mapping = {
|
|
offset._prefix: offset
|
|
for offset in [
|
|
YearBegin, # 'AS'
|
|
YearEnd, # 'A'
|
|
BYearBegin, # 'BAS'
|
|
BYearEnd, # 'BA'
|
|
BusinessDay, # 'B'
|
|
BusinessMonthBegin, # 'BMS'
|
|
BusinessMonthEnd, # 'BM'
|
|
BQuarterEnd, # 'BQ'
|
|
BQuarterBegin, # 'BQS'
|
|
BusinessHour, # 'BH'
|
|
CustomBusinessDay, # 'C'
|
|
CustomBusinessMonthEnd, # 'CBM'
|
|
CustomBusinessMonthBegin, # 'CBMS'
|
|
CustomBusinessHour, # 'CBH'
|
|
MonthEnd, # 'M'
|
|
MonthBegin, # 'MS'
|
|
Nano, # 'N'
|
|
SemiMonthEnd, # 'SM'
|
|
SemiMonthBegin, # 'SMS'
|
|
Week, # 'W'
|
|
Second, # 'S'
|
|
Minute, # 'T'
|
|
Micro, # 'U'
|
|
QuarterEnd, # 'Q'
|
|
QuarterBegin, # 'QS'
|
|
Milli, # 'L'
|
|
Hour, # 'H'
|
|
Day, # 'D'
|
|
WeekOfMonth, # 'WOM'
|
|
FY5253,
|
|
FY5253Quarter,
|
|
]
|
|
}
|
|
|
|
# hack to handle WOM-1MON
|
|
opattern = re.compile(
|
|
r"([+\-]?\d*|[+\-]?\d*\.\d*)\s*([A-Za-z]+([\-][\dA-Za-z\-]+)?)"
|
|
)
|
|
|
|
_lite_rule_alias = {
|
|
"W": "W-SUN",
|
|
"Q": "Q-DEC",
|
|
|
|
"A": "A-DEC", # YearEnd(month=12),
|
|
"Y": "A-DEC",
|
|
"AS": "AS-JAN", # YearBegin(month=1),
|
|
"YS": "AS-JAN",
|
|
"BA": "BA-DEC", # BYearEnd(month=12),
|
|
"BY": "BA-DEC",
|
|
"BAS": "BAS-JAN", # BYearBegin(month=1),
|
|
"BYS": "BAS-JAN",
|
|
|
|
"Min": "T",
|
|
"min": "T",
|
|
"ms": "L",
|
|
"us": "U",
|
|
"ns": "N",
|
|
}
|
|
|
|
_dont_uppercase = {"MS", "ms"}
|
|
|
|
INVALID_FREQ_ERR_MSG = "Invalid frequency: {0}"
|
|
|
|
# TODO: still needed?
|
|
# cache of previously seen offsets
|
|
_offset_map = {}
|
|
|
|
|
|
# TODO: better name?
|
|
def _get_offset(name: str) -> BaseOffset:
|
|
"""
|
|
Return DateOffset object associated with rule name.
|
|
|
|
Examples
|
|
--------
|
|
_get_offset('EOM') --> BMonthEnd(1)
|
|
"""
|
|
if name not in _dont_uppercase:
|
|
name = name.upper()
|
|
name = _lite_rule_alias.get(name, name)
|
|
name = _lite_rule_alias.get(name.lower(), name)
|
|
else:
|
|
name = _lite_rule_alias.get(name, name)
|
|
|
|
if name not in _offset_map:
|
|
try:
|
|
split = name.split("-")
|
|
klass = prefix_mapping[split[0]]
|
|
# handles case where there's no suffix (and will TypeError if too
|
|
# many '-')
|
|
offset = klass._from_name(*split[1:])
|
|
except (ValueError, TypeError, KeyError) as err:
|
|
# bad prefix or suffix
|
|
raise ValueError(INVALID_FREQ_ERR_MSG.format(name)) from err
|
|
# cache
|
|
_offset_map[name] = offset
|
|
|
|
return _offset_map[name]
|
|
|
|
|
|
cpdef to_offset(freq):
|
|
"""
|
|
Return DateOffset object from string or datetime.timedelta object.
|
|
|
|
Parameters
|
|
----------
|
|
freq : str, datetime.timedelta, BaseOffset or None
|
|
|
|
Returns
|
|
-------
|
|
DateOffset or None
|
|
|
|
Raises
|
|
------
|
|
ValueError
|
|
If freq is an invalid frequency
|
|
|
|
See Also
|
|
--------
|
|
BaseOffset : Standard kind of date increment used for a date range.
|
|
|
|
Examples
|
|
--------
|
|
>>> to_offset("5min")
|
|
<5 * Minutes>
|
|
|
|
>>> to_offset("1D1H")
|
|
<25 * Hours>
|
|
|
|
>>> to_offset("2W")
|
|
<2 * Weeks: weekday=6>
|
|
|
|
>>> to_offset("2B")
|
|
<2 * BusinessDays>
|
|
|
|
>>> to_offset(pd.Timedelta(days=1))
|
|
<Day>
|
|
|
|
>>> to_offset(Hour())
|
|
<Hour>
|
|
"""
|
|
if freq is None:
|
|
return None
|
|
|
|
if isinstance(freq, BaseOffset):
|
|
return freq
|
|
|
|
if isinstance(freq, tuple):
|
|
raise TypeError(
|
|
f"to_offset does not support tuples {freq}, pass as a string instead"
|
|
)
|
|
|
|
elif PyDelta_Check(freq):
|
|
return delta_to_tick(freq)
|
|
|
|
elif isinstance(freq, str):
|
|
delta = None
|
|
stride_sign = None
|
|
|
|
try:
|
|
split = opattern.split(freq)
|
|
if split[-1] != "" and not split[-1].isspace():
|
|
# the last element must be blank
|
|
raise ValueError("last element must be blank")
|
|
|
|
tups = zip(split[0::4], split[1::4], split[2::4])
|
|
for n, (sep, stride, name) in enumerate(tups):
|
|
if sep != "" and not sep.isspace():
|
|
raise ValueError("separator must be spaces")
|
|
prefix = _lite_rule_alias.get(name) or name
|
|
if stride_sign is None:
|
|
stride_sign = -1 if stride.startswith("-") else 1
|
|
if not stride:
|
|
stride = 1
|
|
|
|
if prefix in {"D", "H", "T", "S", "L", "U", "N"}:
|
|
# For these prefixes, we have something like "3H" or
|
|
# "2.5T", so we can construct a Timedelta with the
|
|
# matching unit and get our offset from delta_to_tick
|
|
td = Timedelta(1, unit=prefix)
|
|
off = delta_to_tick(td)
|
|
offset = off * float(stride)
|
|
if n != 0:
|
|
# If n==0, then stride_sign is already incorporated
|
|
# into the offset
|
|
offset *= stride_sign
|
|
else:
|
|
stride = int(stride)
|
|
offset = _get_offset(name)
|
|
offset = offset * int(np.fabs(stride) * stride_sign)
|
|
|
|
if delta is None:
|
|
delta = offset
|
|
else:
|
|
delta = delta + offset
|
|
except (ValueError, TypeError) as err:
|
|
raise ValueError(INVALID_FREQ_ERR_MSG.format(freq)) from err
|
|
else:
|
|
delta = None
|
|
|
|
if delta is None:
|
|
raise ValueError(INVALID_FREQ_ERR_MSG.format(freq))
|
|
|
|
return delta
|
|
|
|
|
|
# ----------------------------------------------------------------------
|
|
# RelativeDelta Arithmetic
|
|
|
|
cdef datetime _shift_day(datetime other, int days):
|
|
"""
|
|
Increment the datetime `other` by the given number of days, retaining
|
|
the time-portion of the datetime. For tz-naive datetimes this is
|
|
equivalent to adding a timedelta. For tz-aware datetimes it is similar to
|
|
dateutil's relativedelta.__add__, but handles pytz tzinfo objects.
|
|
|
|
Parameters
|
|
----------
|
|
other : datetime or Timestamp
|
|
days : int
|
|
|
|
Returns
|
|
-------
|
|
shifted: datetime or Timestamp
|
|
"""
|
|
if other.tzinfo is None:
|
|
return other + timedelta(days=days)
|
|
|
|
tz = other.tzinfo
|
|
naive = other.replace(tzinfo=None)
|
|
shifted = naive + timedelta(days=days)
|
|
return localize_pydatetime(shifted, tz)
|
|
|
|
|
|
cdef int year_add_months(npy_datetimestruct dts, int months) nogil:
|
|
"""
|
|
New year number after shifting npy_datetimestruct number of months.
|
|
"""
|
|
return dts.year + (dts.month + months - 1) // 12
|
|
|
|
|
|
cdef int month_add_months(npy_datetimestruct dts, int months) nogil:
|
|
"""
|
|
New month number after shifting npy_datetimestruct
|
|
number of months.
|
|
"""
|
|
cdef:
|
|
int new_month = (dts.month + months) % 12
|
|
return 12 if new_month == 0 else new_month
|
|
|
|
|
|
@cython.wraparound(False)
|
|
@cython.boundscheck(False)
|
|
cdef ndarray shift_quarters(
|
|
ndarray dtindex,
|
|
int quarters,
|
|
int q1start_month,
|
|
str day_opt,
|
|
int modby=3,
|
|
NPY_DATETIMEUNIT reso=NPY_DATETIMEUNIT.NPY_FR_ns,
|
|
):
|
|
"""
|
|
Given an int64 array representing nanosecond timestamps, shift all elements
|
|
by the specified number of quarters using DateOffset semantics.
|
|
|
|
Parameters
|
|
----------
|
|
dtindex : int64_t[:] timestamps for input dates
|
|
quarters : int number of quarters to shift
|
|
q1start_month : int month in which Q1 begins by convention
|
|
day_opt : {'start', 'end', 'business_start', 'business_end'}
|
|
modby : int (3 for quarters, 12 for years)
|
|
reso : NPY_DATETIMEUNIT, default NPY_FR_ns
|
|
|
|
Returns
|
|
-------
|
|
out : ndarray[int64_t]
|
|
"""
|
|
if day_opt not in ["start", "end", "business_start", "business_end"]:
|
|
raise ValueError("day must be None, 'start', 'end', "
|
|
"'business_start', or 'business_end'")
|
|
|
|
cdef:
|
|
Py_ssize_t count = dtindex.size
|
|
ndarray out = cnp.PyArray_EMPTY(dtindex.ndim, dtindex.shape, cnp.NPY_INT64, 0)
|
|
Py_ssize_t i
|
|
int64_t val, res_val
|
|
int months_since, n
|
|
npy_datetimestruct dts
|
|
cnp.broadcast mi = cnp.PyArray_MultiIterNew2(out, dtindex)
|
|
|
|
with nogil:
|
|
for i in range(count):
|
|
# Analogous to: val = dtindex[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
else:
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
n = quarters
|
|
|
|
months_since = (dts.month - q1start_month) % modby
|
|
n = _roll_qtrday(&dts, n, months_since, day_opt)
|
|
|
|
dts.year = year_add_months(dts, modby * n - months_since)
|
|
dts.month = month_add_months(dts, modby * n - months_since)
|
|
dts.day = get_day_of_month(&dts, day_opt)
|
|
|
|
res_val = npy_datetimestruct_to_datetime(reso, &dts)
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
return out
|
|
|
|
|
|
@cython.wraparound(False)
|
|
@cython.boundscheck(False)
|
|
def shift_months(
|
|
ndarray dtindex, # int64_t, arbitrary ndim
|
|
int months,
|
|
str day_opt=None,
|
|
NPY_DATETIMEUNIT reso=NPY_DATETIMEUNIT.NPY_FR_ns,
|
|
):
|
|
"""
|
|
Given an int64-based datetime index, shift all elements
|
|
specified number of months using DateOffset semantics
|
|
|
|
day_opt: {None, 'start', 'end', 'business_start', 'business_end'}
|
|
* None: day of month
|
|
* 'start' 1st day of month
|
|
* 'end' last day of month
|
|
"""
|
|
cdef:
|
|
Py_ssize_t i
|
|
npy_datetimestruct dts
|
|
int count = dtindex.size
|
|
ndarray out = cnp.PyArray_EMPTY(dtindex.ndim, dtindex.shape, cnp.NPY_INT64, 0)
|
|
int months_to_roll
|
|
int64_t val, res_val
|
|
|
|
cnp.broadcast mi = cnp.PyArray_MultiIterNew2(out, dtindex)
|
|
|
|
if day_opt is not None and day_opt not in {
|
|
"start", "end", "business_start", "business_end"
|
|
}:
|
|
raise ValueError("day must be None, 'start', 'end', "
|
|
"'business_start', or 'business_end'")
|
|
|
|
if day_opt is None:
|
|
# TODO: can we combine this with the non-None case?
|
|
with nogil:
|
|
for i in range(count):
|
|
# Analogous to: val = i8other[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
else:
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
dts.year = year_add_months(dts, months)
|
|
dts.month = month_add_months(dts, months)
|
|
|
|
dts.day = min(dts.day, get_days_in_month(dts.year, dts.month))
|
|
res_val = npy_datetimestruct_to_datetime(reso, &dts)
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
else:
|
|
with nogil:
|
|
for i in range(count):
|
|
|
|
# Analogous to: val = i8other[i]
|
|
val = (<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 1))[0]
|
|
|
|
if val == NPY_NAT:
|
|
res_val = NPY_NAT
|
|
else:
|
|
pandas_datetime_to_datetimestruct(val, reso, &dts)
|
|
months_to_roll = months
|
|
|
|
months_to_roll = _roll_qtrday(&dts, months_to_roll, 0, day_opt)
|
|
|
|
dts.year = year_add_months(dts, months_to_roll)
|
|
dts.month = month_add_months(dts, months_to_roll)
|
|
dts.day = get_day_of_month(&dts, day_opt)
|
|
|
|
res_val = npy_datetimestruct_to_datetime(reso, &dts)
|
|
|
|
# Analogous to: out[i] = res_val
|
|
(<int64_t*>cnp.PyArray_MultiIter_DATA(mi, 0))[0] = res_val
|
|
|
|
cnp.PyArray_MultiIter_NEXT(mi)
|
|
|
|
return out
|
|
|
|
|
|
def shift_month(stamp: datetime, months: int, day_opt: object = None) -> datetime:
|
|
"""
|
|
Given a datetime (or Timestamp) `stamp`, an integer `months` and an
|
|
option `day_opt`, return a new datetimelike that many months later,
|
|
with day determined by `day_opt` using relativedelta semantics.
|
|
|
|
Scalar analogue of shift_months.
|
|
|
|
Parameters
|
|
----------
|
|
stamp : datetime or Timestamp
|
|
months : int
|
|
day_opt : None, 'start', 'end', 'business_start', 'business_end', or int
|
|
None: returned datetimelike has the same day as the input, or the
|
|
last day of the month if the new month is too short
|
|
'start': returned datetimelike has day=1
|
|
'end': returned datetimelike has day on the last day of the month
|
|
'business_start': returned datetimelike has day on the first
|
|
business day of the month
|
|
'business_end': returned datetimelike has day on the last
|
|
business day of the month
|
|
int: returned datetimelike has day equal to day_opt
|
|
|
|
Returns
|
|
-------
|
|
shifted : datetime or Timestamp (same as input `stamp`)
|
|
"""
|
|
cdef:
|
|
int year, month, day
|
|
int days_in_month, dy
|
|
|
|
dy = (stamp.month + months) // 12
|
|
month = (stamp.month + months) % 12
|
|
|
|
if month == 0:
|
|
month = 12
|
|
dy -= 1
|
|
year = stamp.year + dy
|
|
|
|
if day_opt is None:
|
|
days_in_month = get_days_in_month(year, month)
|
|
day = min(stamp.day, days_in_month)
|
|
elif day_opt == "start":
|
|
day = 1
|
|
elif day_opt == "end":
|
|
day = get_days_in_month(year, month)
|
|
elif day_opt == "business_start":
|
|
# first business day of month
|
|
day = get_firstbday(year, month)
|
|
elif day_opt == "business_end":
|
|
# last business day of month
|
|
day = get_lastbday(year, month)
|
|
elif is_integer_object(day_opt):
|
|
days_in_month = get_days_in_month(year, month)
|
|
day = min(day_opt, days_in_month)
|
|
else:
|
|
raise ValueError(day_opt)
|
|
return stamp.replace(year=year, month=month, day=day)
|
|
|
|
|
|
cdef int get_day_of_month(npy_datetimestruct* dts, str day_opt) nogil:
|
|
"""
|
|
Find the day in `other`'s month that satisfies a DateOffset's is_on_offset
|
|
policy, as described by the `day_opt` argument.
|
|
|
|
Parameters
|
|
----------
|
|
dts : npy_datetimestruct*
|
|
day_opt : {'start', 'end', 'business_start', 'business_end'}
|
|
'start': returns 1
|
|
'end': returns last day of the month
|
|
'business_start': returns the first business day of the month
|
|
'business_end': returns the last business day of the month
|
|
|
|
Returns
|
|
-------
|
|
day_of_month : int
|
|
|
|
Examples
|
|
-------
|
|
>>> other = datetime(2017, 11, 14)
|
|
>>> get_day_of_month(other, 'start')
|
|
1
|
|
>>> get_day_of_month(other, 'end')
|
|
30
|
|
|
|
Notes
|
|
-----
|
|
Caller is responsible for ensuring one of the four accepted day_opt values
|
|
is passed.
|
|
"""
|
|
|
|
if day_opt == "start":
|
|
return 1
|
|
elif day_opt == "end":
|
|
return get_days_in_month(dts.year, dts.month)
|
|
elif day_opt == "business_start":
|
|
# first business day of month
|
|
return get_firstbday(dts.year, dts.month)
|
|
else:
|
|
# i.e. day_opt == "business_end":
|
|
# last business day of month
|
|
return get_lastbday(dts.year, dts.month)
|
|
|
|
|
|
cpdef int roll_convention(int other, int n, int compare) nogil:
|
|
"""
|
|
Possibly increment or decrement the number of periods to shift
|
|
based on rollforward/rollbackward conventions.
|
|
|
|
Parameters
|
|
----------
|
|
other : int, generally the day component of a datetime
|
|
n : number of periods to increment, before adjusting for rolling
|
|
compare : int, generally the day component of a datetime, in the same
|
|
month as the datetime form which `other` was taken.
|
|
|
|
Returns
|
|
-------
|
|
n : int number of periods to increment
|
|
"""
|
|
if n > 0 and other < compare:
|
|
n -= 1
|
|
elif n <= 0 and other > compare:
|
|
# as if rolled forward already
|
|
n += 1
|
|
return n
|
|
|
|
|
|
def roll_qtrday(other: datetime, n: int, month: int,
|
|
day_opt: str, modby: int) -> int:
|
|
"""
|
|
Possibly increment or decrement the number of periods to shift
|
|
based on rollforward/rollbackward conventions.
|
|
|
|
Parameters
|
|
----------
|
|
other : datetime or Timestamp
|
|
n : number of periods to increment, before adjusting for rolling
|
|
month : int reference month giving the first month of the year
|
|
day_opt : {'start', 'end', 'business_start', 'business_end'}
|
|
The convention to use in finding the day in a given month against
|
|
which to compare for rollforward/rollbackward decisions.
|
|
modby : int 3 for quarters, 12 for years
|
|
|
|
Returns
|
|
-------
|
|
n : int number of periods to increment
|
|
|
|
See Also
|
|
--------
|
|
get_day_of_month : Find the day in a month provided an offset.
|
|
"""
|
|
cdef:
|
|
int months_since
|
|
npy_datetimestruct dts
|
|
|
|
if day_opt not in ["start", "end", "business_start", "business_end"]:
|
|
raise ValueError(day_opt)
|
|
|
|
pydate_to_dtstruct(other, &dts)
|
|
|
|
if modby == 12:
|
|
# We care about the month-of-year, not month-of-quarter, so skip mod
|
|
months_since = other.month - month
|
|
else:
|
|
months_since = other.month % modby - month % modby
|
|
|
|
return _roll_qtrday(&dts, n, months_since, day_opt)
|
|
|
|
|
|
cdef int _roll_qtrday(npy_datetimestruct* dts,
|
|
int n,
|
|
int months_since,
|
|
str day_opt) except? -1 nogil:
|
|
"""
|
|
See roll_qtrday.__doc__
|
|
"""
|
|
|
|
if n > 0:
|
|
if months_since < 0 or (months_since == 0 and
|
|
dts.day < get_day_of_month(dts, day_opt)):
|
|
# pretend to roll back if on same month but
|
|
# before compare_day
|
|
n -= 1
|
|
else:
|
|
if months_since > 0 or (months_since == 0 and
|
|
dts.day > get_day_of_month(dts, day_opt)):
|
|
# make sure to roll forward, so negate
|
|
n += 1
|
|
return n
|