diff --git a/doc/source/whatsnew/v0.24.0.rst b/doc/source/whatsnew/v0.24.0.rst index 4e12b22c8ccac..4ea67d1c883db 100644 --- a/doc/source/whatsnew/v0.24.0.rst +++ b/doc/source/whatsnew/v0.24.0.rst @@ -1146,7 +1146,9 @@ from :class:`Period`, :class:`PeriodIndex`, and in some cases :class:`Timestamp`, :class:`DatetimeIndex` and :class:`TimedeltaIndex`. This usage is now deprecated. Instead add or subtract integer multiples of -the object's ``freq`` attribute (:issue:`21939`) +the object's ``freq`` attribute. The result of subtraction of :class:`Period` +objects will be agnostic of the multiplier of the objects' ``freq`` attribute +(:issue:`21939`, :issue:`23878`). Previous Behavior: diff --git a/pandas/_libs/tslibs/offsets.pyx b/pandas/_libs/tslibs/offsets.pyx index f3ac102bf177e..7685ddd76d4b6 100644 --- a/pandas/_libs/tslibs/offsets.pyx +++ b/pandas/_libs/tslibs/offsets.pyx @@ -352,6 +352,14 @@ class _BaseOffset(object): 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 getattr(other, "_typ", None) in ["datetimeindex", "periodindex", "datetimearray", "periodarray", diff --git a/pandas/_libs/tslibs/period.pyx b/pandas/_libs/tslibs/period.pyx index dfbf24cf177f6..1ad2688b70a03 100644 --- a/pandas/_libs/tslibs/period.pyx +++ b/pandas/_libs/tslibs/period.pyx @@ -1686,7 +1686,8 @@ cdef class _Period(object): if other.freq != self.freq: msg = _DIFFERENT_FREQ.format(self.freqstr, other.freqstr) raise IncompatibleFrequency(msg) - return (self.ordinal - other.ordinal) * self.freq + # GH 23915 - mul by base freq since __add__ is agnostic of n + return (self.ordinal - other.ordinal) * self.freq.base elif getattr(other, '_typ', None) == 'periodindex': # GH#21314 PeriodIndex - Period returns an object-index # of DateOffset objects, for which we cannot use __neg__ diff --git a/pandas/conftest.py b/pandas/conftest.py index 20f97bdec1107..c72efdf865052 100644 --- a/pandas/conftest.py +++ b/pandas/conftest.py @@ -636,6 +636,14 @@ def mock(): return pytest.importorskip("mock") +@pytest.fixture(params=[getattr(pd.offsets, o) for o in pd.offsets.__all__ if + issubclass(getattr(pd.offsets, o), pd.offsets.Tick)]) +def tick_classes(request): + """ + Fixture for Tick based datetime offsets available for a time series. + """ + return request.param + # ---------------------------------------------------------------- # Global setup for tests using Hypothesis diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py index 45eec41e498d1..ceaf9e748fe5a 100644 --- a/pandas/core/arrays/datetimelike.py +++ b/pandas/core/arrays/datetimelike.py @@ -710,7 +710,7 @@ def _sub_period_array(self, other): arr_mask=self._isnan, b_mask=other._isnan) - new_values = np.array([self.freq * x for x in new_values]) + new_values = np.array([self.freq.base * x for x in new_values]) if self.hasnans or other.hasnans: mask = (self._isnan) | (other._isnan) new_values[mask] = NaT diff --git a/pandas/tests/arithmetic/test_period.py b/pandas/tests/arithmetic/test_period.py index 7158eae376ba6..9f436281de0a0 100644 --- a/pandas/tests/arithmetic/test_period.py +++ b/pandas/tests/arithmetic/test_period.py @@ -370,6 +370,41 @@ def test_parr_sub_pi_mismatched_freq(self, box_with_array): with pytest.raises(IncompatibleFrequency): rng - other + @pytest.mark.parametrize('n', [1, 2, 3, 4]) + def test_sub_n_gt_1_ticks(self, tick_classes, n): + # GH 23878 + p1_d = '19910905' + p2_d = '19920406' + p1 = pd.PeriodIndex([p1_d], freq=tick_classes(n)) + p2 = pd.PeriodIndex([p2_d], freq=tick_classes(n)) + + expected = (pd.PeriodIndex([p2_d], freq=p2.freq.base) + - pd.PeriodIndex([p1_d], freq=p1.freq.base)) + + tm.assert_index_equal((p2 - p1), expected) + + @pytest.mark.parametrize('n', [1, 2, 3, 4]) + @pytest.mark.parametrize('offset, kwd_name', [ + (pd.offsets.YearEnd, 'month'), + (pd.offsets.QuarterEnd, 'startingMonth'), + (pd.offsets.MonthEnd, None), + (pd.offsets.Week, 'weekday') + ]) + def test_sub_n_gt_1_offsets(self, offset, kwd_name, n): + # GH 23878 + kwds = {kwd_name: 3} if kwd_name is not None else {} + p1_d = '19910905' + p2_d = '19920406' + freq = offset(n, normalize=False, **kwds) + p1 = pd.PeriodIndex([p1_d], freq=freq) + p2 = pd.PeriodIndex([p2_d], freq=freq) + + result = p2 - p1 + expected = (pd.PeriodIndex([p2_d], freq=freq.base) + - pd.PeriodIndex([p1_d], freq=freq.base)) + + tm.assert_index_equal(result, expected) + # ------------------------------------------------------------- # Invalid Operations diff --git a/pandas/tests/scalar/period/test_period.py b/pandas/tests/scalar/period/test_period.py index ddb4f89738f98..715a596999e42 100644 --- a/pandas/tests/scalar/period/test_period.py +++ b/pandas/tests/scalar/period/test_period.py @@ -1106,6 +1106,38 @@ def test_sub(self): with pytest.raises(period.IncompatibleFrequency, match=msg): per1 - Period('2011-02', freq='M') + @pytest.mark.parametrize('n', [1, 2, 3, 4]) + def test_sub_n_gt_1_ticks(self, tick_classes, n): + # GH 23878 + p1 = pd.Period('19910905', freq=tick_classes(n)) + p2 = pd.Period('19920406', freq=tick_classes(n)) + + expected = (pd.Period(str(p2), freq=p2.freq.base) + - pd.Period(str(p1), freq=p1.freq.base)) + + assert (p2 - p1) == expected + + @pytest.mark.parametrize('normalize', [True, False]) + @pytest.mark.parametrize('n', [1, 2, 3, 4]) + @pytest.mark.parametrize('offset, kwd_name', [ + (pd.offsets.YearEnd, 'month'), + (pd.offsets.QuarterEnd, 'startingMonth'), + (pd.offsets.MonthEnd, None), + (pd.offsets.Week, 'weekday') + ]) + def test_sub_n_gt_1_offsets(self, offset, kwd_name, n, normalize): + # GH 23878 + kwds = {kwd_name: 3} if kwd_name is not None else {} + p1_d = '19910905' + p2_d = '19920406' + p1 = pd.Period(p1_d, freq=offset(n, normalize, **kwds)) + p2 = pd.Period(p2_d, freq=offset(n, normalize, **kwds)) + + expected = (pd.Period(p2_d, freq=p2.freq.base) + - pd.Period(p1_d, freq=p1.freq.base)) + + assert (p2 - p1) == expected + def test_add_offset(self): # freq is DateOffset for freq in ['A', '2A', '3A']: diff --git a/pandas/tests/tseries/offsets/conftest.py b/pandas/tests/tseries/offsets/conftest.py index db8379e335679..c192a56b205ca 100644 --- a/pandas/tests/tseries/offsets/conftest.py +++ b/pandas/tests/tseries/offsets/conftest.py @@ -19,12 +19,3 @@ def month_classes(request): Fixture for month based datetime offsets available for a time series. """ return request.param - - -@pytest.fixture(params=[getattr(offsets, o) for o in offsets.__all__ if - issubclass(getattr(offsets, o), offsets.Tick)]) -def tick_classes(request): - """ - Fixture for Tick based datetime offsets available for a time series. - """ - return request.param