Repository: dask/dask

Issue:
ENH: Add rolling function for groupby objects
I am trying to get the aggregations for rolling windows. It is working fine with pandas but getting an error when using dask.

The code that I am using is as follows:
```import pandas as pd
import numpy as np
from dask import dataframe as dd

df = pd.DataFrame( 
    ...:         { 
    ...:             "date": pd.date_range("2018-01-01", periods=12, freq="D"), 
    ...:             "group": ["first", "second"] * 6, 
    ...:             "clicks": range(12), 
    ...:             "orders": range(10, 22), 
    ...:         } 
    ...:      ).sort_values(by=['group', "date"]).set_index("date") 

```
This is working:
```df.groupby('group').rolling('2D', closed='left').agg([np.sum, np.mean])```                                                                                                                                                                             

But when I replace it with dask datafame:
```
ddf = dd.from_pandas(df, npartitions=16) 
ddf.groupby('group').rolling('2D', closed='left').agg([np.sum, np.mean]).compute()
```
I am getting the following error:
```AttributeError: 'Column not found: rolling'```

Can we implement in dask where we can use the rolling function on a groupby object that uses date as index?
Or is there an existing way of doing it?


Discussion:
I don't believe this is currently implemented. I don't immediately see any fundamental issues blocking it though. @ankurrajdev are you interested in adding support for groupby rolling?
Sure. I can try.
Has there been any progress on this?

It is possible to do a groupby rolling, [example here](https://stackoverflow.com/questions/42172130/dask-rolling-function-by-group-syntax), but it only works for fixed windows. When working with datetime windows, its not working. I have raised a question on this [here](https://stackoverflow.com/questions/63289279/pandas-dask-valueerror-index-must-be-monotonic-when-performing-a-rolling-sum)
Just for visibility, I think the solution in https://github.com/dask/dask/issues/6498#issuecomment-671983351 is probably an ok workaround in the meantime until someone implements this. 

Resolving patch:
diff --git a/dask/dataframe/groupby.py b/dask/dataframe/groupby.py
--- a/dask/dataframe/groupby.py
+++ b/dask/dataframe/groupby.py
@@ -2,6 +2,7 @@
 import itertools as it
 import operator
 import warnings
+from numbers import Integral
 
 import numpy as np
 import pandas as pd
@@ -1101,6 +1102,16 @@ def __init__(
             index_meta, group_keys=group_keys, **self.observed, **self.dropna
         )
 
+    @property
+    def _groupby_kwargs(self):
+        return {
+            "by": self.index,
+            "group_keys": self.group_keys,
+            **self.dropna,
+            "sort": self.sort,
+            **self.observed,
+        }
+
     @property
     def _meta_nonempty(self):
         """
@@ -1798,6 +1809,67 @@ def transform(self, func, *args, **kwargs):
 
         return df3
 
+    def rolling(self, window, min_periods=None, center=False, win_type=None, axis=0):
+        """Provides rolling transformations.
+
+        .. note::
+
+            Since MultiIndexes are not well supported in Dask, this method returns a
+            dataframe with the same index as the original data. The groupby column is
+            not added as the first level of the index like pandas does.
+
+            This method works differently from other groupby methods. It does a groupby
+            on each partition (plus some overlap). This means that the output has the
+            same shape and number of partitions as the original.
+
+        Parameters
+        ----------
+        window : str, offset
+           Size of the moving window. This is the number of observations used
+           for calculating the statistic. Data must have a ``DatetimeIndex``
+        min_periods : int, default None
+            Minimum number of observations in window required to have a value
+            (otherwise result is NA).
+        center : boolean, default False
+            Set the labels at the center of the window.
+        win_type : string, default None
+            Provide a window type. The recognized window types are identical
+            to pandas.
+        axis : int, default 0
+
+        Returns
+        -------
+        a Rolling object on which to call a method to compute a statistic
+
+        Examples
+        --------
+        >>> import dask
+        >>> ddf = dask.datasets.timeseries(freq="1H")
+        >>> result = ddf.groupby("name").x.rolling('1D').max()
+        """
+        from dask.dataframe.rolling import RollingGroupby
+
+        if isinstance(window, Integral):
+            raise ValueError(
+                "Only time indexes are supported for rolling groupbys in dask dataframe. "
+                "``window`` must be a ``freq`` (e.g. '1H')."
+            )
+
+        if min_periods is not None:
+            if not isinstance(min_periods, Integral):
+                raise ValueError("min_periods must be an integer")
+            if min_periods < 0:
+                raise ValueError("min_periods must be >= 0")
+
+        return RollingGroupby(
+            self,
+            window=window,
+            min_periods=min_periods,
+            center=center,
+            win_type=win_type,
+            axis=axis,
+        )
+
 
 class DataFrameGroupBy(_GroupBy):
     _token_prefix = "dataframe-groupby-"
diff --git a/dask/dataframe/rolling.py b/dask/dataframe/rolling.py
--- a/dask/dataframe/rolling.py
+++ b/dask/dataframe/rolling.py
@@ -248,11 +248,6 @@ def _tail_timedelta(prevs, current, before):
     return selected
 
 
-def pandas_rolling_method(df, rolling_kwargs, name, *args, **kwargs):
-    rolling = df.rolling(**rolling_kwargs)
-    return getattr(rolling, name)(*args, **kwargs)
-
-
 class Rolling:
     """Provides rolling window calculations."""
 
@@ -295,16 +290,21 @@ def _has_single_partition(self):
             or self.obj.npartitions == 1
         )
 
+    @staticmethod
+    def pandas_rolling_method(df, rolling_kwargs, name, *args, **kwargs):
+        rolling = df.rolling(**rolling_kwargs)
+        return getattr(rolling, name)(*args, **kwargs)
+
     def _call_method(self, method_name, *args, **kwargs):
         rolling_kwargs = self._rolling_kwargs()
-        meta = pandas_rolling_method(
+        meta = self.pandas_rolling_method(
             self.obj._meta_nonempty, rolling_kwargs, method_name, *args, **kwargs
         )
 
         if self._has_single_partition:
             # There's no overlap just use map_partitions
             return self.obj.map_partitions(
-                pandas_rolling_method,
+                self.pandas_rolling_method,
                 rolling_kwargs,
                 method_name,
                 *args,
@@ -323,7 +323,7 @@ def _call_method(self, method_name, *args, **kwargs):
             before = self.window - 1
             after = 0
         return map_overlap(
-            pandas_rolling_method,
+            self.pandas_rolling_method,
             self.obj,
             before,
             after,
@@ -436,3 +436,63 @@ def order(item):
                 if v is not None
             )
         )
+
+
+class RollingGroupby(Rolling):
+    def __init__(
+        self,
+        groupby,
+        window=None,
+        min_periods=None,
+        center=False,
+        win_type=None,
+        axis=0,
+    ):
+        self._groupby_kwargs = groupby._groupby_kwargs
+        self._groupby_slice = groupby._slice
+
+        obj = groupby.obj
+        if self._groupby_slice is not None:
+            if isinstance(self._groupby_slice, str):
+                sliced_plus = [self._groupby_slice]
+            else:
+                sliced_plus = list(self._groupby_slice)
+            if isinstance(groupby.index, str):
+                sliced_plus.append(groupby.index)
+            else:
+                sliced_plus.extend(groupby.index)
+            obj = obj[sliced_plus]
+
+        super().__init__(
+            obj,
+            window=window,
+            min_periods=min_periods,
+            center=center,
+            win_type=win_type,
+            axis=axis,
+        )
+
+    @staticmethod
+    def pandas_rolling_method(
+        df,
+        rolling_kwargs,
+        name,
+        *args,
+        groupby_kwargs=None,
+        groupby_slice=None,
+        **kwargs
+    ):
+        groupby = df.groupby(**groupby_kwargs)
+        if groupby_slice:
+            groupby = groupby[groupby_slice]
+        rolling = groupby.rolling(**rolling_kwargs)
+        return getattr(rolling, name)(*args, **kwargs).sort_index(level=-1)
+
+    def _call_method(self, method_name, *args, **kwargs):
+        return super()._call_method(
+            method_name,
+            *args,
+            groupby_kwargs=self._groupby_kwargs,
+            groupby_slice=self._groupby_slice,
+            **kwargs,
+        )


Test patch:
diff --git a/dask/dataframe/tests/test_rolling.py b/dask/dataframe/tests/test_rolling.py
--- a/dask/dataframe/tests/test_rolling.py
+++ b/dask/dataframe/tests/test_rolling.py
@@ -401,3 +401,35 @@ def f(x):
         df.rolling(3).apply(f, engine="numba", raw=True),
         ddf.rolling(3).apply(f, engine="numba", raw=True),
     )
+
+
+def test_groupby_rolling():
+    df = pd.DataFrame(
+        {
+            "column1": range(600),
+            "group1": 5 * ["g" + str(i) for i in range(120)],
+        },
+        index=pd.date_range("20190101", periods=60).repeat(10),
+    )
+
+    ddf = dd.from_pandas(df, npartitions=8)
+
+    expected = df.groupby("group1").rolling("15D").sum()
+    actual = ddf.groupby("group1").rolling("15D").sum()
+
+    assert_eq(expected, actual, check_divisions=False)
+
+    expected = df.groupby("group1").column1.rolling("15D").mean()
+    actual = ddf.groupby("group1").column1.rolling("15D").mean()
+
+    assert_eq(expected, actual, check_divisions=False)
+
+
+def test_groupby_rolling_with_integer_window_raises():
+    df = pd.DataFrame(
+        {"B": [0, 1, 2, np.nan, 4, 5, 6], "C": ["a", "a", "a", "b", "b", "a", "b"]}
+    )
+    ddf = dd.from_pandas(df, npartitions=2)
+
+    with pytest.raises(ValueError, match="``window`` must be a ``freq``"):
+        ddf.groupby("C").rolling(2).sum()