Repository: skfolio/skfolio

Issue:
Rolling Measure for Population
Currently the rolling measure plot only works for portfolios and not for populations. This could be a helpful addition. Expected behaviour. 

```
population.plot_rolling_measure(measure=RatioMeasure.SHARPE_RATIO)
```

Then if 4 difference portfolios in population, they would all be plotted

Discussion:
@HugoDelatte: Yes agreed, good idea. Do you wish to create a PR for it otherwise I can do it?
The implementation should be relatively similar to `plot_cumulative_returns` I think.@HugoDelatte: Implementation done in dev branch here:
https://github.com/skfolio/skfolio/blob/128f619960494f827fb2205e1a9d5cca383092d8/src/skfolio/population/_population.py#L455

Solution patch:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index f3e62911..2a336531 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -33,7 +33,7 @@ jobs:
 
       - name: Python Semantic Release
         id: release
-        uses: python-semantic-release/python-semantic-release@v8.5.1
+        uses: python-semantic-release/python-semantic-release@v9.8.6
         with:
           github_token: ${{ steps.generate_token.outputs.token }}
 
diff --git a/examples/1_mean_risk/plot_8_regularization.py b/examples/1_mean_risk/plot_8_regularization.py
index abd543a4..362e230f 100644
--- a/examples/1_mean_risk/plot_8_regularization.py
+++ b/examples/1_mean_risk/plot_8_regularization.py
@@ -119,16 +119,17 @@
 )
 
 for tag in ["No Regularization", "L1 Regularization"]:
+
     print("=================")
     print(tag)
     print("=================")
     print(
         "Avg Sharpe Ratio Train:"
-        f" {population_train.measures_mean(measure=RatioMeasure.ANNUALIZED_SHARPE_RATIO, tags=tag):0.2f}"
+        f" {population_train.filter(tags=tag).measures_mean(measure=RatioMeasure.ANNUALIZED_SHARPE_RATIO):0.2f}"
     )
     print(
         "Avg Sharpe Ratio Test:"
-        f" {population_test.measures_mean(measure=RatioMeasure.ANNUALIZED_SHARPE_RATIO, tags=tag):0.2f}"
+        f" {population_test.filter(tags=tag).measures_mean(measure=RatioMeasure.ANNUALIZED_SHARPE_RATIO):0.2f}"
     )
     print(
         "Avg non-zeros assets:"
diff --git a/pyproject.toml b/pyproject.toml
index 31dfa10c..06524d03 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -95,7 +95,8 @@ version_toml = [
     "pyproject.toml:project.version",
 ]
 branch = "main"
-major_on_zero = true
+allow_zero_version = true
+major_on_zero = false
 tag_format = "v{version}"
 upload_to_pypi = false
 upload_to_vcs_release = true
diff --git a/src/skfolio/model_selection/_walk_forward.py b/src/skfolio/model_selection/_walk_forward.py
index ef560ebd..c57fb508 100644
--- a/src/skfolio/model_selection/_walk_forward.py
+++ b/src/skfolio/model_selection/_walk_forward.py
@@ -8,50 +8,109 @@
 # scikit-learn, Copyright (c) 2007-2010 David Cournapeau, Fabian Pedregosa, Olivier
 # Grisel Licensed under BSD 3 clause.
 
+import datetime as dt
 from collections.abc import Iterator
 
 import numpy as np
 import numpy.typing as npt
+import pandas as pd
 import sklearn.model_selection as sks
 import sklearn.utils as sku
 
 
 class WalkForward(sks.BaseCrossValidator):
-    """Walk Forward cross-validator.
+    """Walk Forward Cross-Validator.
 
-    Provides train/test indices to split time series data samples in a walk forward
+    Provides train/test indices to split time series data samples using a walk-forward
     logic.
 
-    In each split, test indices must be higher than before, and thus shuffling
-    in cross validator is inappropriate.
+    In each split, test indices must be higher than the previous ones; therefore,
+    shuffling in cross-validator is inappropriate.
 
     Compared to `sklearn.model_selection.TimeSeriesSplit`, you control the train/test
-    folds by providing a number of training and test samples instead of a number of
-    split making it more suitable for portfolio cross-validation.
+    folds by specifying the number of training and test samples instead of the number
+    of splits, making it more suitable for portfolio cross-validation.
+
+    If your data is a DataFrame indexed with a DatetimeIndex, you can split the data
+    using specific datetime frequencies and offsets.
 
     Parameters
     ----------
     test_size : int
-        Number of observations in each test set.
+        Length of each test set.
+        If `freq` is `None` (default), it represents the number of observations.
+        Otherwise, it represents the number of periods defined by `freq`.
+
+    train_size : int | pandas.offsets.DateOffset | datetime.timedelta
+        Length of each training set.
+        If `freq` is `None` (default), it represents the number of observations.
+        Otherwise, for integers, it represents the number of periods defined by `freq`;
+        for pandas DateOffset or datetime timedelta it represents the date offset
+        applied to the start of each period.
+
+    freq : str | pandas.offsets.DateOffset, optional
+        If provided, it must be a frequency string or a pandas DateOffset, and the
+        returns `X` must be a DataFrame with an index of type `DatetimeIndex`.
+        For a list of pandas frequencies and offsets, see `here <https://pandas.pydata.org/pandas-docs/stable/user_guide/timeseries.html#timeseries-offset-aliases>`_.
+        The defaul (`None`) means `test_size` and `train_size` represent the number of
+        observations.
+
+        Below are some common examples:
+
+            * Rebalancing    : Montly on the first day
+            * Test Duration  : 1 month
+            * Train Duration : 6 months
+
+            >>> cv = WalkForward(test_size=1, train_size=6, freq="MS")
+
+            * Rebalancing    : Quarterly on the first day
+            * Test Duration  : 1 quarter
+            * Train Duration : 2 months
+
+            >>> cv = WalkForward(test_size=1, train_size=pd.DateOffset(months=2), freq="QS")
+
+            * Rebalancing    : Montly on the third Friday
+            * Test Duration  : 1 month
+            * Train Duration : 6 weeks
+
+            >>> cv = WalkForward(test_size=1, train_size=pd.offsets.Week(6), freq= "WOM-3FRI")
+
+            * Rebalancing    : Semi-annually on the last day
+            * Test Duration  : 6 months
+            * Train Duration : 1 year
+
+            >>> cv = WalkForward(test_size=1, train_size=2, freq=pd.offsets.SemiMonthEnd())
+
+            * Rebalancing    : Every 2 months on the second day
+            * Test Duration  : 2 months
+            * Train Duration : 6 months
 
-    train_size : int
-        Number of observations in each training set.
+            >>> cv = WalkForward(test_size=2, train_size=6, freq="MS", freq_offset=dt.timedelta(days=2))
+
+    freq_offset : pandas DateOffset | datetime timedelta, optional
+        Only used if `freq` is provided. Offsets the `freq` by a pandas DateOffset or a
+        datetime timedelta offset.
+
+    previous : bool, default=False
+        Only used if `freq` is provided. If set to `True`, and if the period start
+        or period end is not in the `DatetimeIndex`, the previous observation is used;
+        otherwise, the next observation is used (default).
 
     expend_train : bool, default=False
-        If this is set to True, each subsequent training set after the first one will
+        If set to `True`, each subsequent training set after the first one will
         use all past observations.
-        The default is `False`
+        The default is `False`.
 
     reduce_test : bool, default=False
-        If this is set to True, the last train/test split will be returned even if the
-        test set is partial (if it contains less observations than `test_size`),
-        otherwise it will be ignored.
-        The default is `False`
+        If set to `True`, the last train/test split will be returned even if the
+        test set is partial (i.e., it contains fewer observations than `test_size`),
+        otherwise, it will be ignored.
+        The default is `False`.
 
     purged_size : int, default=0
-        Number of observations to exclude from the end of each train set before the
-        test set.
-        The default value is `0`
+        The number of observations to exclude from the end of each training set before
+        the test set.
+        The default value is `0`.
 
     Examples
     --------
@@ -124,13 +183,19 @@ class WalkForward(sks.BaseCrossValidator):
     def __init__(
         self,
         test_size: int,
-        train_size: int,
+        train_size: int | pd.offsets.BaseOffset | dt.timedelta,
+        freq: str | pd.offsets.BaseOffset | None = None,
+        freq_offset: pd.offsets.BaseOffset | dt.timedelta | None = None,
+        previous: bool = False,
         expend_train: bool = False,
         reduce_test: bool = False,
         purged_size: int = 0,
     ):
         self.test_size = test_size
         self.train_size = train_size
+        self.freq = freq
+        self.freq_offset = freq_offset
+        self.previous = previous
         self.expend_train = expend_train
         self.reduce_test = reduce_test
         self.purged_size = purged_size
@@ -161,40 +226,51 @@ def split(
         """
         X, y = sku.indexable(X, y)
         n_samples = X.shape[0]
-        # Make sure we have enough samples for the given split parameters
-        if self.train_size + self.purged_size >= n_samples:
-            raise ValueError(
-                "The sum of `train_size` with `purged_size` "
-                f"({self.train_size + self.purged_size}) cannot be greater than the"
-                f" number of samples ({n_samples})."
-            )
 
-        indices = np.arange(n_samples)
+        if not isinstance(self.test_size, int):
+            raise ValueError("test_size` must be an integer")
 
-        test_start = self.train_size + self.purged_size
-        while True:
-            if test_start >= n_samples:
-                return
-            test_end = test_start + self.test_size
-            train_end = test_start - self.purged_size
-            if self.expend_train:
-                train_start = 0
-            else:
-                train_start = train_end - self.train_size
-
-            if test_end > n_samples:
-                if not self.reduce_test:
-                    return
-                yield (
-                    indices[train_start:train_end],
-                    indices[test_start:],
-                )
-            else:
-                yield (
-                    indices[train_start:train_end],
-                    indices[test_start:test_end],
-                )
-            test_start = test_end
+        if self.freq is None:
+            if not isinstance(self.train_size, int):
+                raise ValueError("When `freq` is None, `train_size` must be an integer")
+            return _split_without_period(
+                n_samples=n_samples,
+                train_size=self.train_size,
+                test_size=self.test_size,
+                purged_size=self.purged_size,
+                expend_train=self.expend_train,
+                reduce_test=self.reduce_test,
+            )
+
+        if not hasattr(X, "index") or not isinstance(X.index, pd.DatetimeIndex):
+            raise ValueError(
+                "X must be a DataFrame with an index of type DatetimeIndex"
+            )
+        if isinstance(self.train_size, int):
+            return _split_from_period_without_train_offset(
+                n_samples=n_samples,
+                train_size=self.train_size,
+                test_size=self.test_size,
+                freq=self.freq,
+                freq_offset=self.freq_offset,
+                previous=self.previous,
+                purged_size=self.purged_size,
+                expend_train=self.expend_train,
+                reduce_test=self.reduce_test,
+                ts_index=X.index,
+            )
+        return _split_from_period_with_train_offset(
+            n_samples=n_samples,
+            train_size=self.train_size,
+            test_size=self.test_size,
+            freq=self.freq,
+            freq_offset=self.freq_offset,
+            previous=self.previous,
+            purged_size=self.purged_size,
+            expend_train=self.expend_train,
+            reduce_test=self.reduce_test,
+            ts_index=X.index,
+        )
 
     def get_n_splits(self, X=None, y=None, groups=None) -> int:
         """Returns the number of splitting iterations in the cross-validator
@@ -224,3 +300,141 @@ def get_n_splits(self, X=None, y=None, groups=None) -> int:
         if self.reduce_test and n % self.test_size != 0:
             return n // self.test_size + 1
         return n // self.test_size
+
+
+def _split_without_period(
+    n_samples: int,
+    train_size: int,
+    test_size: int,
+    purged_size: int,
+    expend_train: bool,
+    reduce_test: bool,
+) -> Iterator[np.ndarray, np.ndarray]:
+    if train_size + purged_size >= n_samples:
+        raise ValueError(
+            "The sum of `train_size` with `purged_size` "
+            f"({train_size + purged_size}) cannot be greater than the"
+            f" number of samples ({n_samples})."
+        )
+
+    indices = np.arange(n_samples)
+
+    test_start = train_size + purged_size
+    while True:
+        if test_start >= n_samples:
+            return
+        test_end = test_start + test_size
+        train_end = test_start - purged_size
+        if expend_train:
+            train_start = 0
+        else:
+            train_start = train_end - train_size
+
+        if test_end > n_samples:
+            if not reduce_test:
+                return
+            test_indices = indices[test_start:]
+        else:
+            test_indices = indices[test_start:test_end]
+        train_indices = indices[train_start:train_end]
+        yield train_indices, test_indices
+
+        test_start = test_end
+
+
+def _split_from_period_without_train_offset(
+    n_samples: int,
+    train_size: int,
+    test_size: int,
+    freq: str,
+    freq_offset: pd.offsets.BaseOffset | dt.timedelta | None,
+    previous: bool,
+    purged_size: int,
+    expend_train: bool,
+    reduce_test: bool,
+    ts_index,
+) -> Iterator[np.ndarray, np.ndarray]:
+    start = ts_index[0]
+    end = ts_index[-1]
+    if freq_offset is not None:
+        start = min(start, start - freq_offset)
+
+    date_range = pd.date_range(start=start, end=end, freq=freq)
+    if freq_offset is not None:
+        date_range += freq_offset
+
+    idx = ts_index.get_indexer(date_range, method="ffill" if previous else "bfill")
+    n = len(idx)
+    i = 0
+    while True:
+        if i + train_size >= n:
+            return
+
+        if i + train_size + test_size >= n:
+            if not reduce_test:
+                return
+            test_indices = np.arange(idx[i + train_size], n_samples)
+
+        else:
+            test_indices = np.arange(
+                idx[i + train_size], idx[i + train_size + test_size]
+            )
+        if expend_train:
+            train_start = 0
+        else:
+            train_start = idx[i]
+        train_indices = np.arange(train_start, idx[i + train_size] - purged_size)
+        yield train_indices, test_indices
+
+        i += test_size
+
+
+def _split_from_period_with_train_offset(
+    n_samples: int,
+    train_size: pd.offsets.BaseOffset | dt.timedelta,
+    test_size: int,
+    freq: str,
+    freq_offset: pd.offsets.BaseOffset | dt.timedelta | None,
+    previous: bool,
+    purged_size: int,
+    expend_train: bool,
+    reduce_test: bool,
+    ts_index,
+) -> Iterator[np.ndarray, np.ndarray]:
+    start = ts_index[0]
+    end = ts_index[-1]
+    if freq_offset is not None:
+        start = min(start, start - freq_offset)
+
+    date_range = pd.date_range(start=start, end=end, freq=freq)
+    if freq_offset is not None:
+        date_range += freq_offset
+
+    idx = ts_index.get_indexer(date_range, method="ffill" if previous else "bfill")
+    train_idx = ts_index.get_indexer(date_range - train_size, method="ffill")
+
+    n = len(idx)
+
+    if np.all(train_idx == -1):
+        return
+
+    i = np.argmax(train_idx > -1)
+    while True:
+        if i >= n:
+            return
+
+        if i + test_size >= n:
+            if not reduce_test:
+                return
+            test_indices = np.arange(idx[i], n_samples)
+        else:
+            test_indices = np.arange(idx[i], idx[i + test_size] - purged_size)
+
+        if expend_train:
+            train_start = 0
+        else:
+            train_start = train_idx[i]
+        train_indices = np.arange(train_start, idx[i])
+        yield train_indices, test_indices
+
+        i += test_size
diff --git a/src/skfolio/population/_population.py b/src/skfolio/population/_population.py
index e2260dcd..c1c9b552 100644
--- a/src/skfolio/population/_population.py
+++ b/src/skfolio/population/_population.py
@@ -16,9 +16,10 @@
 import scipy.interpolate as sci
 
 import skfolio.typing as skt
+from skfolio.measures import RatioMeasure
 from skfolio.portfolio import BasePortfolio, MultiPeriodPortfolio
 from skfolio.utils.sorting import non_denominated_sort
-from skfolio.utils.tools import deduplicate_names
+from skfolio.utils.tools import deduplicate_names, optimal_rounding_decimals
 
 
 class Population(list):
@@ -201,94 +202,62 @@ def filter(
     def measures(
         self,
         measure: skt.Measure,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> np.ndarray:
         """Vector of portfolios measures for each portfolio from the
-        population filtered by names and tags.
+        population.
 
         Parameters
         ----------
         measure : Measure
             The portfolio measure.
 
-        names :  str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         values : ndarray
             The vector of portfolios measures.
         """
-        population = self.filter(names=names, tags=tags)
-        return np.array([ptf.__getattribute__(measure.value) for ptf in population])
+        return np.array([ptf.__getattribute__(measure.value) for ptf in self])
 
     def measures_mean(
         self,
         measure: skt.Measure,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> float:
         """Mean of portfolios measures for each portfolio from the
-        population filtered by names and tags.
+        population.
 
         Parameters
         ----------
         measure : Measure
             The portfolio measure.
 
-        names :  str | list[str], optional
-           If provided, the population is filtered by portfolio names.
-
-        tags :  str | list[str], optional
-           If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         value : float
             The mean of portfolios measures.
         """
-        return self.measures(measure=measure, names=names, tags=tags).mean()
+        return self.measures(measure=measure).mean()
 
     def measures_std(
         self,
         measure: skt.Measure,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> float:
         """Standard-deviation of portfolios measures for each portfolio from the
-        population filtered by names and tags.
+        population.
 
         Parameters
         ----------
         measure : Measure
             The portfolio measure.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         value : float
             The standard-deviation of portfolios measures.
         """
-        return self.measures(measure=measure, names=names, tags=tags).std()
+        return self.measures(measure=measure).std()
 
-    def sort_measure(
-        self,
-        measure: skt.Measure,
-        reverse: bool = False,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
-    ) -> "Population":
-        """Sort the population by a given portfolio measure and filter the portfolios
-        by names and tags.
+    def sort_measure(self, measure: skt.Measure, reverse: bool = False) -> "Population":
+        """Sort the population by a given portfolio measure.
 
         Parameters
         ----------
@@ -298,21 +267,14 @@ def sort_measure(
         reverse : bool, default=False
             If this is set to True, the order is reversed.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         values : Populations
             The sorted population.
         """
-        population = self.filter(names=names, tags=tags)
         return self.__class__(
             sorted(
-                population,
+                self,
                 key=lambda x: x.__getattribute__(measure.value),
                 reverse=reverse,
             )
@@ -322,8 +284,6 @@ def quantile(
         self,
         measure: skt.Measure,
         q: float,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> BasePortfolio:
         """Returns the portfolio corresponding to the `q` quantile for a given portfolio
         measure.
@@ -336,12 +296,6 @@ def quantile(
         q : float
             The quantile value.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         values : BasePortfolio
@@ -349,17 +303,13 @@ def quantile(
         """
         if not 0 <= q <= 1:
             raise ValueError("The quantile`q` must be between 0 and 1")
-        sorted_portfolios = self.sort_measure(
-            measure=measure, reverse=False, names=names, tags=tags
-        )
+        sorted_portfolios = self.sort_measure(measure=measure, reverse=False)
         k = max(0, int(np.round(len(sorted_portfolios) * q)) - 1)
         return sorted_portfolios[k]
 
     def min_measure(
         self,
         measure: skt.Measure,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> BasePortfolio:
         """Returns the portfolio with the minimum measure.
 
@@ -368,24 +318,16 @@ def min_measure(
         measure : Measure
             The portfolio measure.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         values : BasePortfolio
             The portfolio with minimum measure.
         """
-        return self.quantile(measure=measure, q=0, names=names, tags=tags)
+        return self.quantile(measure=measure, q=0)
 
     def max_measure(
         self,
         measure: skt.Measure,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> BasePortfolio:
         """Returns the portfolio with the maximum measure.
 
@@ -394,24 +336,16 @@ def max_measure(
         measure: Measure
             The portfolio measure.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         values : BasePortfolio
             The portfolio with maximum measure.
         """
-        return self.quantile(measure=measure, q=1, names=names, tags=tags)
+        return self.quantile(measure=measure, q=1)
 
     def summary(
         self,
         formatted: bool = True,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> pd.DataFrame:
         """Summary of the portfolios in the population
 
@@ -422,69 +356,135 @@ def summary(
             units.
             The default is `True`.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         summary : pandas DataFrame
             The population's portfolios summary
         """
 
-        portfolios = self.filter(names=names, tags=tags)
         df = pd.concat(
-            [p.summary(formatted=formatted) for p in portfolios],
-            keys=[p.name for p in portfolios],
+            [p.summary(formatted=formatted) for p in self],
+            keys=[p.name for p in self],
             axis=1,
         )
         return df
 
     def composition(
         self,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
         display_sub_ptf_name: bool = True,
     ) -> pd.DataFrame:
-        """Composition of the portfolios in the population.
+        """Composition of each portfolio in the population.
 
         Parameters
         ----------
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         display_sub_ptf_name : bool, default=True
             If this is set to True, each sub-portfolio name composing a multi-period
             portfolio is displayed.
 
         Returns
         -------
-        summary : DataFrame
+        df : DataFrame
             Composition of the portfolios in the population.
         """
-        portfolios = self.filter(names=names, tags=tags)
-        comp_list = []
-        for p in portfolios:
-            comp = p.composition
+        res = []
+        for ptf in self:
+            comp = ptf.composition
             if display_sub_ptf_name:
-                if isinstance(p, MultiPeriodPortfolio):
+                if isinstance(ptf, MultiPeriodPortfolio):
                     comp.rename(
-                        columns={c: f"{p.name}_{c}" for c in comp.columns}, inplace=True
+                        columns={c: f"{ptf.name}_{c}" for c in comp.columns},
+                        inplace=True,
+                    )
+            else:
+                comp.rename(columns={c: ptf.name for c in comp.columns}, inplace=True)
+            res.append(comp)
+
+        df = pd.concat(res, axis=1)
+        df.columns = deduplicate_names(list(df.columns))
+        df.fillna(0, inplace=True)
+        return df
+
+    def contribution(
+        self,
+        measure: skt.Measure,
+        spacing: float | None = None,
+        display_sub_ptf_name: bool = True,
+    ) -> pd.DataFrame:
+        """Contribution of each asset to a given measure of each portfolio in the
+        population.
+
+        Parameters
+        ----------
+        measure : Measure
+            The measure used for the contribution computation.
+
+        spacing : float, optional
+            Spacing "h" of the finite difference:
+            :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
+
+        display_sub_ptf_name : bool, default=True
+            If this is set to True, each sub-portfolio name composing a multi-period
+            portfolio is displayed.
+
+        Returns
+        -------
+        df : DataFrame
+            Contribution of each asset to a given measure of each portfolio in the
+            population.
+        """
+        res = []
+        for ptf in self:
+            contribution = ptf.contribution(
+                measure=measure, spacing=spacing, to_df=True
+            )
+            if display_sub_ptf_name:
+                if isinstance(ptf, MultiPeriodPortfolio):
+                    contribution.rename(
+                        columns={c: f"{ptf.name}_{c}" for c in contribution.columns},
+                        inplace=True,
                     )
             else:
-                comp.rename(columns={c: p.name for c in comp.columns}, inplace=True)
-            comp_list.append(comp)
+                contribution.rename(
+                    columns={c: ptf.name for c in contribution.columns}, inplace=True
+                )
+            res.append(contribution)
 
-        df = pd.concat(comp_list, axis=1)
+        df = pd.concat(res, axis=1)
         df.columns = deduplicate_names(list(df.columns))
         df.fillna(0, inplace=True)
         return df
 
+    def rolling_measure(
+        self, measure: skt.Measure = RatioMeasure.SHARPE_RATIO, window: int = 30
+    ) -> pd.DataFrame:
+        """Compute the measure over a rolling window for each portfolio in the
+         population.
+
+        Parameters
+        ----------
+        measure : ct.Measure, default=RatioMeasure.SHARPE_RATIO
+            The measure. The default measure is the Sharpe Ratio.
+
+        window : int, default=30
+            The window size. The default value is `30` observations.
+
+        Returns
+        -------
+        dataframe : pandas DataFrame
+            The rolling measures.
+        """
+
+        rolling_measures = []
+        names = []
+        for ptf in self:
+            rolling_measures.append(ptf.rolling_measure(measure=measure, window=window))
+            names.append(_ptf_name_with_tag(ptf))
+        df = pd.concat(rolling_measures, axis=1)
+        df.columns = deduplicate_names(names)
+        # Sort index because pd.concat unsort NaNs at the end
+        df.sort_index(inplace=True)
+        return df
+
     def plot_distribution(
         self,
         measure_list: list[skt.Measure],
@@ -518,7 +518,7 @@ def plot_distribution(
         for measure in measure_list:
             if tag_list is not None:
                 for tag in tag_list:
-                    values.append(self.measures(measure=measure, tags=tag))
+                    values.append(self.filter(tags=tag).measures(measure=measure))
                     labels.append(f"{measure} - {tag}")
             else:
                 values.append(self.measures(measure=measure))
@@ -542,8 +542,6 @@ def plot_cumulative_returns(
         self,
         log_scale: bool = False,
         idx: slice | np.ndarray | None = None,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
     ) -> go.Figure:
         """Plot the population's portfolios cumulative returns.
         Non-compounded cumulative returns start at 0.
@@ -560,12 +558,6 @@ def plot_cumulative_returns(
             Indexes or slice of the observations to plot.
             The default (`None`) is to take all observations.
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         plot : Figure
@@ -573,16 +565,13 @@ def plot_cumulative_returns(
         """
         if idx is None:
             idx = slice(None)
-        portfolios = self.filter(names=names, tags=tags)
-        if not portfolios:
-            raise ValueError("No portfolio found")
 
         cumulative_returns = []
         names = []
         compounded = []
-        for ptf in portfolios:
+        for ptf in self:
             cumulative_returns.append(ptf.cumulative_returns_df)
-            names.append(f"{ptf.name}_{ptf.tag}" if ptf.tag is not None else ptf.name)
+            names.append(_ptf_name_with_tag(ptf))
             compounded.append(ptf.compounded)
         compounded = set(compounded)
 
@@ -631,22 +620,11 @@ def plot_cumulative_returns(
             fig.update_yaxes(type="log")
         return fig
 
-    def plot_composition(
-        self,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
-        display_sub_ptf_name: bool = True,
-    ) -> go.Figure:
+    def plot_composition(self, display_sub_ptf_name: bool = True) -> go.Figure:
         """Plot the compositions of the portfolios in the population.
 
         Parameters
         ----------
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         display_sub_ptf_name : bool, default=True
             If this is set to True, each sub-portfolio name composing a multi-period
             portfolio is displayed.
@@ -656,15 +634,11 @@ def plot_composition(
         plot : Figure
             Returns the plotly Figure object.
         """
-        df = self.composition(
-            names=names, tags=tags, display_sub_ptf_name=display_sub_ptf_name
-        ).T
+        df = self.composition(display_sub_ptf_name=display_sub_ptf_name).T
         fig = px.bar(df, x=df.index, y=df.columns)
         fig.update_layout(
             title="Portfolios Composition",
-            xaxis={
-                "title": "Portfolios",
-            },
+            xaxis_title="Portfolios",
             yaxis={
                 "title": "Weight",
                 "tickformat": ",.0%",
@@ -673,6 +647,53 @@ def plot_composition(
         )
         return fig
 
+    def plot_contribution(
+        self,
+        measure: skt.Measure,
+        spacing: float | None = None,
+        display_sub_ptf_name: bool = True,
+    ) -> go.Figure:
+        """Plot the contribution of each asset to a given measure of the portfolios
+        in the population.
+
+        Parameters
+        ----------
+        measure : Measure
+            The measure used for the contribution computation.
+
+        spacing : float, optional
+            Spacing "h" of the finite difference:
+            :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
+
+        display_sub_ptf_name : bool, default=True
+            If this is set to True, each sub-portfolio name composing a multi-period
+            portfolio is displayed.
+
+        Returns
+        -------
+        plot : Figure
+            Returns the plotly Figure object.
+        """
+        df = self.contribution(
+            display_sub_ptf_name=display_sub_ptf_name, measure=measure, spacing=spacing
+        ).T
+        fig = px.bar(df, x=df.index, y=df.columns)
+
+        yaxis = {
+            "title": "Contribution",
+        }
+        if not measure.is_ratio:
+            n = optimal_rounding_decimals(df.sum(axis=1).max())
+            yaxis["tickformat"] = f",.{n}%"
+
+        fig.update_layout(
+            title=f"{measure} Contribution",
+            xaxis_title="Portfolios",
+            yaxis=yaxis,
+            legend=dict(yanchor="top", y=0.99, xanchor="left", x=1.15),
+        )
+        return fig
+
     def plot_measures(
         self,
         x: skt.Measure,
@@ -682,8 +703,6 @@ def plot_measures(
         hover_measures: list[skt.Measure] | None = None,
         show_fronts: bool = False,
         color_scale: skt.Measure | str | None = None,
-        names: skt.Names | None = None,
-        tags: skt.Tags | None = None,
         title="Portfolios",
     ) -> go.Figure:
         """Plot the 2D (or 3D) scatter points (or surface) of a given set of
@@ -716,18 +735,11 @@ def plot_measures(
         title : str, default="Portfolios"
             The graph title. The default value is "Portfolios".
 
-        names : str | list[str], optional
-            If provided, the population is filtered by portfolio names.
-
-        tags : str | list[str], optional
-            If provided, the population is filtered by portfolio tags.
-
         Returns
         -------
         plot : Figure
             Returns the plotly Figure object.
         """
-        portfolios = self.filter(names=names, tags=tags)
         num_fmt = ":.3f"
         hover_data = {x: num_fmt, y: num_fmt, "tag": True}
 
@@ -749,7 +761,7 @@ def plot_measures(
         col_values = [e.value if isinstance(e, skt.Measure) else e for e in columns]
         res = [
             [portfolio.__getattribute__(attr) for attr in col_values]
-            for portfolio in portfolios
+            for portfolio in self
         ]
         # Improved formatting
         columns = [str(e) for e in columns]
@@ -760,8 +772,6 @@ def plot_measures(
 
         if show_fronts:
             fronts = self.non_denominated_sort(first_front_only=False)
-            if tags is not None:
-                ValueError("Cannot plot front with tags selected")
             df["front"] = str(-1)
             for i, front in enumerate(fronts):
                 for idx in front:
@@ -884,3 +894,56 @@ def plot_measures(
                 legend=dict(yanchor="top", y=0.96, xanchor="left", x=1.25),
             )
         return fig
+
+    def plot_rolling_measure(
+        self,
+        measure: skt.Measure = RatioMeasure.SHARPE_RATIO,
+        window: int = 30,
+    ) -> go.Figure:
+        """Plot the measure over a rolling window for each portfolio in the population.
+
+        Parameters
+        ----------
+        measure : ct.Measure, default = RatioMeasure.SHARPE_RATIO
+           The measure.
+
+        window : int, default=30
+           The window size.
+
+        Returns
+        -------
+        plot : Figure
+            Returns the plot Figure object
+        """
+        df = self.rolling_measure(measure=measure, window=window)
+        fig = df.plot(backend="plotly")
+        max_val = np.max(df)
+        min_val = np.min(df)
+        if max_val > 0 > min_val:
+            fig.add_hrect(
+                y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
+            )
+            fig.add_hrect(
+                y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
+            )
+
+        yaxis = {
+            "title": str(measure),
+        }
+        if not measure.is_ratio:
+            n = optimal_rounding_decimals(max_val)
+            yaxis["tickformat"] = f",.{n}%"
+
+        fig.update_layout(
+            title=f"Rolling {measure} - {window} observations window",
+            xaxis_title="Observations",
+            yaxis=yaxis,
+            showlegend=False,
+        )
+        return fig
+
+
+def _ptf_name_with_tag(portfolio: BasePortfolio) -> str:
+    if portfolio.tag is None:
+        return portfolio.name
+    return f"{portfolio.name}_{portfolio.tag}"
diff --git a/src/skfolio/portfolio/_base.py b/src/skfolio/portfolio/_base.py
index 2ade8696..a088cdaf 100644
--- a/src/skfolio/portfolio/_base.py
+++ b/src/skfolio/portfolio/_base.py
@@ -59,6 +59,7 @@
     args_names,
     cached_property_slots,
     format_measure,
+    optimal_rounding_decimals,
 )
 
 _ZERO_THRESHOLD = 1e-5
@@ -614,6 +615,13 @@ def composition(self) -> pd.DataFrame:
         """DataFrame of the Portfolio composition"""
         pass
 
+    @abstractmethod
+    def contribution(
+        self, measure: skt.Measure, spacing: float | None = None, to_df: bool = True
+    ) -> np.ndarray | pd.DataFrame:
+        """Compute the contribution of each asset to a given measure"""
+        pass
+
     # Custom attribute setter and getter
     @property
     def fitness_measures(self) -> list[skt.Measure]:
@@ -811,7 +819,7 @@ def rolling_measure(
             The measure. The default measure is the Sharpe Ratio.
 
         window : int, default=30
-            The window size. The default value is `30`.
+            The window size. The default value is `30` observations.
 
         Returns
         -------
@@ -925,11 +933,9 @@ def summary(self, formatted: bool = True) -> pd.Series:
                 key = f"{e!s} at {beta:.0%}"
             except AttributeError:
                 key = str(e)
-            if isinstance(e, RatioMeasure) or e in [
+            if e.is_ratio or e in [
                 ExtraRiskMeasure.ENTROPIC_RISK_MEASURE,
                 RiskMeasure.ULCER_INDEX,
-                ExtraRiskMeasure.SKEW,
-                ExtraRiskMeasure.KURTOSIS,
             ]:
                 percent = False
             else:
@@ -1053,19 +1059,18 @@ def plot_rolling_measure(
             line_dash="dash",
             line_color="blue",
         )
-        max_val = rolling.max()
-        min_val = rolling.min()
-        if max_val > 0:
+        max_val = np.max(rolling)
+        min_val = np.min(rolling)
+        if max_val > 0 > min_val:
             fig.add_hrect(
                 y0=0, y1=max_val * 1.3, line_width=0, fillcolor="green", opacity=0.1
             )
-        if min_val < 0:
             fig.add_hrect(
                 y0=min_val * 1.3, y1=0, line_width=0, fillcolor="red", opacity=0.1
             )
 
         fig.update_layout(
-            title=f"rolling {measure} - {window} observations window",
+            title=f"Rolling {measure} - {window} observations window",
             xaxis_title="Observations",
             yaxis_title=str(measure),
             showlegend=False,
@@ -1089,3 +1094,37 @@ def plot_composition(self) -> go.Figure:
             legend_title_text="Assets",
         )
         return fig
+
+    def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
+        r"""Plot the contribution of each asset to a given measure.
+
+        Parameters
+        ----------
+        measure : Measure
+            The measure used for the contribution computation.
+
+        spacing : float, optional
+            Spacing "h" of the finite difference:
+            :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
+
+        Returns
+        -------
+        plot : Figure
+            The plotly Figure of assets contribution to the measure.
+        """
+        df = self.contribution(measure=measure, spacing=spacing, to_df=True).T
+        fig = px.bar(df, x=df.index, y=df.columns)
+        yaxis = {
+            "title": "Contribution",
+        }
+        if not measure.is_ratio:
+            n = optimal_rounding_decimals(df.sum(axis=1).max())
+            yaxis["tickformat"] = f",.{n}%"
+
+        fig.update_layout(
+            title=f"{measure} Contribution",
+            xaxis_title="Portfolio",
+            yaxis=yaxis,
+            legend_title_text="Assets",
+        )
+        return fig
diff --git a/src/skfolio/portfolio/_multi_period_portfolio.py b/src/skfolio/portfolio/_multi_period_portfolio.py
index 90b6eaa1..95b30db2 100644
--- a/src/skfolio/portfolio/_multi_period_portfolio.py
+++ b/src/skfolio/portfolio/_multi_period_portfolio.py
@@ -556,6 +556,51 @@ def composition(self) -> pd.DataFrame:
         df.columns = deduplicate_names(df.columns)
         return df
 
+    @property
+    def weights_per_observation(self) -> pd.DataFrame:
+        """DataFrame of the Portfolio weights per observation."""
+        return (
+            pd.concat([p.weights_per_observation for p in self], axis=0)
+            .fillna(0)
+            .sort_index()
+        )
+
+    def contribution(
+        self, measure: skt.Measure, spacing: float | None = None, to_df: bool = True
+    ) -> np.ndarray | pd.DataFrame:
+        r"""Compute the contribution of each asset to a given measure for each
+        portfolio.
+
+        Parameters
+        ----------
+        measure : Measure
+            The measure used for the contribution computation.
+
+        spacing : float, optional
+            Spacing "h" of the finite difference:
+            :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
+
+        to_df : bool, default=False
+            If this is set to True, a DataFrame with asset names in index and portfolio
+            names in columns is returned, otherwise a list of numpy array is returned.
+            When a DataFrame is returned, the assets with zero weights are removed.
+
+        Returns
+        -------
+        values : list of numpy array of shape (n_assets,) for each portfolio or a DataFrame
+            The measure contribution of each asset for each portfolio.
+        """
+        contributions = [
+            ptf.contribution(measure=measure, spacing=spacing, to_df=to_df)
+            for ptf in self
+        ]
+        if not to_df:
+            return contributions
+        df = pd.concat(contributions, axis=1)
+        df.fillna(0, inplace=True)
+        df.columns = deduplicate_names(df.columns)
+        return df
+
     def summary(self, formatted: bool = True) -> pd.Series:
         """Portfolio summary of all its measures.
 
diff --git a/src/skfolio/portfolio/_portfolio.py b/src/skfolio/portfolio/_portfolio.py
index a8275b9a..a1d9b081 100644
--- a/src/skfolio/portfolio/_portfolio.py
+++ b/src/skfolio/portfolio/_portfolio.py
@@ -14,7 +14,6 @@
 import numpy as np
 import numpy.typing as npt
 import pandas as pd
-import plotly.express as px
 
 import skfolio.typing as skt
 from skfolio.measures import RiskMeasure, effective_number_assets
@@ -668,7 +667,20 @@ def composition(self) -> pd.DataFrame:
         return df
 
     @property
-    def diversification(self):
+    def weights_per_observation(self) -> pd.DataFrame:
+        """DataFrame of the Portfolio weights per observation."""
+        idx = self.nonzero_assets_index
+        weights = self.weights[idx]
+        assets = self.assets[idx]
+        df = pd.DataFrame(
+            np.ones((len(self.observations), len(assets))) * weights,
+            index=self.observations,
+            columns=assets,
+        )
+        return df
+
+    @property
+    def diversification(self) -> float:
         """Weighted average of volatility divided by the portfolio volatility."""
         return (
             self.weights @ np.std(np.asarray(self.X), axis=0) / self.standard_deviation
@@ -750,8 +762,8 @@ def variance_from_assets(self, assets_covariance: np.ndarray) -> float:
         return float(self.weights @ assets_covariance @ self.weights.T)
 
     def contribution(
-        self, measure: skt.Measure, spacing: float | None = None
-    ) -> np.ndarray:
+        self, measure: skt.Measure, spacing: float | None = None, to_df: bool = False
+    ) -> np.ndarray | pd.DataFrame:
         r"""Compute the contribution of each asset to a given measure.
 
         Parameters
@@ -763,9 +775,15 @@ def contribution(
             Spacing "h" of the finite difference:
             :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
 
+        to_df : bool, default=False
+            If set to True, a DataFrame with asset names in index is returned,
+            otherwise a numpy array is returned. When a DataFrame is returned, the
+            values are sorted in descending order and assets with zero weights are
+            removed.
+
         Returns
         -------
-        values : ndrray of shape (n_assets,)
+        values : numpy array of shape (n_assets,) or DataFrame
             The measure contribution of each asset.
         """
         if spacing is None:
@@ -778,49 +796,26 @@ def contribution(
                 spacing = 1e-1
             else:
                 spacing = 1e-5
-        args = {arg: getattr(self, arg) for arg in args_names(self.__init__)}
-
-        def get_risk(i: int, h: float) -> float:
-            a = args.copy()
-            w = a["weights"].copy()
-            w[i] += h
-            a["weights"] = w
-            return getattr(Portfolio(**a), measure.value)
-
-        cont = [
-            (get_risk(i, h=spacing) - get_risk(i, h=-spacing))
-            / (2 * spacing)
-            * self.weights[i]
-            for i in range(len(self.weights))
-        ]
-        return np.array(cont)
-
-    def plot_contribution(self, measure: skt.Measure, spacing: float | None = None):
-        r"""Plot the contribution of each asset to a given measure.
-
-        Parameters
-        ----------
-        measure : Measure
-            The measure used for the contribution computation.
-
-        spacing : float, optional
-            Spacing "h" of the finite difference:
-            :math:`contribution(wi)= \frac{measure(wi-h) - measure(wi+h)}{2h}`
+        args = {
+            arg: getattr(self, arg)
+            for arg in args_names(self.__init__)
+            if arg != "weights"
+        }
 
-        Returns
-        -------
-        plot : Figure
-            The plotly Figure of assets contribution to the measure.
-        """
-        cont = self.contribution(measure=measure, spacing=spacing)
-        df = pd.DataFrame(cont, index=self.assets, columns=["contribution"])
-        fig = px.bar(df, x=df.index, y=df.columns)
-        fig.update_layout(
-            title=f"{measure} contribution",
-            xaxis_title="Asset",
-            yaxis_title=f"{measure} contribution",
+        contribution, assets = _compute_contribution(
+            args=args,
+            weights=self.weights,
+            assets=self.assets,
+            measure=measure,
+            h=spacing,
+            drop_zero_weights=to_df,
         )
-        return fig
+
+        if not to_df:
+            return np.array(contribution)
+        df = pd.DataFrame(contribution, index=assets, columns=[self.name])
+        df.sort_values(by=self.name, ascending=False, inplace=True)
+        return df
 
     def summary(self, formatted: bool = True) -> pd.Series:
         """Portfolio summary of all its measures.
@@ -863,3 +858,44 @@ def get_weight(self, asset: str) -> float:
             return self.weights[np.where(self.assets == asset)[0][0]]
         except IndexError:
             raise IndexError("{asset} is not a valid asset name.") from None
+
+
+def _get_risk(
+    args: dict, weights: np.ndarray, measure: skt.Measure, i: int, h: float
+) -> float:
+    """Get the Portfolio risk measure when the weight of asset `i` is increased by `h`."""
+    assert "weights" not in args
+    weights = weights.copy()
+    weights[i] += h
+    return getattr(Portfolio(weights=weights, **args), measure.value)
+
+
+def _compute_contribution(
+    args: dict,
+    weights: np.ndarray,
+    assets: np.ndarray,
+    measure: skt.Measure,
+    h: float,
+    drop_zero_weights: bool,
+) -> tuple[list[float], list[str]]:
+    """Compute the contribution of each asset to a given measure using finite
+    difference.
+    """
+    contributions = []
+    _assets = []
+    for i, (weight, asset) in enumerate(zip(weights, assets, strict=True)):
+        if weight == 0:
+            if not drop_zero_weights:
+                _assets.append(asset)
+                contributions.append(0)
+        else:
+            _assets.append(asset)
+            contributions.append(
+                (
+                    _get_risk(args, weights, measure, i, h)
+                    - _get_risk(args, weights, measure, i, -h)
+                )
+                / (2 * h)
+                * weight
+            )
+    return contributions, _assets
diff --git a/src/skfolio/utils/tools.py b/src/skfolio/utils/tools.py
index 24da9cc8..1600e3be 100644
--- a/src/skfolio/utils/tools.py
+++ b/src/skfolio/utils/tools.py
@@ -26,6 +26,7 @@
     "input_to_array",
     "args_names",
     "format_measure",
+    "optimal_rounding_decimals",
     "bisection",
     "safe_split",
     "fit_single_estimator",
@@ -465,10 +466,26 @@ def format_measure(x: float, percent: bool = False) -> str:
     if xn == 0:
         n = 0
     else:
-        n = min(6, max(int(-np.log10(abs(xn))) + 2, 2))
+        n = optimal_rounding_decimals(xn)
     return "{value:{fmt}}".format(value=x, fmt=f".{n}{f}")
 
 
+def optimal_rounding_decimals(x: float) -> int:
+    """Return the optimal rounding decimal number for a user-friendly formatting.
+
+    Parameters
+    ----------
+    x : float
+        Number to round.
+
+    Returns
+    -------
+    n : int
+        Rounding decimal number.
+    """
+    return min(6, max(int(-np.log10(abs(x))) + 2, 2))
+
+
 def bisection(x: list[np.ndarray]) -> Iterator[list[np.ndarray, np.ndarray]]:
     """Generator to bisect a list of array.
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index 5b0f2290..89e88e87 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -50,7 +50,8 @@ def y_medium(y):
 
 @pytest.fixture(scope="module")
 def X_small(X):
-    return X["2022":]
+    X_small = X["2022":]
+    return X_small
 
 
 @pytest.fixture(scope="module")
diff --git a/tests/test_model_selection/test_walk_forward.py b/tests/test_model_selection/test_walk_forward.py
index c268d716..350a5e74 100644
--- a/tests/test_model_selection/test_walk_forward.py
+++ b/tests/test_model_selection/test_walk_forward.py
@@ -1,4 +1,8 @@
+import datetime as dt
+
 import numpy as np
+import pandas as pd
+import pytest
 
 from skfolio.model_selection import WalkForward
 
@@ -9,7 +13,426 @@ def assert_split_equal(split, res):
         assert np.array_equal(test, res[i][1])
 
 
-def test_walk_forward():
+def assert_split_equal_dates(index, split, res):
+    for i, (train, test) in enumerate(split):
+        assert index[train[0]].date() == res[i][0][0]
+        assert index[train[-1]].date() == res[i][0][1]
+        assert index[test[0]].date() == res[i][1][0]
+        assert index[test[-1]].date() == res[i][1][1]
+
+
+def _generate(split, index):
+    res = []
+    for _, (train, test) in enumerate(split):
+        res.append(
+            (
+                (index[train[0]].date(), index[train[-1]].date()),
+                (index[test[0]].date(), index[test[-1]].date()),
+            )
+        )
+    return res
+
+
+@pytest.mark.parametrize(
+    "test_size,train_size,freq,freq_offset,previous,reduce_test,expend_train,purged_size,expected",
+    [
+        (
+            2,
+            3,
+            "WOM-3FRI",
+            None,
+            True,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 21), dt.date(2022, 4, 13)),
+                    (dt.date(2022, 4, 14), dt.date(2022, 6, 16)),
+                ),
+                (
+                    (dt.date(2022, 3, 18), dt.date(2022, 6, 16)),
+                    (dt.date(2022, 6, 17), dt.date(2022, 8, 18)),
+                ),
+                (
+                    (dt.date(2022, 5, 20), dt.date(2022, 8, 18)),
+                    (dt.date(2022, 8, 19), dt.date(2022, 10, 20)),
+                ),
+                (
+                    (dt.date(2022, 7, 15), dt.date(2022, 10, 20)),
+                    (dt.date(2022, 10, 21), dt.date(2022, 12, 15)),
+                ),
+            ],
+        ),
+        (
+            2,
+            3,
+            "WOM-3FRI",
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 21), dt.date(2022, 4, 14)),
+                    (dt.date(2022, 4, 18), dt.date(2022, 6, 16)),
+                ),
+                (
+                    (dt.date(2022, 3, 18), dt.date(2022, 6, 16)),
+                    (dt.date(2022, 6, 17), dt.date(2022, 8, 18)),
+                ),
+                (
+                    (dt.date(2022, 5, 20), dt.date(2022, 8, 18)),
+                    (dt.date(2022, 8, 19), dt.date(2022, 10, 20)),
+                ),
+                (
+                    (dt.date(2022, 7, 15), dt.date(2022, 10, 20)),
+                    (dt.date(2022, 10, 21), dt.date(2022, 12, 15)),
+                ),
+            ],
+        ),
+        (
+            4,
+            pd.offsets.Week(3),
+            "WOM-3FRI",
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 28), dt.date(2022, 2, 17)),
+                    (dt.date(2022, 2, 18), dt.date(2022, 6, 16)),
+                ),
+                (
+                    (dt.date(2022, 5, 27), dt.date(2022, 6, 16)),
+                    (dt.date(2022, 6, 17), dt.date(2022, 10, 20)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            None,
+            True,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 2, 1), dt.date(2022, 7, 29)),
+                    (dt.date(2022, 8, 1), dt.date(2022, 9, 29)),
+                ),
+                (
+                    (dt.date(2022, 4, 1), dt.date(2022, 9, 29)),
+                    (dt.date(2022, 9, 30), dt.date(2022, 11, 30)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 2, 1), dt.date(2022, 7, 29)),
+                    (dt.date(2022, 8, 1), dt.date(2022, 9, 30)),
+                ),
+                (
+                    (dt.date(2022, 4, 1), dt.date(2022, 9, 30)),
+                    (dt.date(2022, 10, 3), dt.date(2022, 11, 30)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            None,
+            False,
+            False,
+            False,
+            1,
+            [
+                (
+                    (dt.date(2022, 2, 1), dt.date(2022, 7, 28)),
+                    (dt.date(2022, 8, 1), dt.date(2022, 9, 30)),
+                ),
+                (
+                    (dt.date(2022, 4, 1), dt.date(2022, 9, 29)),
+                    (dt.date(2022, 10, 3), dt.date(2022, 11, 30)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            None,
+            False,
+            True,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 2, 1), dt.date(2022, 7, 29)),
+                    (dt.date(2022, 8, 1), dt.date(2022, 9, 30)),
+                ),
+                (
+                    (dt.date(2022, 4, 1), dt.date(2022, 9, 30)),
+                    (dt.date(2022, 10, 3), dt.date(2022, 11, 30)),
+                ),
+                (
+                    (dt.date(2022, 6, 1), dt.date(2022, 11, 30)),
+                    (dt.date(2022, 12, 1), dt.date(2022, 12, 28)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            None,
+            False,
+            False,
+            True,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 3), dt.date(2022, 7, 29)),
+                    (dt.date(2022, 8, 1), dt.date(2022, 9, 30)),
+                ),
+                (
+                    (dt.date(2022, 1, 3), dt.date(2022, 9, 30)),
+                    (dt.date(2022, 10, 3), dt.date(2022, 11, 30)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            dt.timedelta(days=2),
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 3), dt.date(2022, 7, 1)),
+                    (dt.date(2022, 7, 5), dt.date(2022, 9, 2)),
+                ),
+                (
+                    (dt.date(2022, 3, 3), dt.date(2022, 9, 2)),
+                    (dt.date(2022, 9, 6), dt.date(2022, 11, 2)),
+                ),
+            ],
+        ),
+        (
+            2,
+            6,
+            "MS",
+            pd.offsets.BDay(2),
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 4), dt.date(2022, 7, 1)),
+                    (dt.date(2022, 7, 5), dt.date(2022, 9, 2)),
+                ),
+                (
+                    (dt.date(2022, 3, 3), dt.date(2022, 9, 2)),
+                    (dt.date(2022, 9, 6), dt.date(2022, 11, 2)),
+                ),
+            ],
+        ),
+        (
+            1,
+            48,
+            pd.offsets.Week(weekday=4),
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 7), dt.date(2022, 12, 8)),
+                    (dt.date(2022, 12, 9), dt.date(2022, 12, 15)),
+                ),
+                (
+                    (dt.date(2022, 1, 14), dt.date(2022, 12, 15)),
+                    (dt.date(2022, 12, 16), dt.date(2022, 12, 22)),
+                ),
+            ],
+        ),
+        (
+            1,
+            pd.offsets.Week(48),
+            pd.offsets.Week(weekday=4),
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 1, 7), dt.date(2022, 12, 8)),
+                    (dt.date(2022, 12, 9), dt.date(2022, 12, 15)),
+                ),
+                (
+                    (dt.date(2022, 1, 14), dt.date(2022, 12, 15)),
+                    (dt.date(2022, 12, 16), dt.date(2022, 12, 22)),
+                ),
+            ],
+        ),
+        (
+            1,
+            pd.DateOffset(months=1),
+            pd.offsets.QuarterEnd(),
+            None,
+            False,
+            False,
+            False,
+            0,
+            [
+                (
+                    (dt.date(2022, 2, 28), dt.date(2022, 3, 30)),
+                    (dt.date(2022, 3, 31), dt.date(2022, 6, 29)),
+                ),
+                (
+                    (dt.date(2022, 5, 27), dt.date(2022, 6, 29)),
+                    (dt.date(2022, 6, 30), dt.date(2022, 9, 29)),
+                ),
+            ],
+        ),
+        (
+            1,
+            pd.DateOffset(years=1),
+            pd.offsets.QuarterEnd(),
+            None,
+            False,
+            False,
+            False,
+            0,
+            [],
+        ),
+    ],
+)
+def test_walk_forward_with_period(
+    X_small,
+    test_size,
+    train_size,
+    freq,
+    freq_offset,
+    previous,
+    reduce_test,
+    expend_train,
+    purged_size,
+    expected,
+):
+    cv = WalkForward(
+        test_size=test_size,
+        train_size=train_size,
+        freq=freq,
+        freq_offset=freq_offset,
+        previous=previous,
+        reduce_test=reduce_test,
+        expend_train=expend_train,
+        purged_size=purged_size,
+    )
+    assert_split_equal_dates(X_small.index, cv.split(X_small), expected)
+
+
+@pytest.mark.parametrize(
+    "test_size,train_size,freq,previous,expected",
+    [
+        (
+            1,
+            4,
+            "QS",
+            True,
+            [
+                (
+                    (dt.date(2020, 4, 1), dt.date(2021, 3, 31)),
+                    (dt.date(2021, 4, 1), dt.date(2021, 6, 30)),
+                ),
+                (
+                    (dt.date(2020, 7, 1), dt.date(2021, 6, 30)),
+                    (dt.date(2021, 7, 1), dt.date(2021, 9, 30)),
+                ),
+                (
+                    (dt.date(2020, 10, 1), dt.date(2021, 9, 30)),
+                    (dt.date(2021, 10, 1), dt.date(2021, 12, 30)),
+                ),
+                (
+                    (dt.date(2020, 12, 31), dt.date(2021, 12, 30)),
+                    (dt.date(2021, 12, 31), dt.date(2022, 3, 31)),
+                ),
+                (
+                    (dt.date(2021, 4, 1), dt.date(2022, 3, 31)),
+                    (dt.date(2022, 4, 1), dt.date(2022, 6, 30)),
+                ),
+                (
+                    (dt.date(2021, 7, 1), dt.date(2022, 6, 30)),
+                    (dt.date(2022, 7, 1), dt.date(2022, 9, 29)),
+                ),
+            ],
+        ),
+        (
+            1,
+            pd.DateOffset(years=1),
+            "QS",
+            False,
+            [
+                (
+                    (dt.date(2020, 4, 1), dt.date(2021, 3, 31)),
+                    (dt.date(2021, 4, 1), dt.date(2021, 6, 30)),
+                ),
+                (
+                    (dt.date(2020, 7, 1), dt.date(2021, 6, 30)),
+                    (dt.date(2021, 7, 1), dt.date(2021, 9, 30)),
+                ),
+                (
+                    (dt.date(2020, 10, 1), dt.date(2021, 9, 30)),
+                    (dt.date(2021, 10, 1), dt.date(2021, 12, 31)),
+                ),
+                (
+                    (dt.date(2020, 12, 31), dt.date(2021, 12, 31)),
+                    (dt.date(2022, 1, 3), dt.date(2022, 3, 31)),
+                ),
+                (
+                    (dt.date(2021, 4, 1), dt.date(2022, 3, 31)),
+                    (dt.date(2022, 4, 1), dt.date(2022, 6, 30)),
+                ),
+                (
+                    (dt.date(2021, 7, 1), dt.date(2022, 6, 30)),
+                    (dt.date(2022, 7, 1), dt.date(2022, 9, 30)),
+                ),
+            ],
+        ),
+    ],
+)
+def test_walk_forward_with_period_long(
+    X_medium, test_size, train_size, freq, previous, expected
+):
+    cv = WalkForward(
+        test_size=test_size, train_size=train_size, freq=freq, previous=previous
+    )
+    assert_split_equal_dates(X_medium.index, cv.split(X_medium), expected)
+
+
+def test_walk_forward_without_period():
     X = np.random.randn(12, 2)
 
     cv = WalkForward(
diff --git a/tests/test_population/test_population.py b/tests/test_population/test_population.py
index 660d6749..85639bae 100644
--- a/tests/test_population/test_population.py
+++ b/tests/test_population/test_population.py
@@ -45,6 +45,18 @@ def population(X):
     return population
 
 
+@pytest.fixture(scope="function")
+def small_population(X):
+    n_assets = X.shape[1]
+    population = Population(
+        [
+            Portfolio(X=X, weights=rand_weights(n=n_assets, zeros=n_assets - 10))
+            for _ in range(10)
+        ]
+    )
+    return population
+
+
 @pytest.fixture(scope="function")
 def multi_period_portfolio(X):
     # Add the multi period portfolio
@@ -133,11 +145,10 @@ def test_population_multi_period_portfolio(population, multi_period_portfolio):
     assert population.plot_measures(
         x=RiskMeasure.STANDARD_DEVIATION, y=PerfMeasure.MEAN, show_fronts=True
     )
-    assert population.plot_measures(
+    assert population.filter(tags="random").plot_measures(
         x=RiskMeasure.STANDARD_DEVIATION,
         y=PerfMeasure.MEAN,
         hover_measures=[RatioMeasure.SHARPE_RATIO],
-        tags="random",
         title="Portfolios -- with sharpe ration",
     )
 
@@ -172,3 +183,26 @@ def test_population_plot_cumulative_returns(population):
     population.set_portfolio_params(compounded=True)
     assert population[:2].plot_cumulative_returns()
     assert population[:2].plot_cumulative_returns(log_scale=True)
+
+
+def test_population_rolling_measure(small_population):
+    df = small_population.rolling_measure()
+    assert isinstance(df, pd.DataFrame)
+    assert df.shape[1] == len(small_population)
+
+    assert small_population.plot_rolling_measure(
+        measure=RiskMeasure.STANDARD_DEVIATION, window=50
+    )
+
+
+def test_population_filter_chaining(population):
+    res = population.filter(names=["1", "2"]).composition()
+    assert res.shape[1] == 2
+
+
+def test_portfolio_contribution(small_population):
+    contribution = small_population.contribution(measure=RiskMeasure.CVAR)
+    assert isinstance(contribution, pd.DataFrame)
+    assert contribution.shape == (20, 10)
+
+    assert small_population.plot_contribution(measure=RiskMeasure.STANDARD_DEVIATION)
diff --git a/tests/test_portfolio/test_multi_period_portfolio.py b/tests/test_portfolio/test_multi_period_portfolio.py
index f08c39ec..88d35abe 100644
--- a/tests/test_portfolio/test_multi_period_portfolio.py
+++ b/tests/test_portfolio/test_multi_period_portfolio.py
@@ -153,6 +153,12 @@ def portfolio_and_returns(prices, periods, weights):
     return portfolio, returns
 
 
+@pytest.fixture(scope="function")
+def portfolio(portfolio_and_returns):
+    portfolio, _ = portfolio_and_returns
+    return portfolio
+
+
 @pytest.fixture(
     scope="module",
     params=list(PerfMeasure)
@@ -172,8 +178,7 @@ def annualized_factor(request):
     return request.param
 
 
-def test_portfolio_annualized(portfolio_and_returns, annualized_factor):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_annualized(portfolio, annualized_factor):
     if annualized_factor is not None:
         portfolio.annualized_factor = annualized_factor
 
@@ -254,8 +259,8 @@ def test_portfolio_methods(portfolio_and_returns, periods):
     assert isinstance(portfolio.summary(formatted=False), pd.Series)
 
 
-def test_mpp_magic_methods(portfolio_and_returns, periods):
-    mpp, returns = portfolio_and_returns
+def test_mpp_magic_methods(portfolio, periods):
+    mpp = portfolio
     assert mpp[1] == mpp.portfolios[1]
     for i, p in enumerate(mpp):
         assert p.name == f"portfolio_{i}"
@@ -312,15 +317,13 @@ def test_portfolio_dominate(X):
         ) == portfolio_1.dominates(portfolio_2)
 
 
-def test_portfolio_metrics(portfolio_and_returns, measure):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_metrics(portfolio, measure):
     m = getattr(portfolio, measure.value)
     assert isinstance(m, float)
     assert not np.isnan(m)
 
 
-def test_portfolio_slots(portfolio_and_returns):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_slots(portfolio):
     for attr in portfolio._slots():
         if attr[0] == "_":
             try:
@@ -330,8 +333,7 @@ def test_portfolio_slots(portfolio_and_returns):
         getattr(portfolio, attr)
 
 
-def test_portfolio_cache(portfolio_and_returns, periods, measure):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_cache(portfolio, periods, measure):
     # time for accessing cached attributes
     n = int(1e5)
 
@@ -344,8 +346,7 @@ def test_portfolio_cache(portfolio_and_returns, periods, measure):
     assert first_access_time > 10 * cached_access_time
 
 
-def test_portfolio_clear_cache(portfolio_and_returns, periods, measure):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_clear_cache(portfolio, periods, measure):
     if measure.is_ratio:
         r = measure.linked_risk_measure
     else:
@@ -377,8 +378,7 @@ def test_portfolio_clear_cache(portfolio_and_returns, periods, measure):
             assert getattr(portfolio, measure.value) == portfolio.mean / new_m
 
 
-def test_portfolio_read_only(portfolio_and_returns, periods):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_read_only(portfolio, periods):
     for attr in MultiPeriodPortfolio._read_only_attrs:
         try:
             setattr(portfolio, attr, 0)
@@ -387,8 +387,7 @@ def test_portfolio_read_only(portfolio_and_returns, periods):
             assert str(e) == f"can't set attribute '{attr}' because it is read-only"
 
 
-def test_portfolio_delete_attr(portfolio_and_returns, periods):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_delete_attr(portfolio, periods):
     try:
         delattr(portfolio, "dummy")
         raise
@@ -396,8 +395,29 @@ def test_portfolio_delete_attr(portfolio_and_returns, periods):
         assert str(e) == "`MultiPeriodPortfolio` object has no attribute 'dummy'"
 
 
-def test_portfolio_summary(portfolio_and_returns, periods):
-    portfolio, returns = portfolio_and_returns
+def test_portfolio_summary(portfolio, periods):
     df = portfolio.summary(formatted=False)
     assert df.loc["Portfolios Number"] == 3.0
     assert df.loc["Avg nb of Assets per Portfolio"] == 20.0
+
+
+def test_portfolio_contribution(portfolio):
+    contribution = portfolio.contribution(measure=RiskMeasure.CVAR)
+    assert isinstance(contribution, pd.DataFrame)
+    assert contribution.shape == (17, 3)
+
+    contribution = portfolio.contribution(
+        measure=RiskMeasure.STANDARD_DEVIATION, to_df=False
+    )
+    assert isinstance(contribution, list)
+    assert len(contribution) == 3
+    assert contribution[0].shape == (20,)
+
+    assert portfolio.plot_contribution(measure=RiskMeasure.STANDARD_DEVIATION)
+
+
+def test_weights_per_observation(portfolio):
+    df = portfolio.weights_per_observation
+    np.testing.assert_array_equal(df.index.values, portfolio.observations)
+    assert len(df.columns) == 17
+    assert len(set(df.columns)) == 17
diff --git a/tests/test_portfolio/test_portfolio.py b/tests/test_portfolio/test_portfolio.py
index 85242b9c..558518d8 100644
--- a/tests/test_portfolio/test_portfolio.py
+++ b/tests/test_portfolio/test_portfolio.py
@@ -299,13 +299,6 @@ def test_portfolio_dominate(X):
         ) == portfolio_1.dominates(portfolio_2)
 
 
-def test_portfolio_risk_contribution(X, weights):
-    portfolio = Portfolio(X=X, weights=weights, annualized_factor=252)
-    contribution = portfolio.contribution(measure=RiskMeasure.CVAR)
-    # noinspection PyUnresolvedReferences
-    assert contribution.shape == (X.shape[1],)
-
-
 def test_portfolio_metrics(portfolio, measure):
     m = getattr(portfolio, measure.value)
     assert isinstance(m, float)
@@ -460,3 +453,28 @@ def test_portfolio_plot_cumulative_returns(X, weights):
     portfolio.compounded = True
     assert portfolio.plot_cumulative_returns()
     assert portfolio.plot_cumulative_returns(log_scale=True)
+
+
+def test_portfolio_contribution(portfolio):
+    contribution = portfolio.contribution(measure=RiskMeasure.CVAR, to_df=True)
+    assert isinstance(contribution, pd.DataFrame)
+    assert contribution.shape == (10, 1)
+    assert np.isclose(contribution.sum().sum(), portfolio.cvar)
+
+    contribution = portfolio.contribution(measure=RiskMeasure.STANDARD_DEVIATION)
+    assert isinstance(contribution, np.ndarray)
+    assert contribution.shape == (20,)
+
+    assert np.isclose(np.sum(contribution), portfolio.standard_deviation)
+
+    assert portfolio.plot_contribution(measure=RiskMeasure.STANDARD_DEVIATION)
+
+
+def test_weights_per_observation(portfolio):
+    df = portfolio.weights_per_observation
+    np.testing.assert_array_equal(df.index.values, portfolio.observations)
+    assert len(df.columns) == 10
+    np.testing.assert_array_equal(df.columns.values, portfolio.nonzero_assets)
+    np.testing.assert_array_equal(
+        df.values[0], portfolio.weights[portfolio.nonzero_assets_index]
+    )