diff --git a/doc/source/whatsnew/v3.0.0.rst b/doc/source/whatsnew/v3.0.0.rst index 7ebcf18a36a96..9f37bb70d12ac 100644 --- a/doc/source/whatsnew/v3.0.0.rst +++ b/doc/source/whatsnew/v3.0.0.rst @@ -72,6 +72,7 @@ Other enhancements - :meth:`pandas.concat` will raise a ``ValueError`` when ``ignore_index=True`` and ``keys`` is not ``None`` (:issue:`59274`) - :py:class:`frozenset` elements in pandas objects are now natively printed (:issue:`60690`) - Add ``"delete_rows"`` option to ``if_exists`` argument in :meth:`DataFrame.to_sql` deleting all records of the table before inserting data (:issue:`37210`). +- Added half-year offset classes :class:`HalfYearBegin`, :class:`HalfYearEnd`, :class:`BHalfYearBegin` and :class:`BHalfYearEnd` (:issue:`60928`) - Errors occurring during SQL I/O will now throw a generic :class:`.DatabaseError` instead of the raw Exception type from the underlying driver manager library (:issue:`60748`) - Implemented :meth:`Series.str.isascii` and :meth:`Series.str.isascii` (:issue:`59091`) - Multiplying two :class:`DateOffset` objects will now raise a ``TypeError`` instead of a ``RecursionError`` (:issue:`59442`) diff --git a/pandas/_libs/tslibs/offsets.pyi b/pandas/_libs/tslibs/offsets.pyi index 3f942d6aa3622..f9f56d38c5e0a 100644 --- a/pandas/_libs/tslibs/offsets.pyi +++ b/pandas/_libs/tslibs/offsets.pyi @@ -168,6 +168,16 @@ class BQuarterEnd(QuarterOffset): ... class BQuarterBegin(QuarterOffset): ... class QuarterEnd(QuarterOffset): ... class QuarterBegin(QuarterOffset): ... + +class HalfYearOffset(SingleConstructorOffset): + def __init__( + self, n: int = ..., normalize: bool = ..., startingMonth: int | None = ... + ) -> None: ... + +class BHalfYearEnd(HalfYearOffset): ... +class BHalfYearBegin(HalfYearOffset): ... +class HalfYearEnd(HalfYearOffset): ... +class HalfYearBegin(HalfYearOffset): ... class MonthOffset(SingleConstructorOffset): ... class MonthEnd(MonthOffset): ... class MonthBegin(MonthOffset): ... diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index 36b431974c121..ecd675dd89c32 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -32,6 +32,8 @@ cnp.import_array() # TODO: formalize having _libs.properties "above" tslibs in the dependency structure +from typing import ClassVar + from pandas._libs.properties import cache_readonly from pandas._libs.tslibs cimport util @@ -2524,8 +2526,7 @@ cdef class YearOffset(SingleConstructorOffset): """ _attributes = tuple(["n", "normalize", "month"]) - # FIXME(cython#4446): python annotation here gives compile-time errors - # _default_month: int + _default_month: ClassVar[int] cdef readonly: int month @@ -2788,9 +2789,8 @@ cdef class QuarterOffset(SingleConstructorOffset): # 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 + _default_starting_month: ClassVar[int] + _from_name_starting_month: ClassVar[int] cdef readonly: int startingMonth @@ -3011,6 +3011,227 @@ cdef class QuarterBegin(QuarterOffset): _day_opt = "start" +# ---------------------------------------------------------------------- +# HalfYear-Based Offset Classes + +cdef class HalfYearOffset(SingleConstructorOffset): + _attributes = tuple(["n", "normalize", "startingMonth"]) + # TODO: Consider combining HalfYearOffset, QuarterOffset and YearOffset + + _default_starting_month: ClassVar[int] + _from_name_starting_month: ClassVar[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_on_offset(self, dt: datetime) -> bool: + if self.normalize and not _is_normalized(dt): + return False + mod_month = (dt.month - self.startingMonth) % 6 + 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 half containing other.month, + # e.g. if other.month == 8, the calendar half is [Jul, Aug, Sep, ..., Dec]. + # Then find the month in that half 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 % 6 - self.startingMonth % 6 + hlvs = roll_qtrday( + other, self.n, self.startingMonth, day_opt=self._day_opt, modby=6 + ) + months = hlvs * 6 - months_since + return shift_month(other, months, self._day_opt) + + def _apply_array(self, dtarr: np.ndarray) -> np.ndarray: + reso = get_unit_from_dtype(dtarr.dtype) + shifted = shift_quarters( + dtarr.view("i8"), + self.n, + self.startingMonth, + self._day_opt, + modby=6, + reso=reso, + ) + return shifted + + +cdef class BHalfYearEnd(HalfYearOffset): + """ + DateOffset increments between the last business day of each half-year. + + startingMonth = 1 corresponds to dates like 1/31/2007, 7/31/2007, ... + startingMonth = 2 corresponds to dates like 2/28/2007, 8/31/2007, ... + startingMonth = 6 corresponds to dates like 6/30/2007, 12/31/2007, ... + + Attributes + ---------- + n : int, default 1 + The number of half-years represented. + normalize : bool, default False + Normalize start/end dates to midnight before generating date range. + startingMonth : int, default 6 + A specific integer for the month of the year from which we start half-years. + + See Also + -------- + :class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment. + + Examples + -------- + >>> from pandas.tseries.offsets import BHalfYearEnd + >>> ts = pd.Timestamp('2020-05-24 05:01:15') + >>> ts + BHalfYearEnd() + Timestamp('2020-06-30 05:01:15') + >>> ts + BHalfYearEnd(2) + Timestamp('2020-12-31 05:01:15') + >>> ts + BHalfYearEnd(1, startingMonth=2) + Timestamp('2020-08-31 05:01:15') + >>> ts + BHalfYearEnd(startingMonth=2) + Timestamp('2020-08-31 05:01:15') + """ + _output_name = "BusinessHalfYearEnd" + _default_starting_month = 6 + _from_name_starting_month = 12 + _prefix = "BHYE" + _day_opt = "business_end" + + +cdef class BHalfYearBegin(HalfYearOffset): + """ + DateOffset increments between the first business day of each half-year. + + startingMonth = 1 corresponds to dates like 1/01/2007, 7/01/2007, ... + startingMonth = 2 corresponds to dates like 2/01/2007, 8/01/2007, ... + startingMonth = 3 corresponds to dates like 3/01/2007, 9/01/2007, ... + + Attributes + ---------- + n : int, default 1 + The number of half-years represented. + normalize : bool, default False + Normalize start/end dates to midnight before generating date range. + startingMonth : int, default 1 + A specific integer for the month of the year from which we start half-years. + + See Also + -------- + :class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment. + + Examples + -------- + >>> from pandas.tseries.offsets import BHalfYearBegin + >>> ts = pd.Timestamp('2020-05-24 05:01:15') + >>> ts + BHalfYearBegin() + Timestamp('2020-07-01 05:01:15') + >>> ts + BHalfYearBegin(2) + Timestamp('2021-01-01 05:01:15') + >>> ts + BHalfYearBegin(startingMonth=2) + Timestamp('2020-08-03 05:01:15') + >>> ts + BHalfYearBegin(-1) + Timestamp('2020-01-01 05:01:15') + """ + _output_name = "BusinessHalfYearBegin" + _default_starting_month = 1 + _from_name_starting_month = 1 + _prefix = "BHYS" + _day_opt = "business_start" + + +cdef class HalfYearEnd(HalfYearOffset): + """ + DateOffset increments between half-year end dates. + + startingMonth = 1 corresponds to dates like 1/31/2007, 7/31/2007, ... + startingMonth = 2 corresponds to dates like 2/28/2007, 8/31/2007, ... + startingMonth = 6 corresponds to dates like 6/30/2007, 12/31/2007, ... + + Attributes + ---------- + n : int, default 1 + The number of half-years represented. + normalize : bool, default False + Normalize start/end dates to midnight before generating date range. + startingMonth : int, default 6 + A specific integer for the month of the year from which we start half-years. + + See Also + -------- + :class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment. + + Examples + -------- + >>> ts = pd.Timestamp(2022, 1, 1) + >>> ts + pd.offsets.HalfYearEnd() + Timestamp('2022-06-30 00:00:00') + """ + _default_starting_month = 6 + _from_name_starting_month = 12 + _prefix = "HYE" + _day_opt = "end" + + +cdef class HalfYearBegin(HalfYearOffset): + """ + DateOffset increments between half-year start dates. + + startingMonth = 1 corresponds to dates like 1/01/2007, 7/01/2007, ... + startingMonth = 2 corresponds to dates like 2/01/2007, 8/01/2007, ... + startingMonth = 3 corresponds to dates like 3/01/2007, 9/01/2007, ... + + Attributes + ---------- + n : int, default 1 + The number of half-years represented. + normalize : bool, default False + Normalize start/end dates to midnight before generating date range. + startingMonth : int, default 1 + A specific integer for the month of the year from which we start half-years. + + See Also + -------- + :class:`~pandas.tseries.offsets.DateOffset` : Standard kind of date increment. + + Examples + -------- + >>> ts = pd.Timestamp(2022, 2, 1) + >>> ts + pd.offsets.HalfYearBegin() + Timestamp('2022-07-01 00:00:00') + """ + _default_starting_month = 1 + _from_name_starting_month = 1 + _prefix = "HYS" + _day_opt = "start" + + # ---------------------------------------------------------------------- # Month-Based Offset Classes @@ -4823,6 +5044,8 @@ prefix_mapping = { BusinessMonthEnd, # 'BME' BQuarterEnd, # 'BQE' BQuarterBegin, # 'BQS' + BHalfYearEnd, # 'BHYE' + BHalfYearBegin, # 'BHYS' BusinessHour, # 'bh' CustomBusinessDay, # 'C' CustomBusinessMonthEnd, # 'CBME' @@ -4839,6 +5062,8 @@ prefix_mapping = { Micro, # 'us' QuarterEnd, # 'QE' QuarterBegin, # 'QS' + HalfYearEnd, # 'HYE' + HalfYearBegin, # 'HYS' Milli, # 'ms' Hour, # 'h' Day, # 'D' diff --git a/pandas/tests/tseries/offsets/test_business_halfyear.py b/pandas/tests/tseries/offsets/test_business_halfyear.py new file mode 100644 index 0000000000000..9ea336b3d13f8 --- /dev/null +++ b/pandas/tests/tseries/offsets/test_business_halfyear.py @@ -0,0 +1,329 @@ +""" +Tests for the following offsets: +- BHalfYearBegin +- BHalfYearEnd +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from pandas.tests.tseries.offsets.common import ( + assert_is_on_offset, + assert_offset_equal, +) + +from pandas.tseries.offsets import ( + BHalfYearBegin, + BHalfYearEnd, +) + + +@pytest.mark.parametrize("klass", (BHalfYearBegin, BHalfYearEnd)) +def test_halfyearly_dont_normalize(klass): + date = datetime(2012, 3, 31, 5, 30) + result = date + klass() + assert result.time() == date.time() + + +@pytest.mark.parametrize("offset", [BHalfYearBegin(), BHalfYearEnd()]) +@pytest.mark.parametrize( + "date", + [ + datetime(2016, m, d) + for m in [7, 8, 9, 10, 11, 12] + for d in [1, 2, 3, 28, 29, 30, 31] + if not (m in {9, 11} and d == 31) + ], +) +def test_on_offset(offset, date): + res = offset.is_on_offset(date) + slow_version = date == (date + offset) - offset + assert res == slow_version + + +class TestBHalfYearBegin: + def test_repr(self): + expected = "" + assert repr(BHalfYearBegin()) == expected + expected = "" + assert repr(BHalfYearBegin(startingMonth=3)) == expected + expected = "" + assert repr(BHalfYearBegin(startingMonth=1)) == expected + + def test_offset_corner_case(self): + # corner + offset = BHalfYearBegin(n=-1, startingMonth=1) + assert datetime(2010, 2, 1) + offset == datetime(2010, 1, 1) + + offset_cases = [] + offset_cases.append( + ( + BHalfYearBegin(startingMonth=1), + { + datetime(2007, 12, 1): datetime(2008, 1, 1), + datetime(2008, 1, 1): datetime(2008, 7, 1), + datetime(2008, 2, 15): datetime(2008, 7, 1), + datetime(2008, 2, 29): datetime(2008, 7, 1), + datetime(2008, 3, 15): datetime(2008, 7, 1), + datetime(2008, 3, 31): datetime(2008, 7, 1), + datetime(2008, 4, 15): datetime(2008, 7, 1), + datetime(2008, 4, 1): datetime(2008, 7, 1), + datetime(2008, 7, 1): datetime(2009, 1, 1), + datetime(2008, 7, 15): datetime(2009, 1, 1), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearBegin(startingMonth=2), + { + datetime(2008, 1, 1): datetime(2008, 2, 1), + datetime(2008, 1, 31): datetime(2008, 2, 1), + datetime(2008, 1, 15): datetime(2008, 2, 1), + datetime(2008, 2, 29): datetime(2008, 8, 1), + datetime(2008, 3, 15): datetime(2008, 8, 1), + datetime(2008, 3, 31): datetime(2008, 8, 1), + datetime(2008, 4, 15): datetime(2008, 8, 1), + datetime(2008, 4, 30): datetime(2008, 8, 1), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearBegin(startingMonth=1, n=0), + { + datetime(2008, 1, 1): datetime(2008, 1, 1), + datetime(2008, 12, 1): datetime(2009, 1, 1), + datetime(2008, 2, 15): datetime(2008, 7, 1), + datetime(2008, 2, 29): datetime(2008, 7, 1), + datetime(2008, 3, 15): datetime(2008, 7, 1), + datetime(2008, 3, 31): datetime(2008, 7, 1), + datetime(2008, 4, 15): datetime(2008, 7, 1), + datetime(2008, 4, 30): datetime(2008, 7, 1), + datetime(2008, 7, 1): datetime(2008, 7, 1), + datetime(2008, 7, 15): datetime(2009, 1, 1), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearBegin(startingMonth=1, n=-1), + { + datetime(2008, 1, 1): datetime(2007, 7, 2), + datetime(2008, 1, 31): datetime(2008, 1, 1), + datetime(2008, 2, 15): datetime(2008, 1, 1), + datetime(2008, 2, 29): datetime(2008, 1, 1), + datetime(2008, 3, 15): datetime(2008, 1, 1), + datetime(2008, 3, 31): datetime(2008, 1, 1), + datetime(2008, 4, 15): datetime(2008, 1, 1), + datetime(2008, 4, 30): datetime(2008, 1, 1), + datetime(2008, 7, 1): datetime(2008, 1, 1), + datetime(2008, 7, 15): datetime(2008, 7, 1), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearBegin(startingMonth=1, n=2), + { + datetime(2008, 1, 1): datetime(2009, 1, 1), + datetime(2008, 2, 15): datetime(2009, 1, 1), + datetime(2008, 2, 29): datetime(2009, 1, 1), + datetime(2008, 3, 15): datetime(2009, 1, 1), + datetime(2008, 3, 31): datetime(2009, 1, 1), + datetime(2008, 4, 15): datetime(2009, 1, 1), + datetime(2008, 4, 1): datetime(2009, 1, 1), + datetime(2008, 7, 15): datetime(2009, 7, 1), + datetime(2008, 7, 1): datetime(2009, 7, 1), + }, + ) + ) + + @pytest.mark.parametrize("case", offset_cases) + def test_offset(self, case): + offset, cases = case + for base, expected in cases.items(): + assert_offset_equal(offset, base, expected) + + on_offset_cases = [ + (BHalfYearBegin(1, startingMonth=1), datetime(2008, 1, 1), True), + (BHalfYearBegin(1, startingMonth=1), datetime(2007, 12, 1), False), + (BHalfYearBegin(1, startingMonth=1), datetime(2008, 2, 1), False), + (BHalfYearBegin(1, startingMonth=1), datetime(2007, 3, 1), False), + (BHalfYearBegin(1, startingMonth=1), datetime(2008, 4, 1), False), + (BHalfYearBegin(1, startingMonth=1), datetime(2008, 5, 1), False), + (BHalfYearBegin(1, startingMonth=1), datetime(2007, 6, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2008, 1, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2007, 12, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2008, 2, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2007, 3, 1), True), + (BHalfYearBegin(1, startingMonth=3), datetime(2008, 4, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2008, 5, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2008, 5, 2), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2007, 6, 1), False), + (BHalfYearBegin(1, startingMonth=3), datetime(2007, 6, 2), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2008, 1, 1), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2007, 12, 3), True), + (BHalfYearBegin(1, startingMonth=6), datetime(2008, 2, 1), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2007, 3, 1), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2007, 3, 2), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2008, 4, 1), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2008, 5, 1), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2008, 5, 2), False), + (BHalfYearBegin(1, startingMonth=6), datetime(2007, 6, 1), True), + ] + + @pytest.mark.parametrize("case", on_offset_cases) + def test_is_on_offset(self, case): + offset, dt, expected = case + assert_is_on_offset(offset, dt, expected) + + +class TestBHalfYearEnd: + def test_repr(self): + expected = "" + assert repr(BHalfYearEnd()) == expected + expected = "" + assert repr(BHalfYearEnd(startingMonth=3)) == expected + expected = "" + assert repr(BHalfYearEnd(startingMonth=1)) == expected + + def test_offset_corner_case(self): + # corner + offset = BHalfYearEnd(n=-1, startingMonth=1) + assert datetime(2010, 1, 30) + offset == datetime(2010, 1, 29) + + offset_cases = [] + offset_cases.append( + ( + BHalfYearEnd(startingMonth=1), + { + datetime(2008, 1, 1): datetime(2008, 1, 31), + datetime(2008, 1, 31): datetime(2008, 7, 31), + datetime(2008, 2, 15): datetime(2008, 7, 31), + datetime(2008, 2, 29): datetime(2008, 7, 31), + datetime(2008, 3, 15): datetime(2008, 7, 31), + datetime(2008, 3, 31): datetime(2008, 7, 31), + datetime(2008, 4, 15): datetime(2008, 7, 31), + datetime(2008, 7, 31): datetime(2009, 1, 30), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearEnd(startingMonth=2), + { + datetime(2008, 1, 1): datetime(2008, 2, 29), + datetime(2008, 1, 31): datetime(2008, 2, 29), + datetime(2008, 2, 15): datetime(2008, 2, 29), + datetime(2008, 2, 29): datetime(2008, 8, 29), + datetime(2008, 3, 15): datetime(2008, 8, 29), + datetime(2008, 3, 31): datetime(2008, 8, 29), + datetime(2008, 4, 15): datetime(2008, 8, 29), + datetime(2008, 8, 28): datetime(2008, 8, 29), + datetime(2008, 8, 29): datetime(2009, 2, 27), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearEnd(startingMonth=1, n=0), + { + datetime(2008, 1, 1): datetime(2008, 1, 31), + datetime(2008, 1, 31): datetime(2008, 1, 31), + datetime(2008, 2, 15): datetime(2008, 7, 31), + datetime(2008, 2, 29): datetime(2008, 7, 31), + datetime(2008, 3, 15): datetime(2008, 7, 31), + datetime(2008, 3, 31): datetime(2008, 7, 31), + datetime(2008, 4, 15): datetime(2008, 7, 31), + datetime(2008, 7, 31): datetime(2008, 7, 31), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearEnd(startingMonth=1, n=-1), + { + datetime(2008, 1, 1): datetime(2007, 7, 31), + datetime(2008, 1, 31): datetime(2007, 7, 31), + datetime(2008, 2, 15): datetime(2008, 1, 31), + datetime(2008, 2, 29): datetime(2008, 1, 31), + datetime(2008, 3, 15): datetime(2008, 1, 31), + datetime(2008, 3, 31): datetime(2008, 1, 31), + datetime(2008, 7, 15): datetime(2008, 1, 31), + datetime(2008, 7, 30): datetime(2008, 1, 31), + datetime(2008, 7, 31): datetime(2008, 1, 31), + datetime(2008, 8, 1): datetime(2008, 7, 31), + }, + ) + ) + + offset_cases.append( + ( + BHalfYearEnd(startingMonth=6, n=2), + { + datetime(2008, 1, 31): datetime(2008, 12, 31), + datetime(2008, 2, 15): datetime(2008, 12, 31), + datetime(2008, 2, 29): datetime(2008, 12, 31), + datetime(2008, 3, 15): datetime(2008, 12, 31), + datetime(2008, 3, 31): datetime(2008, 12, 31), + datetime(2008, 4, 15): datetime(2008, 12, 31), + datetime(2008, 4, 30): datetime(2008, 12, 31), + datetime(2008, 6, 30): datetime(2009, 6, 30), + }, + ) + ) + + @pytest.mark.parametrize("case", offset_cases) + def test_offset(self, case): + offset, cases = case + for base, expected in cases.items(): + assert_offset_equal(offset, base, expected) + + on_offset_cases = [ + (BHalfYearEnd(1, startingMonth=1), datetime(2008, 1, 31), True), + (BHalfYearEnd(1, startingMonth=1), datetime(2007, 12, 31), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2008, 2, 29), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2007, 3, 30), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2007, 3, 31), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2008, 4, 30), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2008, 5, 30), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2008, 5, 31), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2007, 6, 29), False), + (BHalfYearEnd(1, startingMonth=1), datetime(2007, 6, 30), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2008, 1, 31), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2007, 12, 31), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2008, 2, 29), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2007, 3, 30), True), + (BHalfYearEnd(1, startingMonth=3), datetime(2007, 3, 31), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2008, 4, 30), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2008, 5, 30), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2008, 5, 31), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2007, 6, 29), False), + (BHalfYearEnd(1, startingMonth=3), datetime(2007, 6, 30), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2008, 1, 31), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2007, 12, 31), True), + (BHalfYearEnd(1, startingMonth=6), datetime(2008, 2, 29), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2007, 3, 30), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2007, 3, 31), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2008, 4, 30), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2008, 5, 30), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2008, 5, 31), False), + (BHalfYearEnd(1, startingMonth=6), datetime(2007, 6, 29), True), + (BHalfYearEnd(1, startingMonth=6), datetime(2007, 6, 30), False), + ] + + @pytest.mark.parametrize("case", on_offset_cases) + def test_is_on_offset(self, case): + offset, dt, expected = case + assert_is_on_offset(offset, dt, expected) diff --git a/pandas/tests/tseries/offsets/test_halfyear.py b/pandas/tests/tseries/offsets/test_halfyear.py new file mode 100644 index 0000000000000..5bb3821fd07f8 --- /dev/null +++ b/pandas/tests/tseries/offsets/test_halfyear.py @@ -0,0 +1,329 @@ +""" +Tests for the following offsets: +- HalfYearBegin +- HalfYearEnd +""" + +from __future__ import annotations + +from datetime import datetime + +import pytest + +from pandas.tests.tseries.offsets.common import ( + assert_is_on_offset, + assert_offset_equal, +) + +from pandas.tseries.offsets import ( + HalfYearBegin, + HalfYearEnd, +) + + +@pytest.mark.parametrize("klass", (HalfYearBegin, HalfYearEnd)) +def test_halfyearly_dont_normalize(klass): + date = datetime(2012, 3, 31, 5, 30) + result = date + klass() + assert result.time() == date.time() + + +@pytest.mark.parametrize("offset", [HalfYearBegin(), HalfYearEnd()]) +@pytest.mark.parametrize( + "date", + [ + datetime(2016, m, d) + for m in [7, 8, 9, 10, 11, 12] + for d in [1, 2, 3, 28, 29, 30, 31] + if not (m in {9, 11} and d == 31) + ], +) +def test_on_offset(offset, date): + res = offset.is_on_offset(date) + slow_version = date == (date + offset) - offset + assert res == slow_version + + +class TestHalfYearBegin: + def test_repr(self): + expected = "" + assert repr(HalfYearBegin()) == expected + expected = "" + assert repr(HalfYearBegin(startingMonth=3)) == expected + expected = "" + assert repr(HalfYearBegin(startingMonth=1)) == expected + + def test_offset_corner_case(self): + # corner + offset = HalfYearBegin(n=-1, startingMonth=1) + assert datetime(2010, 2, 1) + offset == datetime(2010, 1, 1) + + offset_cases = [] + offset_cases.append( + ( + HalfYearBegin(startingMonth=1), + { + datetime(2007, 12, 1): datetime(2008, 1, 1), + datetime(2008, 1, 1): datetime(2008, 7, 1), + datetime(2008, 2, 15): datetime(2008, 7, 1), + datetime(2008, 2, 29): datetime(2008, 7, 1), + datetime(2008, 3, 15): datetime(2008, 7, 1), + datetime(2008, 3, 31): datetime(2008, 7, 1), + datetime(2008, 4, 15): datetime(2008, 7, 1), + datetime(2008, 4, 1): datetime(2008, 7, 1), + datetime(2008, 7, 1): datetime(2009, 1, 1), + datetime(2008, 7, 15): datetime(2009, 1, 1), + }, + ) + ) + + offset_cases.append( + ( + HalfYearBegin(startingMonth=2), + { + datetime(2008, 1, 1): datetime(2008, 2, 1), + datetime(2008, 1, 31): datetime(2008, 2, 1), + datetime(2008, 1, 15): datetime(2008, 2, 1), + datetime(2008, 2, 29): datetime(2008, 8, 1), + datetime(2008, 3, 15): datetime(2008, 8, 1), + datetime(2008, 3, 31): datetime(2008, 8, 1), + datetime(2008, 4, 15): datetime(2008, 8, 1), + datetime(2008, 4, 30): datetime(2008, 8, 1), + }, + ) + ) + + offset_cases.append( + ( + HalfYearBegin(startingMonth=1, n=0), + { + datetime(2008, 1, 1): datetime(2008, 1, 1), + datetime(2008, 12, 1): datetime(2009, 1, 1), + datetime(2008, 2, 15): datetime(2008, 7, 1), + datetime(2008, 2, 29): datetime(2008, 7, 1), + datetime(2008, 3, 15): datetime(2008, 7, 1), + datetime(2008, 3, 31): datetime(2008, 7, 1), + datetime(2008, 4, 15): datetime(2008, 7, 1), + datetime(2008, 4, 30): datetime(2008, 7, 1), + datetime(2008, 7, 1): datetime(2008, 7, 1), + datetime(2008, 7, 15): datetime(2009, 1, 1), + }, + ) + ) + + offset_cases.append( + ( + HalfYearBegin(startingMonth=1, n=-1), + { + datetime(2008, 1, 1): datetime(2007, 7, 1), + datetime(2008, 1, 31): datetime(2008, 1, 1), + datetime(2008, 2, 15): datetime(2008, 1, 1), + datetime(2008, 2, 29): datetime(2008, 1, 1), + datetime(2008, 3, 15): datetime(2008, 1, 1), + datetime(2008, 3, 31): datetime(2008, 1, 1), + datetime(2008, 4, 15): datetime(2008, 1, 1), + datetime(2008, 4, 30): datetime(2008, 1, 1), + datetime(2008, 7, 1): datetime(2008, 1, 1), + datetime(2008, 7, 15): datetime(2008, 7, 1), + }, + ) + ) + + offset_cases.append( + ( + HalfYearBegin(startingMonth=1, n=2), + { + datetime(2008, 1, 1): datetime(2009, 1, 1), + datetime(2008, 2, 15): datetime(2009, 1, 1), + datetime(2008, 2, 29): datetime(2009, 1, 1), + datetime(2008, 3, 15): datetime(2009, 1, 1), + datetime(2008, 3, 31): datetime(2009, 1, 1), + datetime(2008, 4, 15): datetime(2009, 1, 1), + datetime(2008, 4, 1): datetime(2009, 1, 1), + datetime(2008, 7, 15): datetime(2009, 7, 1), + datetime(2008, 7, 1): datetime(2009, 7, 1), + }, + ) + ) + + @pytest.mark.parametrize("case", offset_cases) + def test_offset(self, case): + offset, cases = case + for base, expected in cases.items(): + assert_offset_equal(offset, base, expected) + + on_offset_cases = [ + (HalfYearBegin(1, startingMonth=1), datetime(2008, 1, 1), True), + (HalfYearBegin(1, startingMonth=1), datetime(2007, 12, 1), False), + (HalfYearBegin(1, startingMonth=1), datetime(2008, 2, 1), False), + (HalfYearBegin(1, startingMonth=1), datetime(2007, 3, 1), False), + (HalfYearBegin(1, startingMonth=1), datetime(2008, 4, 1), False), + (HalfYearBegin(1, startingMonth=1), datetime(2008, 5, 1), False), + (HalfYearBegin(1, startingMonth=1), datetime(2007, 6, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2008, 1, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2007, 12, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2008, 2, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2007, 3, 1), True), + (HalfYearBegin(1, startingMonth=3), datetime(2008, 4, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2008, 5, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2008, 5, 2), False), + (HalfYearBegin(1, startingMonth=3), datetime(2007, 6, 1), False), + (HalfYearBegin(1, startingMonth=3), datetime(2007, 6, 2), False), + (HalfYearBegin(1, startingMonth=6), datetime(2008, 1, 1), False), + (HalfYearBegin(1, startingMonth=6), datetime(2007, 12, 1), True), + (HalfYearBegin(1, startingMonth=6), datetime(2008, 2, 1), False), + (HalfYearBegin(1, startingMonth=6), datetime(2007, 3, 1), False), + (HalfYearBegin(1, startingMonth=6), datetime(2007, 3, 2), False), + (HalfYearBegin(1, startingMonth=6), datetime(2008, 4, 1), False), + (HalfYearBegin(1, startingMonth=6), datetime(2008, 5, 1), False), + (HalfYearBegin(1, startingMonth=6), datetime(2008, 5, 2), False), + (HalfYearBegin(1, startingMonth=6), datetime(2007, 6, 1), True), + ] + + @pytest.mark.parametrize("case", on_offset_cases) + def test_is_on_offset(self, case): + offset, dt, expected = case + assert_is_on_offset(offset, dt, expected) + + +class TestHalfYearEnd: + def test_repr(self): + expected = "" + assert repr(HalfYearEnd()) == expected + expected = "" + assert repr(HalfYearEnd(startingMonth=3)) == expected + expected = "" + assert repr(HalfYearEnd(startingMonth=1)) == expected + + def test_offset_corner_case(self): + # corner + offset = HalfYearEnd(n=-1, startingMonth=1) + assert datetime(2010, 2, 1) + offset == datetime(2010, 1, 31) + + offset_cases = [] + offset_cases.append( + ( + HalfYearEnd(startingMonth=1), + { + datetime(2008, 1, 1): datetime(2008, 1, 31), + datetime(2008, 1, 31): datetime(2008, 7, 31), + datetime(2008, 2, 15): datetime(2008, 7, 31), + datetime(2008, 2, 29): datetime(2008, 7, 31), + datetime(2008, 3, 15): datetime(2008, 7, 31), + datetime(2008, 3, 31): datetime(2008, 7, 31), + datetime(2008, 4, 15): datetime(2008, 7, 31), + datetime(2008, 7, 31): datetime(2009, 1, 31), + }, + ) + ) + + offset_cases.append( + ( + HalfYearEnd(startingMonth=2), + { + datetime(2008, 1, 1): datetime(2008, 2, 29), + datetime(2008, 1, 31): datetime(2008, 2, 29), + datetime(2008, 2, 15): datetime(2008, 2, 29), + datetime(2008, 2, 29): datetime(2008, 8, 31), + datetime(2008, 3, 15): datetime(2008, 8, 31), + datetime(2008, 3, 31): datetime(2008, 8, 31), + datetime(2008, 4, 15): datetime(2008, 8, 31), + datetime(2008, 8, 30): datetime(2008, 8, 31), + datetime(2008, 8, 31): datetime(2009, 2, 28), + }, + ) + ) + + offset_cases.append( + ( + HalfYearEnd(startingMonth=1, n=0), + { + datetime(2008, 1, 1): datetime(2008, 1, 31), + datetime(2008, 1, 31): datetime(2008, 1, 31), + datetime(2008, 2, 15): datetime(2008, 7, 31), + datetime(2008, 2, 29): datetime(2008, 7, 31), + datetime(2008, 3, 15): datetime(2008, 7, 31), + datetime(2008, 3, 31): datetime(2008, 7, 31), + datetime(2008, 4, 15): datetime(2008, 7, 31), + datetime(2008, 7, 31): datetime(2008, 7, 31), + }, + ) + ) + + offset_cases.append( + ( + HalfYearEnd(startingMonth=1, n=-1), + { + datetime(2008, 1, 1): datetime(2007, 7, 31), + datetime(2008, 1, 31): datetime(2007, 7, 31), + datetime(2008, 2, 15): datetime(2008, 1, 31), + datetime(2008, 2, 29): datetime(2008, 1, 31), + datetime(2008, 3, 15): datetime(2008, 1, 31), + datetime(2008, 3, 31): datetime(2008, 1, 31), + datetime(2008, 7, 15): datetime(2008, 1, 31), + datetime(2008, 7, 30): datetime(2008, 1, 31), + datetime(2008, 7, 31): datetime(2008, 1, 31), + datetime(2008, 8, 1): datetime(2008, 7, 31), + }, + ) + ) + + offset_cases.append( + ( + HalfYearEnd(startingMonth=6, n=2), + { + datetime(2008, 1, 31): datetime(2008, 12, 31), + datetime(2008, 2, 15): datetime(2008, 12, 31), + datetime(2008, 2, 29): datetime(2008, 12, 31), + datetime(2008, 3, 15): datetime(2008, 12, 31), + datetime(2008, 3, 31): datetime(2008, 12, 31), + datetime(2008, 4, 15): datetime(2008, 12, 31), + datetime(2008, 4, 30): datetime(2008, 12, 31), + datetime(2008, 6, 30): datetime(2009, 6, 30), + }, + ) + ) + + @pytest.mark.parametrize("case", offset_cases) + def test_offset(self, case): + offset, cases = case + for base, expected in cases.items(): + assert_offset_equal(offset, base, expected) + + on_offset_cases = [ + (HalfYearEnd(1, startingMonth=1), datetime(2008, 1, 31), True), + (HalfYearEnd(1, startingMonth=1), datetime(2007, 12, 31), False), + (HalfYearEnd(1, startingMonth=1), datetime(2008, 2, 29), False), + (HalfYearEnd(1, startingMonth=1), datetime(2007, 3, 30), False), + (HalfYearEnd(1, startingMonth=1), datetime(2007, 3, 31), False), + (HalfYearEnd(1, startingMonth=1), datetime(2008, 4, 30), False), + (HalfYearEnd(1, startingMonth=1), datetime(2008, 5, 30), False), + (HalfYearEnd(1, startingMonth=1), datetime(2008, 5, 31), False), + (HalfYearEnd(1, startingMonth=1), datetime(2007, 6, 29), False), + (HalfYearEnd(1, startingMonth=1), datetime(2007, 6, 30), False), + (HalfYearEnd(1, startingMonth=3), datetime(2008, 1, 31), False), + (HalfYearEnd(1, startingMonth=3), datetime(2007, 12, 31), False), + (HalfYearEnd(1, startingMonth=3), datetime(2008, 2, 29), False), + (HalfYearEnd(1, startingMonth=3), datetime(2007, 3, 30), False), + (HalfYearEnd(1, startingMonth=3), datetime(2007, 3, 31), True), + (HalfYearEnd(1, startingMonth=3), datetime(2008, 4, 30), False), + (HalfYearEnd(1, startingMonth=3), datetime(2008, 5, 30), False), + (HalfYearEnd(1, startingMonth=3), datetime(2008, 5, 31), False), + (HalfYearEnd(1, startingMonth=3), datetime(2007, 6, 29), False), + (HalfYearEnd(1, startingMonth=3), datetime(2007, 6, 30), False), + (HalfYearEnd(1, startingMonth=6), datetime(2008, 1, 31), False), + (HalfYearEnd(1, startingMonth=6), datetime(2007, 12, 31), True), + (HalfYearEnd(1, startingMonth=6), datetime(2008, 2, 29), False), + (HalfYearEnd(1, startingMonth=6), datetime(2007, 3, 30), False), + (HalfYearEnd(1, startingMonth=6), datetime(2007, 3, 31), False), + (HalfYearEnd(1, startingMonth=6), datetime(2008, 4, 30), False), + (HalfYearEnd(1, startingMonth=6), datetime(2008, 5, 30), False), + (HalfYearEnd(1, startingMonth=6), datetime(2008, 5, 31), False), + (HalfYearEnd(1, startingMonth=6), datetime(2007, 6, 29), False), + (HalfYearEnd(1, startingMonth=6), datetime(2007, 6, 30), True), + ] + + @pytest.mark.parametrize("case", on_offset_cases) + def test_is_on_offset(self, case): + offset, dt, expected = case + assert_is_on_offset(offset, dt, expected) diff --git a/pandas/tests/tseries/offsets/test_offsets.py b/pandas/tests/tseries/offsets/test_offsets.py index 7480b99595066..f5c2c06162fcb 100644 --- a/pandas/tests/tseries/offsets/test_offsets.py +++ b/pandas/tests/tseries/offsets/test_offsets.py @@ -160,6 +160,10 @@ def expecteds(): "BQuarterBegin": Timestamp("2011-03-01 09:00:00"), "QuarterEnd": Timestamp("2011-03-31 09:00:00"), "BQuarterEnd": Timestamp("2011-03-31 09:00:00"), + "HalfYearBegin": Timestamp("2011-07-01 09:00:00"), + "HalfYearEnd": Timestamp("2011-06-30 09:00:00"), + "BHalfYearBegin": Timestamp("2011-01-03 09:00:00"), + "BHalfYearEnd": Timestamp("2011-06-30 09:00:00"), "BusinessHour": Timestamp("2011-01-03 10:00:00"), "CustomBusinessHour": Timestamp("2011-01-03 10:00:00"), "WeekOfMonth": Timestamp("2011-01-08 09:00:00"), @@ -325,6 +329,7 @@ def test_rollforward(self, offset_types, expecteds): "MonthBegin", "SemiMonthBegin", "YearBegin", + "HalfYearBegin", "Week", "Hour", "Minute", @@ -351,6 +356,7 @@ def test_rollforward(self, offset_types, expecteds): "MonthBegin": Timestamp("2011-02-01 00:00:00"), "SemiMonthBegin": Timestamp("2011-01-15 00:00:00"), "YearBegin": Timestamp("2012-01-01 00:00:00"), + "HalfYearBegin": Timestamp("2011-07-01 00:00:00"), "Week": Timestamp("2011-01-08 00:00:00"), "Hour": Timestamp("2011-01-01 00:00:00"), "Minute": Timestamp("2011-01-01 00:00:00"), @@ -388,6 +394,10 @@ def test_rollback(self, offset_types): "BQuarterBegin": Timestamp("2010-12-01 09:00:00"), "QuarterEnd": Timestamp("2010-12-31 09:00:00"), "BQuarterEnd": Timestamp("2010-12-31 09:00:00"), + "HalfYearBegin": Timestamp("2010-07-01 09:00:00"), + "HalfYearEnd": Timestamp("2010-12-31 09:00:00"), + "BHalfYearBegin": Timestamp("2010-07-01 09:00:00"), + "BHalfYearEnd": Timestamp("2010-12-31 09:00:00"), "BusinessHour": Timestamp("2010-12-31 17:00:00"), "CustomBusinessHour": Timestamp("2010-12-31 17:00:00"), "WeekOfMonth": Timestamp("2010-12-11 09:00:00"), @@ -403,6 +413,7 @@ def test_rollback(self, offset_types): "MonthBegin", "SemiMonthBegin", "YearBegin", + "HalfYearBegin", "Week", "Hour", "Minute", @@ -425,6 +436,7 @@ def test_rollback(self, offset_types): "MonthBegin": Timestamp("2010-12-01 00:00:00"), "SemiMonthBegin": Timestamp("2010-12-15 00:00:00"), "YearBegin": Timestamp("2010-01-01 00:00:00"), + "HalfYearBegin": Timestamp("2010-07-01 00:00:00"), "Week": Timestamp("2010-12-25 00:00:00"), "Hour": Timestamp("2011-01-01 00:00:00"), "Minute": Timestamp("2011-01-01 00:00:00"), @@ -849,7 +861,20 @@ def test_rule_code(self): "NOV", "DEC", ] - base_lst = ["YE", "YS", "BYE", "BYS", "QE", "QS", "BQE", "BQS"] + base_lst = [ + "YE", + "YS", + "BYE", + "BYS", + "QE", + "QS", + "BQE", + "BQS", + "HYE", + "HYS", + "BHYE", + "BHYS", + ] for base in base_lst: for v in suffix_lst: alias = "-".join([base, v]) @@ -868,7 +893,20 @@ def test_freq_offsets(): class TestReprNames: def test_str_for_named_is_name(self): # look at all the amazing combinations! - month_prefixes = ["YE", "YS", "BYE", "BYS", "QE", "BQE", "BQS", "QS"] + month_prefixes = [ + "YE", + "YS", + "BYE", + "BYS", + "QE", + "BQE", + "BQS", + "QS", + "HYE", + "HYS", + "BHYE", + "BHYS", + ] names = [ prefix + "-" + month for prefix in month_prefixes diff --git a/pandas/tseries/offsets.py b/pandas/tseries/offsets.py index a065137e6971c..1f0c4281ffc77 100644 --- a/pandas/tseries/offsets.py +++ b/pandas/tseries/offsets.py @@ -4,6 +4,8 @@ FY5253, BaseOffset, BDay, + BHalfYearBegin, + BHalfYearEnd, BMonthBegin, BMonthEnd, BQuarterBegin, @@ -25,6 +27,8 @@ Day, Easter, FY5253Quarter, + HalfYearBegin, + HalfYearEnd, Hour, LastWeekOfMonth, Micro, @@ -48,6 +52,8 @@ __all__ = [ "FY5253", "BDay", + "BHalfYearBegin", + "BHalfYearEnd", "BMonthBegin", "BMonthEnd", "BQuarterBegin", @@ -70,6 +76,8 @@ "Day", "Easter", "FY5253Quarter", + "HalfYearBegin", + "HalfYearEnd", "Hour", "LastWeekOfMonth", "Micro",