Repository: pandas-dev/pandas
Pull request: ENH: Add cumulative methods to ea
- [x] closes #28385 (Replace xxxx with the Github issue number)
- [x] [Tests added and passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#writing-tests) if fixing a bug or adding a new feature
- [x] All [code checks passed](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#pre-commit).
- [x] Added [type annotations](https://pandas.pydata.org/pandas-docs/dev/development/contributing_codebase.html#type-hints) to new arguments/methods/functions.
- [x] Added an entry in the latest `doc/source/whatsnew/vX.X.X.rst` file if fixing a bug or adding a new feature.

This tries to finish #28509. I've reduced the scope to masked arrays and would prefer to do datetimelike as a follow up, if we want to change the behavior there. This is currently a big buggy as mentioned in https://github.com/pandas-dev/pandas/pull/28509#issuecomment-778741118
Currently, we are dispatching back to the current implementation.

I was wondering about the exact interface. Currently, you'd have to call ``_accumulate`` with the name of the function, since ``cumsum`` etc. are not really added to the interface, meaning

```
arr = pd.array([1, 2, pd.NA], dtype="Int64")
arr.cumsum()
```

raises, because the method is not registered. I this intended? 

Problem statement:
Nullable Int64 column changes type after some (cumsum) operations
#### Code
```python
import pandas as pd

s1 = pd.Series([1, 2, 3, 4], dtype='Int64')
s2 = pd.Series([1, 2, float('nan'), 3, 4], dtype='Int64')

cs1 = s1.cumsum()
cs2 = s1.cumsum()

print(str(s1.dtype), str(cs1.dtype))
print(str(s2.dtype), str(cs2.dtype))
```

#### Output
```
Int64 object
Int64 object
```
#### Expected Output

```
Int64 Int64
Int64 Int64
```

#### Problem description

After an operation like a cumulative sum on a column/series with an `Int64` dtype, the dtype of the result is downcast to `object`. The contents (integers and nans) of the result still qualify for  it to have an `Int64` dtype.

`cummax`, `cummin` and `cumprod` have the same behaviour.


#### Output of ``pd.show_versions()``

<details>

INSTALLED VERSIONS
------------------
commit           : None
python           : 3.7.4.final.0
python-bits      : 64
OS               : Linux
OS-release       : 5.0.13-arch1-1-ARCH
machine          : x86_64
processor        : 
byteorder        : little
LC_ALL           : en_US.utf8
LANG             : en_US.UTF-8
LOCALE           : en_US.UTF-8

pandas           : 0.25.1
numpy            : 1.17.0
pytz             : 2019.2
dateutil         : 2.8.0
pip              : 19.2.3
setuptools       : 41.1.0
Cython           : 0.29.13
pytest           : 5.1.0
hypothesis       : None
sphinx           : 2.2.0
blosc            : None
feather          : None
xlsxwriter       : None
lxml.etree       : 4.4.1
html5lib         : 1.0.1
pymysql          : None
psycopg2         : None
jinja2           : 2.10.1
IPython          : 7.7.0
pandas_datareader: None
bs4              : 4.8.0
bottleneck       : None
fastparquet      : None
gcsfs            : None
lxml.etree       : 4.4.1
matplotlib       : 3.1.1
numexpr          : 2.7.0
odfpy            : None
openpyxl         : None
pandas_gbq       : None
pyarrow          : None
pytables         : None
s3fs             : None
scipy            : 1.3.1
sqlalchemy       : None
tables           : None
xarray           : 0.12.3
xlrd             : 1.2.0
xlwt             : None
xlsxwriter       : None
import pandas as pd

s = pd.Series([1, 2, 3, 4], dtype='Int64')
cs = s.cumsum()

print(str(s.dtype))
print(str(cs.dtype))
import pandas as pd
​
</details>

Hints from issue discussion:
I don't think that cumulative operations are currently part of the extension array interface.

Likely, we need something similar to `ExtensionArray._reduce`, but for cumulative methods.
Python's `itertools` contain an `accumulate` method, so I'd suggest `ExtensionArray._accumulate` and would like to take a shot at this.
@TomAugspurger would this direction/naming be okay?

Is the assumption correct, that I'd have to e.g. create a `nancumsum` function in `core.nanops.py`?
We might need to think a bit more in general about this problem. Adding `_accumulate` is fine for accumulative methods, but do we want to keep adding such methods for other kinds of functions? (eg the `np.round` that came up before) 
Do we want to eg use the `__numpy_function__` protocol for this? Or a similar mechanism specifically for pandas? 

Now for this specific case, the numpy protocol is actually not sufficient, as numpy only has `cumsum` and `cumprod`, and not `cummin`/`cummax`
This may be obvious to those who know the implementation details, but it happens with `.diff()` too (on pandas 0.25.2):

```python
pd.Series([1, 2, 3], dtype="Int16").diff()
```
```
0    NaN
1      1
2      1
dtype: object
```

`Series.diff` is actually different, and has a custom implementation in pandas, that could be expanded to handle our nullable dtypes. I think it is worth opening a separate issue for that.

Commit message: Add whatsnew

Patch to review:
diff --git a/doc/source/reference/extensions.rst b/doc/source/reference/extensions.rst
index ce8d8d5c2ca10..595b415ff7342 100644
--- a/doc/source/reference/extensions.rst
+++ b/doc/source/reference/extensions.rst
@@ -32,6 +32,7 @@ objects.
    .. autosummary::
       :toctree: api/
 
+      api.extensions.ExtensionArray._accumulate
       api.extensions.ExtensionArray._concat_same_type
       api.extensions.ExtensionArray._formatter
       api.extensions.ExtensionArray._from_factorized
diff --git a/doc/source/whatsnew/v2.0.0.rst b/doc/source/whatsnew/v2.0.0.rst
index 02ea290995c8d..0e130d1d1e28a 100644
--- a/doc/source/whatsnew/v2.0.0.rst
+++ b/doc/source/whatsnew/v2.0.0.rst
@@ -58,6 +58,7 @@ Other enhancements
 - Added new argument ``use_nullable_dtypes`` to :func:`read_csv` and :func:`read_excel` to enable automatic conversion to nullable dtypes (:issue:`36712`)
 - Added ``index`` parameter to :meth:`DataFrame.to_dict` (:issue:`46398`)
 - Added metadata propagation for binary operators on :class:`DataFrame` (:issue:`28283`)
+- Added ``cumsum``, ``cumprod``, ``cummin`` and ``cummax`` to the ``ExtensionArray`` interface via ``_accumulate`` (:issue:`28385`)
 - :class:`.CategoricalConversionWarning`, :class:`.InvalidComparison`, :class:`.InvalidVersion`, :class:`.LossySetitemError`, and :class:`.NoBufferPresent` are now exposed in ``pandas.errors`` (:issue:`27656`)
 - Fix ``test`` optional_extra by adding missing test package ``pytest-asyncio`` (:issue:`48361`)
 - :func:`DataFrame.astype` exception message thrown improved to include column name when type conversion is not possible. (:issue:`47571`)
diff --git a/pandas/conftest.py b/pandas/conftest.py
index 30ff8306a03b2..831f1e4dbfe55 100644
--- a/pandas/conftest.py
+++ b/pandas/conftest.py
@@ -1121,6 +1121,17 @@ def all_logical_operators(request):
     return request.param
 
 
+_all_numeric_accumulations = ["cumsum", "cumprod", "cummin", "cummax"]
+
+
+@pytest.fixture(params=_all_numeric_accumulations)
+def all_numeric_accumulations(request):
+    """
+    Fixture for numeric accumulation names
+    """
+    return request.param
+
+
 # ----------------------------------------------------------------
 # Data sets/files
 # ----------------------------------------------------------------
diff --git a/pandas/core/array_algos/masked_accumulations.py b/pandas/core/array_algos/masked_accumulations.py
new file mode 100644
index 0000000000000..581801b3c3946
--- /dev/null
+++ b/pandas/core/array_algos/masked_accumulations.py
@@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+
+from pandas.core.dtypes.common import (
+    is_bool_dtype,
+    is_float_dtype,
+    is_integer_dtype,
+)
+
+"""
+masked_accumulations.py is for accumulation algorithms using a mask-based approach
+for missing values.
+"""
+
+
+def _cum_func(
+    func: Callable,
+    values: np.ndarray,
+    mask: np.ndarray,
+    *,
+    skipna: bool = True,
+):
+    """
+    Accumulations for 1D masked array.
+
+    Parameters
+    ----------
+    func : np.cumsum, np.cumprod, np.maximum.accumulate, np.minimum.accumulate
+    values : np.ndarray
+        Numpy array with the values (can be of any dtype that support the
+        operation).
+    mask : np.ndarray
+        Boolean numpy array (True values indicate missing values).
+    skipna : bool, default True
+        Whether to skip NA.
+    """
+    dtype_info: np.iinfo | np.finfo
+    if is_float_dtype(values):
+        dtype_info = np.finfo(values.dtype.type)
+    elif is_integer_dtype(values):
+        dtype_info = np.iinfo(values.dtype.type)
+    elif is_bool_dtype(values):
+        # Max value of bool is 1, but since we are setting into a boolean
+        # array, 255 is fine as well. Min value has to be 0 when setting
+        # into the boolean array.
+        dtype_info = np.iinfo(np.uint8)
+    else:
+        raise NotImplementedError(
+            f"No masked accumulation defined for dtype {values.dtype.type}"
+        )
+    try:
+        fill_value = {
+            np.cumprod: 1,
+            np.maximum.accumulate: dtype_info.min,
+            np.cumsum: 0,
+            np.minimum.accumulate: dtype_info.max,
+        }[func]
+    except KeyError:
+        raise NotImplementedError(
+            f"No accumulation for {func} implemented on BaseMaskedArray"
+        )
+
+    values[mask] = fill_value
+
+    if not skipna:
+        mask = np.maximum.accumulate(mask)
+
+    values = func(values)
+    return values, mask
+
+
+def cumsum(values: np.ndarray, mask: np.ndarray, *, skipna: bool = True):
+    return _cum_func(np.cumsum, values, mask, skipna=skipna)
+
+
+def cumprod(values: np.ndarray, mask: np.ndarray, *, skipna: bool = True):
+    return _cum_func(np.cumprod, values, mask, skipna=skipna)
+
+
+def cummin(values: np.ndarray, mask: np.ndarray, *, skipna: bool = True):
+    return _cum_func(np.minimum.accumulate, values, mask, skipna=skipna)
+
+
+def cummax(values: np.ndarray, mask: np.ndarray, *, skipna: bool = True):
+    return _cum_func(np.maximum.accumulate, values, mask, skipna=skipna)
diff --git a/pandas/core/arrays/base.py b/pandas/core/arrays/base.py
index a3c201b402b0f..5144083ea6772 100644
--- a/pandas/core/arrays/base.py
+++ b/pandas/core/arrays/base.py
@@ -133,6 +133,7 @@ class ExtensionArray:
     tolist
     unique
     view
+    _accumulate
     _concat_same_type
     _formatter
     _from_factorized
@@ -182,8 +183,9 @@ class ExtensionArray:
     as they only compose abstract methods. Still, a more efficient
     implementation may be available, and these methods can be overridden.
 
-    One can implement methods to handle array reductions.
+    One can implement methods to handle array accumulations or reductions.
 
+    * _accumulate
     * _reduce
 
     One can implement methods to handle parsing from strings that will be used
@@ -1368,6 +1370,38 @@ def _concat_same_type(
     def _can_hold_na(self) -> bool:
         return self.dtype._can_hold_na
 
+    def _accumulate(
+        self, name: str, *, skipna: bool = True, **kwargs
+    ) -> ExtensionArray:
+        """
+        Return an ExtensionArray performing an accumulation operation.
+
+        The underlying data type might change.
+
+        Parameters
+        ----------
+        name : str
+            Name of the function, supported values are:
+            - cummin
+            - cummax
+            - cumsum
+            - cumprod
+        skipna : bool, default True
+            If True, skip NA values.
+        **kwargs
+            Additional keyword arguments passed to the accumulation function.
+            Currently, there is no supported kwarg.
+
+        Returns
+        -------
+        array
+
+        Raises
+        ------
+        NotImplementedError : subclass does not define accumulations
+        """
+        raise NotImplementedError(f"cannot perform {name} with type {self.dtype}")
+
     def _reduce(self, name: str, *, skipna: bool = True, **kwargs):
         """
         Return a scalar result of performing the reduction operation.
diff --git a/pandas/core/arrays/boolean.py b/pandas/core/arrays/boolean.py
index 35b9de3f7af93..8ac665b1b2e11 100644
--- a/pandas/core/arrays/boolean.py
+++ b/pandas/core/arrays/boolean.py
@@ -26,6 +26,7 @@
 from pandas.core.dtypes.missing import isna
 
 from pandas.core import ops
+from pandas.core.array_algos import masked_accumulations
 from pandas.core.arrays.masked import (
     BaseMaskedArray,
     BaseMaskedDtype,
@@ -378,3 +379,19 @@ def _logical_method(self, other, op):
 
         # i.e. BooleanArray
         return self._maybe_mask_result(result, mask)
+
+    def _accumulate(
+        self, name: str, *, skipna: bool = True, **kwargs
+    ) -> BaseMaskedArray:
+        data = self._data
+        mask = self._mask
+        if name in ("cummin", "cummax"):
+            op = getattr(masked_accumulations, name)
+            data, mask = op(data, mask, skipna=skipna, **kwargs)
+            return type(self)(data, mask, copy=False)
+        else:
+            from pandas.core.arrays import IntegerArray
+
+            return IntegerArray(data.astype(int), mask)._accumulate(
+                name, skipna=skipna, **kwargs
+            )
diff --git a/pandas/core/arrays/datetimelike.py b/pandas/core/arrays/datetimelike.py
index 2f28bf5d0145d..e0a07171dffe1 100644
--- a/pandas/core/arrays/datetimelike.py
+++ b/pandas/core/arrays/datetimelike.py
@@ -1397,6 +1397,24 @@ def _time_shift(
         #  to be passed explicitly.
         return self._generate_range(start=start, end=end, periods=None, freq=self.freq)
 
+    def _accumulate(self, name: str, *, skipna: bool = True, **kwargs):
+
+        data = self._data.copy()
+
+        if name in {"cummin", "cummax"}:
+            func = np.minimum.accumulate if name == "cummin" else np.maximum.accumulate
+            result = cast(np.ndarray, nanops.na_accum_func(data, func, skipna=skipna))
+
+            # error: Unexpected keyword argument "freq" for
+            # "_simple_new" of "NDArrayBacked"  [call-arg]
+            return type(self)._simple_new(
+                result, freq=self.freq, dtype=self.dtype  # type: ignore[call-arg]
+            )
+
+        raise NotImplementedError(
+            f"Accumlation {name} not implemented for {type(self)}"
+        )
+
     @unpack_zerodim_and_defer("__add__")
     def __add__(self, other):
         other_dtype = getattr(other, "dtype", None)
diff --git a/pandas/core/arrays/masked.py b/pandas/core/arrays/masked.py
index 9968ebc826575..e9006c9e8f737 100644
--- a/pandas/core/arrays/masked.py
+++ b/pandas/core/arrays/masked.py
@@ -74,7 +74,10 @@
     isin,
     take,
 )
-from pandas.core.array_algos import masked_reductions
+from pandas.core.array_algos import (
+    masked_accumulations,
+    masked_reductions,
+)
 from pandas.core.array_algos.quantile import quantile_with_mask
 from pandas.core.arraylike import OpsMixin
 from pandas.core.arrays import ExtensionArray
@@ -1330,3 +1333,14 @@ def all(self, *, skipna: bool = True, **kwargs):
                 return result
             else:
                 return self.dtype.na_value
+
+    def _accumulate(
+        self, name: str, *, skipna: bool = True, **kwargs
+    ) -> BaseMaskedArray:
+        data = self._data
+        mask = self._mask
+
+        op = getattr(masked_accumulations, name)
+        data, mask = op(data, mask, skipna=skipna, **kwargs)
+
+        return type(self)(data, mask, copy=False)
diff --git a/pandas/core/arrays/timedeltas.py b/pandas/core/arrays/timedeltas.py
index 334e5437c2f70..cd0283e308120 100644
--- a/pandas/core/arrays/timedeltas.py
+++ b/pandas/core/arrays/timedeltas.py
@@ -410,6 +410,22 @@ def std(
             return self._box_func(result)
         return self._from_backing_data(result)
 
+    # ----------------------------------------------------------------
+    # Accumulations
+
+    def _accumulate(self, name: str, *, skipna: bool = True, **kwargs):
+
+        data = self._data.copy()
+
+        if name in {"cumsum", "cumprod"}:
+            func = np.cumsum if name == "cumsum" else np.cumprod
+            result = cast(np.ndarray, nanops.na_accum_func(data, func, skipna=skipna))
+
+            return type(self)._simple_new(result, freq=None, dtype=self.dtype)
+
+        else:
+            return super()._accumulate(name, skipna=skipna, **kwargs)
+
     # ----------------------------------------------------------------
     # Rendering Methods
 
diff --git a/pandas/core/generic.py b/pandas/core/generic.py
index 1b17deb7def90..b657c599362f6 100644
--- a/pandas/core/generic.py
+++ b/pandas/core/generic.py
@@ -10859,7 +10859,15 @@ def _accum_func(
         def block_accum_func(blk_values):
             values = blk_values.T if hasattr(blk_values, "T") else blk_values
 
-            result = nanops.na_accum_func(values, func, skipna=skipna)
+            from pandas.core.construction import ensure_wrapped_if_datetimelike
+
+            values = ensure_wrapped_if_datetimelike(values)
+
+            result: np.ndarray | ExtensionArray
+            if isinstance(values, ExtensionArray):
+                result = values._accumulate(name, skipna=skipna, **kwargs)
+            else:
+                result = nanops.na_accum_func(values, func, skipna=skipna)
 
             result = result.T if hasattr(result, "T") else result
             return result
diff --git a/pandas/tests/extension/base/__init__.py b/pandas/tests/extension/base/__init__.py
index 571ab3dca1efc..0089aa08da65a 100644
--- a/pandas/tests/extension/base/__init__.py
+++ b/pandas/tests/extension/base/__init__.py
@@ -41,6 +41,10 @@ class TestMyDtype(BaseDtypeTests):
 ``assert_series_equal`` on your base test class.
 
 """
+from pandas.tests.extension.base.accumulate import (  # noqa
+    BaseNoAccumulateTests,
+    BaseNumericAccumulateTests,
+)
 from pandas.tests.extension.base.casting import BaseCastingTests  # noqa
 from pandas.tests.extension.base.constructors import BaseConstructorsTests  # noqa
 from pandas.tests.extension.base.dim2 import (  # noqa
diff --git a/pandas/tests/extension/base/accumulate.py b/pandas/tests/extension/base/accumulate.py
new file mode 100644
index 0000000000000..e6f476ff0ac4f
--- /dev/null
+++ b/pandas/tests/extension/base/accumulate.py
@@ -0,0 +1,43 @@
+import pytest
+
+import pandas as pd
+from pandas.tests.extension.base.base import BaseExtensionTests
+
+
+class BaseAccumulateTests(BaseExtensionTests):
+    """
+    Accumulation specific tests. Generally these only
+    make sense for numeric/boolean operations.
+    """
+
+    def check_accumulate(self, s, op_name, skipna):
+        result = getattr(s, op_name)(skipna=skipna)
+
+        if result.dtype == pd.Float32Dtype() and op_name == "cumprod" and skipna:
+            pytest.skip(
+                f"Float32 precision lead to large differences with op {op_name} "
+                f"and skipna={skipna}"
+            )
+
+        expected = getattr(s.astype("float64"), op_name)(skipna=skipna)
+        self.assert_series_equal(result, expected, check_dtype=False)
+
+
+class BaseNoAccumulateTests(BaseAccumulateTests):
+    """we don't define any accumulations"""
+
+    @pytest.mark.parametrize("skipna", [True, False])
+    def test_accumulate_series_numeric(self, data, all_numeric_accumulations, skipna):
+        op_name = all_numeric_accumulations
+        s = pd.Series(data)
+
+        with pytest.raises(NotImplementedError):
+            getattr(s, op_name)(skipna=skipna)
+
+
+class BaseNumericAccumulateTests(BaseAccumulateTests):
+    @pytest.mark.parametrize("skipna", [True, False])
+    def test_accumulate_series(self, data, all_numeric_accumulations, skipna):
+        op_name = all_numeric_accumulations
+        s = pd.Series(data)
+        self.check_accumulate(s, op_name, skipna)
diff --git a/pandas/tests/extension/test_boolean.py b/pandas/tests/extension/test_boolean.py
index b846028dab947..a347aea4bf28b 100644
--- a/pandas/tests/extension/test_boolean.py
+++ b/pandas/tests/extension/test_boolean.py
@@ -16,6 +16,8 @@
 import numpy as np
 import pytest
 
+from pandas.core.dtypes.common import is_bool_dtype
+
 import pandas as pd
 import pandas._testing as tm
 from pandas.core.arrays.boolean import BooleanDtype
@@ -389,6 +391,15 @@ class TestUnaryOps(base.BaseUnaryOpsTests):
     pass
 
 
+class TestNumericAccumulation(base.BaseNumericAccumulateTests):
+    def check_accumulate(self, s, op_name, skipna):
+        result = getattr(s, op_name)(skipna=skipna)
+        expected = getattr(pd.Series(s.astype("float64")), op_name)(skipna=skipna)
+        tm.assert_series_equal(result, expected, check_dtype=False)
+        if op_name in ("cummin", "cummax"):
+            assert is_bool_dtype(result)
+
+
 class TestParsing(base.BaseParsingTests):
     pass
 
diff --git a/pandas/tests/extension/test_categorical.py b/pandas/tests/extension/test_categorical.py
index 1e17bf33c806c..5ba548d6dd222 100644
--- a/pandas/tests/extension/test_categorical.py
+++ b/pandas/tests/extension/test_categorical.py
@@ -156,6 +156,10 @@ class TestReduce(base.BaseNoReduceTests):
     pass
 
 
+class TestAccumulate(base.BaseNoAccumulateTests):
+    pass
+
+
 class TestMethods(base.BaseMethodsTests):
     @pytest.mark.xfail(reason="Unobserved categories included")
     def test_value_counts(self, all_data, dropna):
diff --git a/pandas/tests/extension/test_floating.py b/pandas/tests/extension/test_floating.py
index 0d88822009a90..e4222637ec372 100644
--- a/pandas/tests/extension/test_floating.py
+++ b/pandas/tests/extension/test_floating.py
@@ -214,3 +214,7 @@ class TestParsing(base.BaseParsingTests):
 @pytest.mark.filterwarnings("ignore:overflow encountered in reduce:RuntimeWarning")
 class Test2DCompat(base.Dim2CompatTests):
     pass
+
+
+class TestNumericAccumulation(base.BaseNumericAccumulateTests):
+    pass
diff --git a/pandas/tests/extension/test_integer.py b/pandas/tests/extension/test_integer.py
index a6cf820dc7609..40254c79f96bf 100644
--- a/pandas/tests/extension/test_integer.py
+++ b/pandas/tests/extension/test_integer.py
@@ -16,6 +16,11 @@
 import numpy as np
 import pytest
 
+from pandas.compat import (
+    IS64,
+    is_platform_windows,
+)
+
 import pandas as pd
 import pandas._testing as tm
 from pandas.api.types import (
@@ -224,6 +229,52 @@ class TestBooleanReduce(base.BaseBooleanReduceTests):
     pass
 
 
+class TestNumericAccumulation(base.BaseNumericAccumulateTests):
+    def check_accumulate(self, s, op_name, skipna):
+        # overwrite to ensure pd.NA is tested instead of np.nan
+        # https://github.com/pandas-dev/pandas/issues/30958
+        length = 64
+        if not IS64 or is_platform_windows():
+            if not s.dtype.itemsize == 8:
+                length = 32
+
+        if s.dtype.name.startswith("U"):
+            expected_dtype = f"UInt{length}"
+        else:
+            expected_dtype = f"Int{length}"
+
+        if op_name == "cumsum":
+            result = getattr(s, op_name)(skipna=skipna)
+            expected = pd.Series(
+                pd.array(
+                    getattr(s.astype("float64"), op_name)(skipna=skipna),
+                    dtype=expected_dtype,
+                )
+            )
+            tm.assert_series_equal(result, expected)
+        elif op_name in ["cummax", "cummin"]:
+            result = getattr(s, op_name)(skipna=skipna)
+            expected = pd.Series(
+                pd.array(
+                    getattr(s.astype("float64"), op_name)(skipna=skipna),
+                    dtype=s.dtype,
+                )
+            )
+            tm.assert_series_equal(result, expected)
+        elif op_name == "cumprod":
+            result = getattr(s[:12], op_name)(skipna=skipna)
+            expected = pd.Series(
+                pd.array(
+                    getattr(s[:12].astype("float64"), op_name)(skipna=skipna),
+                    dtype=expected_dtype,
+                )
+            )
+            tm.assert_series_equal(result, expected)
+
+        else:
+            raise NotImplementedError(f"{op_name} not supported")
+
+
 class TestPrinting(base.BasePrintingTests):
     pass
 
diff --git a/pandas/tests/extension/test_sparse.py b/pandas/tests/extension/test_sparse.py
index f82d3c6c06fca..0465d352d1d82 100644
--- a/pandas/tests/extension/test_sparse.py
+++ b/pandas/tests/extension/test_sparse.py
@@ -482,3 +482,7 @@ def test_EA_types(self, engine, data):
         with pytest.raises(NotImplementedError, match=expected_msg):
             with tm.assert_produces_warning(FutureWarning, match="astype from"):
                 super().test_EA_types(engine, data)
+
+
+class TestNoNumericAccumulations(base.BaseNoAccumulateTests):
+    pass


Code review comments:
diff_hunk: @@ -1397,6 +1397,24 @@ def _time_shift(
         #  to be passed explicitly.
         return self._generate_range(start=start, end=end, periods=None, freq=self.freq)
 
+    def _accumulate(self, name: str, *, skipna: bool = True, **kwargs):
+
+        data = self._data.copy()
path: pandas/core/arrays/datetimelike.py
text: @user1:
self._ndarray.copy()

@user1:
(we should get rid of the _data alias)

@author:
Changed

diff_hunk: @@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+
+from pandas.core.dtypes.common import (
+    is_bool_dtype,
+    is_float_dtype,
+    is_integer_dtype,
+)
+
+"""
path: pandas/core/array_algos/masked_accumulations.py
text: @user1:
id expect this at the top of the file.  does one of the linters not like that?

@author:
Moved

diff_hunk: @@ -1397,6 +1397,24 @@ def _time_shift(
         #  to be passed explicitly.
         return self._generate_range(start=start, end=end, periods=None, freq=self.freq)
 
+    def _accumulate(self, name: str, *, skipna: bool = True, **kwargs):
+
+        data = self._data.copy()
+
+        if name in {"cummin", "cummax"}:
+            func = np.minimum.accumulate if name == "cummin" else np.maximum.accumulate
+            result = cast(np.ndarray, nanops.na_accum_func(data, func, skipna=skipna))
+
+            # error: Unexpected keyword argument "freq" for
+            # "_simple_new" of "NDArrayBacked"  [call-arg]
+            return type(self)._simple_new(
+                result, freq=self.freq, dtype=self.dtype  # type: ignore[call-arg]
+            )
+
+        raise NotImplementedError(
path: pandas/core/arrays/datetimelike.py
text: @user1:
possibly TypeError?  in these cases its not that its not implemented, its that they're not valid

@author:
Sounds good, changed

diff_hunk: @@ -10859,7 +10859,15 @@ def _accum_func(
         def block_accum_func(blk_values):
             values = blk_values.T if hasattr(blk_values, "T") else blk_values
 
-            result = nanops.na_accum_func(values, func, skipna=skipna)
+            from pandas.core.construction import ensure_wrapped_if_datetimelike
path: pandas/core/generic.py
text: @user1:
can this go at the top of the file?

@author:
moved

diff_hunk: @@ -10859,7 +10859,15 @@ def _accum_func(
         def block_accum_func(blk_values):
             values = blk_values.T if hasattr(blk_values, "T") else blk_values
 
-            result = nanops.na_accum_func(values, func, skipna=skipna)
+            from pandas.core.construction import ensure_wrapped_if_datetimelike
+
+            values = ensure_wrapped_if_datetimelike(values)
path: pandas/core/generic.py
text: @user1:
blk_values should already be EA in the relevant cases

@author:
Indeed, removed

diff_hunk: @@ -0,0 +1,88 @@
+from __future__ import annotations
+
+from typing import Callable
+
+import numpy as np
+
+from pandas.core.dtypes.common import (
+    is_bool_dtype,
+    is_float_dtype,
+    is_integer_dtype,
+)
+
+"""
+masked_accumulations.py is for accumulation algorithms using a mask-based approach
+for missing values.
+"""
+
+
+def _cum_func(
+    func: Callable,
+    values: np.ndarray,
+    mask: np.ndarray,
path: pandas/core/array_algos/masked_accumulations.py
text: @user1:
can we type as `npt.NDArray[np.bool_]`

@author:
Done