diff --git a/doc/source/reference/offset_frequency.rst b/doc/source/reference/offset_frequency.rst index 20170459f24fe..94ac3eef66077 100644 --- a/doc/source/reference/offset_frequency.rst +++ b/doc/source/reference/offset_frequency.rst @@ -613,40 +613,6 @@ Methods LastWeekOfMonth.is_on_offset LastWeekOfMonth.__call__ -QuarterOffset -------------- -.. autosummary:: - :toctree: api/ - - QuarterOffset - -Properties -~~~~~~~~~~ -.. autosummary:: - :toctree: api/ - - QuarterOffset.freqstr - QuarterOffset.kwds - QuarterOffset.name - QuarterOffset.nanos - QuarterOffset.normalize - QuarterOffset.rule_code - QuarterOffset.n - -Methods -~~~~~~~ -.. autosummary:: - :toctree: api/ - - QuarterOffset.apply - QuarterOffset.apply_index - QuarterOffset.copy - QuarterOffset.isAnchored - QuarterOffset.onOffset - QuarterOffset.is_anchored - QuarterOffset.is_on_offset - QuarterOffset.__call__ - BQuarterEnd ----------- .. autosummary:: @@ -666,6 +632,7 @@ Properties BQuarterEnd.normalize BQuarterEnd.rule_code BQuarterEnd.n + BQuarterEnd.startingMonth Methods ~~~~~~~ @@ -700,6 +667,7 @@ Properties BQuarterBegin.normalize BQuarterBegin.rule_code BQuarterBegin.n + BQuarterBegin.startingMonth Methods ~~~~~~~ @@ -734,6 +702,7 @@ Properties QuarterEnd.normalize QuarterEnd.rule_code QuarterEnd.n + QuarterEnd.startingMonth Methods ~~~~~~~ @@ -768,6 +737,7 @@ Properties QuarterBegin.normalize QuarterBegin.rule_code QuarterBegin.n + QuarterBegin.startingMonth Methods ~~~~~~~ diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 6f044380d436e..4fd70843d2c42 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -1060,6 +1060,13 @@ class BusinessHourMixin(BusinessMixin): object.__setattr__(self, "start", start) object.__setattr__(self, "end", end) + @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 _repr_attrs(self) -> str: out = super()._repr_attrs() hours = ",".join( @@ -1223,6 +1230,75 @@ cdef class YearOffset(BaseOffset): return type(dtindex)._simple_new(shifted, dtype=dtindex.dtype) +cdef class QuarterOffset(BaseOffset): + _attributes = frozenset(["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: python annotations here breaks things + # _default_startingMonth: int + # _from_name_startingMonth: 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_startingMonth + self.startingMonth = startingMonth + + def __reduce__(self): + return type(self), (self.n, self.normalize, self.startingMonth) + + @classmethod + def _from_name(cls, suffix=None): + kwargs = {} + if suffix: + kwargs["startingMonth"] = MONTH_TO_CAL_NUM[suffix] + else: + if cls._from_name_startingMonth is not None: + kwargs["startingMonth"] = cls._from_name_startingMonth + 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) -> 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): + # 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_index_wraps + def apply_index(self, dtindex): + shifted = shift_quarters( + dtindex.asi8, self.n, self.startingMonth, self._day_opt + ) + return type(dtindex)._simple_new(shifted, dtype=dtindex.dtype) + + # ---------------------------------------------------------------------- # RelativeDelta Arithmetic @@ -1268,7 +1344,7 @@ cdef inline int month_add_months(npy_datetimestruct dts, int months) nogil: @cython.wraparound(False) @cython.boundscheck(False) -def shift_quarters( +cdef shift_quarters( const int64_t[:] dtindex, int quarters, int q1start_month, @@ -1612,7 +1688,7 @@ def shift_month(stamp: datetime, months: int, return stamp.replace(year=year, month=month, day=day) -cpdef int get_day_of_month(datetime other, day_opt) except? -1: +cdef int get_day_of_month(datetime other, day_opt) except? -1: """ Find the day in `other`'s month that satisfies a DateOffset's is_on_offset policy, as described by the `day_opt` argument. diff --git a/pandas/tests/tseries/offsets/test_offsets_properties.py b/pandas/tests/tseries/offsets/test_offsets_properties.py index 81465e733da85..e6c1ef01bb0ca 100644 --- a/pandas/tests/tseries/offsets/test_offsets_properties.py +++ b/pandas/tests/tseries/offsets/test_offsets_properties.py @@ -62,7 +62,7 @@ # enough runtime information (e.g. type hints) to infer how to build them. gen_yqm_offset = st.one_of( *map( - st.from_type, + st.from_type, # type: ignore [ MonthBegin, MonthEnd, diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index 8aced50b78ae2..c608d0edca64f 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -30,7 +30,6 @@ apply_wraps, as_datetime, is_normalized, - roll_yearday, shift_month, to_dt64D, ) @@ -311,13 +310,6 @@ def freqstr(self): class SingleConstructorMixin: - @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() - @cache_readonly def _params(self): # TODO: see if we can just write cache_readonly(BaseOffset._params.__get__) @@ -330,7 +322,12 @@ def freqstr(self): class SingleConstructorOffset(SingleConstructorMixin, BaseOffset): - pass + @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() class BusinessDay(BusinessMixin, SingleConstructorOffset): @@ -1447,69 +1444,13 @@ def _from_name(cls, suffix=None): # Quarter-Based Offset Classes -class QuarterOffset(SingleConstructorOffset): +class QuarterOffset(SingleConstructorMixin, liboffsets.QuarterOffset): """ - Quarter representation - doesn't call super. + Quarter representation. """ _default_startingMonth: Optional[int] = None _from_name_startingMonth: Optional[int] = None - _attributes = frozenset(["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 - - def __init__(self, n=1, normalize=False, startingMonth=None): - BaseOffset.__init__(self, n, normalize) - - if startingMonth is None: - startingMonth = self._default_startingMonth - object.__setattr__(self, "startingMonth", startingMonth) - - def is_anchored(self) -> bool: - return self.n == 1 and self.startingMonth is not None - - @classmethod - def _from_name(cls, suffix=None): - kwargs = {} - if suffix: - kwargs["startingMonth"] = ccalendar.MONTH_TO_CAL_NUM[suffix] - else: - if cls._from_name_startingMonth is not None: - kwargs["startingMonth"] = cls._from_name_startingMonth - return cls(**kwargs) - - @property - def rule_code(self) -> str: - month = ccalendar.MONTH_ALIASES[self.startingMonth] - return f"{self._prefix}-{month}" - - @apply_wraps - def apply(self, other): - # 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 = liboffsets.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) - - 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_index_wraps - def apply_index(self, dtindex): - shifted = liboffsets.shift_quarters( - dtindex.asi8, self.n, self.startingMonth, self._day_opt - ) - return type(dtindex)._simple_new(shifted, dtype=dtindex.dtype) class BQuarterEnd(QuarterOffset): @@ -1532,6 +1473,7 @@ class BQuarterEnd(QuarterOffset): class BQuarterBegin(QuarterOffset): _outputName = "BusinessQuarterBegin" # I suspect this is wrong for *all* of them. + # TODO: What does the above comment refer to? _default_startingMonth = 3 _from_name_startingMonth = 1 _prefix = "BQS"