    def get_metadata_routing(self):
        # noinspection PyTypeChecker
        router = (
            skm.MetadataRouter(owner=self.__class__.__name__)
            .add(
                distance_estimator=self.distance_estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
            .add(
                clustering_estimator=self.clustering_estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
            .add(
                inner_estimator=self.inner_estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
        )
        return router

    def fit(
        self, X: npt.ArrayLike, y: npt.ArrayLike | None = None, **fit_params
    ) -> "NestedClustersOptimization":
        """Fit the Nested Clusters Optimization estimator.

        Parameters
        ----------
        X : array-like of shape (n_observations, n_assets)
           Price returns of the assets.

        y : array-like of shape (n_observations, n_targets), optional
            Price returns of factors or a target benchmark.
            The default is `None`.

        **fit_params : dict
            Parameters to pass to the underlying estimators.
            Only available if `enable_metadata_routing=True`, which can be
            set by using ``sklearn.set_config(enable_metadata_routing=True)``.
            See :ref:`Metadata Routing User Guide <metadata_routing>` for
            more details.

        Returns
        -------
        self : NestedClustersOptimization
            Fitted estimator.
        """
        routed_params = skm.process_routing(self, "fit", **fit_params)

        self.distance_estimator_ = check_estimator(
            self.distance_estimator,
            default=PearsonDistance(),
            check_type=BaseDistance,
        )
        self.clustering_estimator_ = check_estimator(
            self.clustering_estimator,
            default=HierarchicalClustering(),
            check_type=skb.BaseEstimator,
        )
        self.outer_estimator_ = check_estimator(
            self.outer_estimator,
            default=MeanRisk(),
            check_type=BaseOptimization,
        )
        _inner_estimator = check_estimator(
            self.inner_estimator,
            default=MeanRisk(),
            check_type=BaseOptimization,
        )

        # noinspection PyArgumentList
        self.distance_estimator_.fit(X, y, **routed_params.distance_estimator.fit)
        distance = self.distance_estimator_.distance_
        n_assets = distance.shape[0]

        # To keep the asset_names --> used for visualisation
        if isinstance(X, pd.DataFrame):
            distance = pd.DataFrame(distance, columns=X.columns)

        # noinspection PyUnresolvedReferences
        self.clustering_estimator_.fit(
            X=distance, y=None, **routed_params.clustering_estimator.fit
        )
        # noinspection PyUnresolvedReferences
        labels = self.clustering_estimator_.labels_
        n_clusters = max(labels) + 1
        clusters = [np.argwhere(labels == i).flatten() for i in range(n_clusters)]

        # Intra cluster weights
        # Fit the inner estimator on the whole training data. Those
        # base estimators will be used to retrieve the inner weights.
        # They are exposed publicly.
        # noinspection PyCallingNonCallable
        fitted_inner_estimators = skp.Parallel(n_jobs=self.n_jobs)(
            skp.delayed(fit_single_estimator)(
                sk.clone(_inner_estimator),
                X,
                y,
                routed_params.inner_estimator.fit,
                indices=cluster_ids,
                axis=1,
            )
            for cluster_ids in clusters
            if len(cluster_ids) != 1
        )
        fitted_inner_estimators = iter(fitted_inner_estimators)

        self.inner_estimators_ = []
        inner_weights = []
        for cluster_ids in clusters:
            w = np.zeros(n_assets)
            # For single assets, we don't run the inner optimization estimator.
            if len(cluster_ids) == 1:
                w[cluster_ids] = 1
            else:
                fitted_inner_estimator = next(fitted_inner_estimators)
                self.inner_estimators_.append(fitted_inner_estimator)
                w[cluster_ids] = fitted_inner_estimator.weights_
            inner_weights.append(w)
        inner_weights = np.array(inner_weights)
        assert not any(fitted_inner_estimators), (
            "fitted_inner_estimator iterator must be empty"
        )

        # Outer cluster weights
        # To train the outer-estimator using the most data as possible, we use
        # a cross-validation to obtain the output of the cluster estimators.
        # To ensure that the data provided to each estimator are the same,
        # we need to set the random state of the cv if there is one and we
        # need to take a copy.
        if self.cv == "ignore":
            cv_predictions = None
            test_indices = slice(None)
        else:
            cv = sks.check_cv(self.cv)
            if hasattr(cv, "random_state") and cv.random_state is None:
                cv.random_state = np.random.RandomState()
            # noinspection PyCallingNonCallable
            cv_predictions = skp.Parallel(n_jobs=self.n_jobs)(
                skp.delayed(cross_val_predict)(
                    sk.clone(_inner_estimator),
                    X,
                    y,
                    cv=deepcopy(cv),
                    n_jobs=self.n_jobs,
                    verbose=self.verbose,
                    column_indices=cluster_ids,
                    method="predict",
                    params=routed_params.inner_estimator.fit,
                )
                for cluster_ids in clusters
                if len(cluster_ids) != 1
            )
            cv_predictions = iter(cv_predictions)
            if isinstance(self.cv, BaseCombinatorialCV):
                test_indices = slice(None)
            else:
                test_indices = np.sort(
                    np.concatenate([test for _, test in cv.split(X, y)])
                )

        # We validate and convert to numpy array only after inner-estimator fitting to
        # keep the assets names in case they are used in the estimator.
        if y is not None:
            X, y = skv.validate_data(self, X, y)
            y_pred = y[test_indices]
        else:
            X = skv.validate_data(self, X)
            y_pred = None

        X_pred = []
        fitted_inner_estimators = iter(self.inner_estimators_)
        for cluster_ids in clusters:
            if len(cluster_ids) == 1:
                pred = X[test_indices, cluster_ids[0]]
            else:
                if cv_predictions is None:
                    fitted_inner_estimator = next(fitted_inner_estimators)
                    pred = fitted_inner_estimator.predict(X[test_indices, cluster_ids])
                else:
                    pred = next(cv_predictions)
                    if isinstance(self.cv, BaseCombinatorialCV):
                        pred = pred.quantile(
                            measure=self.quantile_measure, q=self.quantile
                        )
            X_pred.append(np.asarray(pred))
        X_pred = np.array(X_pred).T
        if cv_predictions is None:
            assert not any(fitted_inner_estimators), (
                "fitted_inner_estimator iterator must be empty"
            )
        else:
            assert not any(cv_predictions), "cv_predictions iterator must be empty"

        fit_single_estimator(self.outer_estimator_, X_pred, y_pred, fit_params={})
        outer_weights = self.outer_estimator_.weights_
        self.weights_ = outer_weights @ inner_weights
        return self


def load_gzip_compressed_csv_data(
    data_filename: str,
    data_module: str = DATA_MODULE,
    encoding="utf-8",
    datetime_index: bool = True,
) -> pd.DataFrame:
    """Load gzip-compressed csv files with `importlib.resources`.

    1) Open resource file with `importlib.resources.open_binary`
    2) Decompress csv file with `gzip.open`
    3) Load decompressed data with `pd.read_csv`

    Parameters
    ----------
    data_filename : str
        Name of gzip-compressed csv file  (`'*.csv.gz'`) to be loaded from
        `data_module/data_file_name`. For example `'SPX500.csv.gz'`.

    data_module : str or module, default='skfolio.datasets.data'
        Module where data lives. The default is `'skfolio.datasets.data'`.

    encoding : str, default="utf-8"
        Name of the encoding that the gzip-decompressed file will be
        decoded with. The default is 'utf-8'.

    datetime_index: bool, default=True
        If this is set to True, the DataFrame index is converted to datetime with
        format="%Y-%m-%d".
        The default is `True`.

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        DataFrame with each row representing one observation and each column
        representing the asset price of a given observation.
    """
    path = resources.files(data_module).joinpath(data_filename)
    with path.open("rb") as compressed_file:
        compressed_file = gzip.open(compressed_file, mode="rt", encoding=encoding)
        df = pd.read_csv(compressed_file, sep=",", index_col=0)
        if datetime_index:
            df.index = pd.to_datetime(df.index, format="%Y-%m-%d")
        return df

def load_sp500_dataset() -> pd.DataFrame:
    """Load the prices of 20 assets from the S&P 500 Index composition.

    This dataset is composed of the daily prices of 20 assets from the S&P 500
    composition starting from 1990-01-02 up to 2022-12-28.

    The data comes from the Yahoo public API.
    The price is the adjusted close which is the closing price after adjustments for
    all applicable splits and dividend distributions.
    The adjustment uses appropriate split and dividend multipliers, adhering to
    the Center for Research in Security Prices (CRSP) standards.

    ==============   ==================
    Observations     8313
    Assets           20
    ==============   ==================

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        Prices DataFrame

    Examples
    --------
    >>> from skfolio.datasets import load_sp500_dataset
    >>> prices = load_sp500_dataset()
    >>> prices.head()
                    AAPL     AMD       BAC  ...       UNH       WMT      XOM
    1990-01-02  0.332589  4.1250  11.65625  ...  0.382813  5.890625  12.5000
    1990-01-03  0.334821  4.0000  11.75000  ...  0.375000  5.890625  12.3750
    1990-01-04  0.335938  3.9375  11.50000  ...  0.371094  5.859375  12.2500
    1990-01-05  0.337054  3.8125  11.25000  ...  0.355469  5.796875  12.1875
    1990-01-08  0.339286  3.8125  11.31250  ...  0.347656  5.875000  12.3750
    """
    data_filename = "sp500_dataset.csv.gz"
    df = load_gzip_compressed_csv_data(data_filename)
    return df

    def get_metadata_routing(self):
        # noinspection PyTypeChecker
        router = (
            skm.MetadataRouter(owner=self.__class__.__name__)
            .add(
                mu_estimator=self.mu_estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
            .add(
                covariance_estimator=self.covariance_estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
        )
        return router

    def fit(self, X: npt.ArrayLike, y=None, **fit_params) -> "EmpiricalPrior":
        """Fit the Empirical Prior estimator.

        Parameters
        ----------
        X : array-like of shape (n_observations, n_assets)
            Price returns of the assets.

        y : Ignored
            Not used, present for API consistency by convention.

        **fit_params : dict
            Parameters to pass to the underlying estimators.
            Only available if `enable_metadata_routing=True`, which can be
            set by using ``sklearn.set_config(enable_metadata_routing=True)``.
            See :ref:`Metadata Routing User Guide <metadata_routing>` for
            more details.

        Returns
        -------
        self : EmpiricalPrior
            Fitted estimator.
        """
        routed_params = skm.process_routing(self, "fit", **fit_params)

        self.mu_estimator_ = check_estimator(
            self.mu_estimator,
            default=EmpiricalMu(),
            check_type=BaseMu,
        )
        self.covariance_estimator_ = check_estimator(
            self.covariance_estimator,
            default=EmpiricalCovariance(),
            check_type=BaseCovariance,
        )
        # fitting estimators
        if not self.is_log_normal:
            if self.investment_horizon is not None:
                raise ValueError(
                    "`investment_horizon` must be `None` when "
                    "`is_log_normal` is `False`"
                )
            # Expected returns
            # noinspection PyArgumentList
            self.mu_estimator_.fit(X, y, **routed_params.mu_estimator.fit)
            mu = self.mu_estimator_.mu_

            # Covariance
            # noinspection PyArgumentList
            self.covariance_estimator_.fit(
                X, y, **routed_params.covariance_estimator.fit
            )
            covariance = self.covariance_estimator_.covariance_
        else:
            if self.investment_horizon is None:
                raise ValueError(
                    "`investment_horizon` must be provided when "
                    "`is_log_normal` is `True`"
                )
            # Convert linear returns to log returns
            X_log = np.log(1 + X)
            y_log = np.log(1 + y) if y is not None else None

            # Estimates the moments on the log returns
            # Expected returns
            # noinspection PyArgumentList
            self.mu_estimator_.fit(X_log, y_log, **routed_params.mu_estimator.fit)
            mu = self.mu_estimator_.mu_

            # Covariance
            # noinspection PyArgumentList
            self.covariance_estimator_.fit(
                X_log, y_log, **routed_params.covariance_estimator.fit
            )
            covariance = self.covariance_estimator_.covariance_

            # Using the property of aggregation across time we scale this distribution
            # to the investment horizon by the “square-root rule”.
            mu *= self.investment_horizon
            covariance *= self.investment_horizon

            # We convert it into a distribution of linear returns over the investment
            # horizon
            mu = np.exp(mu + 0.5 * np.diag(covariance))
            covariance = np.outer(mu, mu) * (np.exp(covariance) - 1)

        # we validate and convert to numpy after all models have been fitted to keep
        # features names information.
        X = skv.validate_data(self, X)
        self.prior_model_ = PriorModel(
            mu=mu,
            covariance=covariance,
            returns=X,
        )
        return self


    def get_metadata_routing(self):
        # noinspection PyTypeChecker
        router = skm.MetadataRouter(owner=self.__class__.__name__)
        for name, estimator in self.estimators:
            router.add(
                **{name: estimator},
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
        return router

    def fit(
        self, X: npt.ArrayLike, y: npt.ArrayLike | None = None, **fit_params
    ) -> "StackingOptimization":
        """Fit the Stacking Optimization estimator.

        Parameters
        ----------
        X : array-like of shape (n_observations, n_assets)
           Price returns of the assets.

        y : array-like of shape (n_observations, n_targets), optional
            Price returns of factors or a target benchmark.
            The default is `None`.

        **fit_params : dict
            Parameters to pass to the underlying estimators.
            Only available if `enable_metadata_routing=True`, which can be
            set by using ``sklearn.set_config(enable_metadata_routing=True)``.
            See :ref:`Metadata Routing User Guide <metadata_routing>` for
            more details.

        Returns
        -------
        self : StackingOptimization
           Fitted estimator.
        """
        routed_params = skm.process_routing(self, "fit", **fit_params)

        names, all_estimators = self._validate_estimators()
        self.final_estimator_ = check_estimator(
            self.final_estimator,
            default=MeanRisk(),
            check_type=BaseOptimization,
        )

        if self.cv == "prefit":
            self.estimators_ = []
            for estimator in all_estimators:
                skv.check_is_fitted(estimator)
                self.estimators_.append(estimator)
        else:
            # Fit the base estimators on the whole training data. Those
            # base estimators will be used to retrieve the inner weights.
            # They are exposed publicly.
            # noinspection PyCallingNonCallable
            self.estimators_ = skp.Parallel(n_jobs=self.n_jobs)(
                skp.delayed(fit_single_estimator)(
                    sk.clone(est), X, y, routed_params[name]["fit"]
                )
                for name, est in zip(names, all_estimators, strict=True)
            )

        self.named_estimators_ = {
            name: estimator
            for name, estimator in zip(names, self.estimators_, strict=True)
        }

        inner_weights = np.array([estimator.weights_ for estimator in self.estimators_])

        # To train the final-estimator using the most data as possible, we use
        # a cross-validation to obtain the output of the stacked estimators.
        # To ensure that the data provided to each estimator are the same,
        # we need to set the random state of the cv if there is one and we
        # need to take a copy.
        if self.cv in ["prefit", "ignore"]:
            X_pred = np.array(
                [estimator.predict(X) for estimator in self.estimators_]
            ).T
        else:
            cv = sks.check_cv(self.cv)
            if hasattr(cv, "random_state") and cv.random_state is None:
                cv.random_state = np.random.RandomState()
            # noinspection PyCallingNonCallable
            cv_predictions = skp.Parallel(n_jobs=self.n_jobs)(
                skp.delayed(cross_val_predict)(
                    sk.clone(est),
                    X,
                    y,
                    cv=deepcopy(cv),
                    method="predict",
                    n_jobs=self.n_jobs,
                    params=routed_params[name]["fit"],
                    verbose=self.verbose,
                )
                for name, est in zip(names, all_estimators, strict=True)
            )

            # We validate and convert to numpy array only after base-estimator fitting
            # to keep the assets names in case they are used in the estimator.
            if y is not None:
                _, y = skv.validate_data(self, X, y, multi_output=True)
            else:
                _ = skv.validate_data(self, X)

            if isinstance(self.cv, BaseCombinatorialCV):
                X_pred = np.array(
                    [
                        pred.quantile(measure=self.quantile_measure, q=self.quantile)
                        for pred in cv_predictions
                    ]
                ).T
            else:
                X_pred = np.array(cv_predictions).T
                if y is not None:
                    test_indices = np.sort(
                        np.concatenate([test for _, test in cv.split(X, y)])
                    )
                    y = y[test_indices]

        fit_single_estimator(self.final_estimator_, X_pred, y, {})
        outer_weights = self.final_estimator_.weights_
        self.weights_ = outer_weights @ inner_weights
        return self


def load_factors_dataset() -> pd.DataFrame:
    """Load the prices of 5 factor ETFs.

    This dataset is composed of the daily prices of 5 ETF representing common factors
    starting from 2014-01-02 up to 2022-12-28.

    The factors are:

        * "MTUM": Momentum
        * "QUAL": Quality
        * "SIZE": Size
        * "VLUE": Value
        * "USMV": low volatility

    The data comes from the Yahoo public API.
    The price is the adjusted close which is the closing price after adjustments for
    all applicable splits and dividend distributions.
    The adjustment uses appropriate split and dividend multipliers, adhering to
    the Center for Research in Security Prices (CRSP) standards.

    ==============   ==================
    Observations     2264
    Assets           5
    ==============   ==================

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        Prices DataFrame

    Examples
    --------
    >>> from skfolio.datasets import load_factors_dataset
    >>> prices = load_factors_dataset()
    >>> prices.head()
                  MTUM    QUAL    SIZE    USMV    VLUE
    Date
    2014-01-02  52.704  48.351  48.986  29.338  47.054
    2014-01-03  52.792  48.256  48.722  29.330  46.999
    2014-01-06  52.677  48.067  48.722  29.263  46.991
    2014-01-07  53.112  48.455  48.731  29.430  47.253
    2014-01-08  53.502  48.437  48.731  29.422  47.253
    """
    data_filename = "factors_dataset.csv.gz"
    df = load_gzip_compressed_csv_data(data_filename)
    return df


def variance(returns: np.ndarray) -> float:
    """Compute the variance (second moment).

    Parameters
    ----------
    returns : ndarray of shape (n_observations,)
        Vector of returns.

    Returns
    -------
    value : float
        Variance.
    """
    return returns.var(ddof=1)

def standard_deviation(returns: np.ndarray) -> float:
    """Compute the standard-deviation (square root of the second moment).

    Parameters
    ----------
    returns : ndarray of shape (n_observations,)
        Vector of returns.

    Returns
    -------
    value : float
        Standard-deviation.
    """
    return np.sqrt(variance(returns=returns))


def input_to_array(
    items: dict | npt.ArrayLike,
    n_assets: int,
    fill_value: Any,
    dim: int,
    assets_names: np.ndarray | None,
    name: str,
) -> np.ndarray:
    """Convert a collection of items (array-like or dictionary) into
    a numpy array and verify its shape.

    Parameters
    ----------
    items : np.ndarray | dict | list
        Items to verify and convert to array.

    n_assets : int
        Expected number of assets.
        Used to verify the shape of the converted array.

    fill_value : Any
        When `items` is a dictionary, elements that are not in `asset_names` are filled
        with `fill_value` in the converted array.

    dim : int
        Dimension of the final array.
        Possible values are `1` or `2`.

    assets_names : ndarray, optional
        Asset names used when `items` is a dictionary.

    name : str
        Name of the items used for error messages.

    Returns
    -------
    values : ndarray of shape (n_assets) for dim=1 or (n_groups, n_assets) for dim=2
        Converted array.
    """
    if dim not in [1, 2]:
        raise ValueError(f"dim must be 1 or 2, got {dim}")
    if isinstance(items, dict):
        if assets_names is None:
            raise ValueError(
                f"If `{name}` is provided as a dictionary, you must input `X` as a"
                " DataFrame with assets names in columns"
            )
        if dim == 1:
            arr = np.array([items.get(asset, fill_value) for asset in assets_names])
        else:
            # add assets and convert dict to ordered array
            arr = {}
            for asset in assets_names:
                elem = items.get(asset)
                if elem is None:
                    elem = [asset]
                elif np.isscalar(elem):
                    elem = [asset, elem]
                else:
                    elem = [asset, *elem]
                arr[asset] = elem
            arr = (
                pd.DataFrame.from_dict(arr, orient="index")
                .loc[assets_names]
                .to_numpy()
                .T
            )
    else:
        arr = np.asarray(items)

    if arr.ndim != dim:
        raise ValueError(f"`{name}` must be a {dim}D array, got a {arr.ndim}D array")

    if not isinstance(fill_value, str) and np.isnan(arr).any():
        raise ValueError(f"`{name}` contains NaN")

    if arr.shape[-1] != n_assets:
        if dim == 1:
            s = "(n_assets,)"
        else:
            s = "(n_groups, n_assets)"
        raise ValueError(
            f"`{name}` must be a of shape {s} with n_assets={n_assets}, "
            f"got {arr.shape[0]}"
        )
    return arr

    def __init__(
        self,
        X: npt.ArrayLike,
        weights: skt.MultiInput,
        previous_weights: skt.MultiInput = None,
        transaction_costs: skt.MultiInput = None,
        management_fees: skt.MultiInput = None,
        risk_free_rate: float = 0,
        name: str | None = None,
        tag: str | None = None,
        annualized_factor: float = 252,
        fitness_measures: list[skt.Measure] | None = None,
        compounded: bool = False,
        min_acceptable_return: float | None = None,
        value_at_risk_beta: float = 0.95,
        entropic_risk_measure_theta: float = 1,
        entropic_risk_measure_beta: float = 0.95,
        cvar_beta: float = 0.95,
        evar_beta: float = 0.95,
        drawdown_at_risk_beta: float = 0.95,
        cdar_beta: float = 0.95,
        edar_beta: float = 0.95,
    ):
        # extract assets names from X
        assets = None
        observations = None
        if hasattr(X, "columns"):
            assets = np.asarray(X.columns, dtype=object)
            observations = np.asarray(X.index)

        # We don't perform extensive checks (like in check_X) for faster instantiation.
        rets = np.asarray(X)
        if rets.ndim != 2:
            raise ValueError("`X` must be a 2D array-like")

        n_observations, n_assets = rets.shape

        weights = input_to_array(
            items=weights,
            n_assets=n_assets,
            fill_value=0,
            dim=1,
            assets_names=assets,
            name="weights",
        )

        if previous_weights is None:
            previous_weights = np.zeros(n_assets)
        else:
            previous_weights = input_to_array(
                items=previous_weights,
                n_assets=n_assets,
                fill_value=0,
                dim=1,
                assets_names=assets,
                name="previous_weights",
            )

        if transaction_costs is None:
            transaction_costs = 0
        elif not np.isscalar(transaction_costs):
            transaction_costs = input_to_array(
                items=transaction_costs,
                n_assets=n_assets,
                fill_value=0,
                dim=1,
                assets_names=assets,
                name="transaction_costs",
            )

        if management_fees is None:
            management_fees = 0
        elif not np.isscalar(management_fees):
            management_fees = input_to_array(
                items=management_fees,
                n_assets=n_assets,
                fill_value=0,
                dim=1,
                assets_names=assets,
                name="management_fees",
            )

        # Default observations and assets if X is not a DataFrame
        if observations is None or len(observations) == 0:
            observations = np.arange(n_observations)

        if assets is None or len(assets) == 0:
            assets = default_asset_names(n_assets=n_assets)

        # Computing portfolio returns
        if np.isscalar(transaction_costs) and transaction_costs == 0:
            total_cost = 0
        else:
            total_cost = (transaction_costs * abs(previous_weights - weights)).sum()

        if np.isscalar(management_fees) and management_fees == 0:
            total_fee = 0
        else:
            total_fee = (management_fees * weights).sum()

        returns = weights @ rets.T - total_cost - total_fee

        if np.any(np.isnan(returns)):
            raise ValueError("NaN found in `returns`")

        super().__init__(
            returns=returns,
            observations=observations,
            name=name,
            tag=tag,
            fitness_measures=fitness_measures,
            compounded=compounded,
            risk_free_rate=risk_free_rate,
            annualized_factor=annualized_factor,
            min_acceptable_return=min_acceptable_return,
            value_at_risk_beta=value_at_risk_beta,
            cvar_beta=cvar_beta,
            entropic_risk_measure_theta=entropic_risk_measure_theta,
            entropic_risk_measure_beta=entropic_risk_measure_beta,
            evar_beta=evar_beta,
            drawdown_at_risk_beta=drawdown_at_risk_beta,
            cdar_beta=cdar_beta,
            edar_beta=edar_beta,
        )
        self._loaded = False
        # We save the original array-like object and not the numpy copy for improved
        # memory
        self.X = X
        self.assets = assets
        self.n_assets = n_assets
        self.weights = weights
        self.transaction_costs = transaction_costs
        self.management_fees = management_fees
        self.previous_weights = previous_weights
        self.total_cost = total_cost
        self.total_fee = total_fee
        self._loaded = True


def safe_indexing(
    X: npt.ArrayLike | pd.DataFrame, indices: npt.ArrayLike | None, axis: int = 0
):
    """Return rows, items or columns of X using indices.

    Parameters
    ----------
    X : array-like
        Data from which to sample rows.

    indices : array-like, optional
        Indices of rows or columns.
        The default (`None`) is to select the entire data.

    axis : int, default=0
        The axis along which `X` will be sub-sampled. `axis=0` will select
        rows while `axis=1` will select columns.

    Returns
    -------
    subset :
        Subset of X on axis 0.
    """
    if indices is None:
        return X
    if hasattr(X, "iloc"):
        return X.take(indices, axis=axis)
    if axis == 0:
        return X[indices]
    return X[:, indices]

def safe_split(
    X: npt.ArrayLike,
    y: npt.ArrayLike | None = None,
    indices: np.ndarray | None = None,
    axis: int = 0,
):
    """Create subset of dataset.

    Slice X, y according to indices for cross-validation.

    Parameters
    ----------
    X : array-like
        Data to be indexed.

    y : array-like
        Data to be indexed.

    indices : ndarray of int, optional
        Rows or columns to select from X and y.
        The default (`None`) is to select the entire data.

    axis : int, default=0
        The axis along which `X` will be sub-sampled. `axis=0` will select
        rows while `axis=1` will select columns.

    Returns
    -------
    X_subset : array-like
        Indexed data.

    y_subset : array-like
        Indexed targets.
    """
    X_subset = safe_indexing(X, indices=indices, axis=axis)
    if y is not None:
        y_subset = safe_indexing(y, indices=indices, axis=axis)
    else:
        y_subset = None
    return X_subset, y_subset

def cross_val_predict(
    estimator: skb.BaseEstimator,
    X: npt.ArrayLike,
    y: npt.ArrayLike = None,
    cv: sks.BaseCrossValidator | BaseCombinatorialCV | int | None = None,
    n_jobs: int | None = None,
    method: str = "predict",
    verbose: int = 0,
    params: dict | None = None,
    pre_dispatch: str = "2*n_jobs",
    column_indices: np.ndarray | None = None,
    portfolio_params: dict | None = None,
) -> MultiPeriodPortfolio | Population:
    """Generate cross-validated `Portfolios` estimates.

    The data is split according to the `cv` parameter.
    The optimization estimator is fitted on the training set and portfolios are
    predicted on the corresponding test set.

    For non-combinatorial cross-validation like `Kfold`, the output is the predicted
    :class:`~skfolio.portfolio.MultiPeriodPortfolio` where
    each :class:`~skfolio.portfolio.Portfolio` corresponds to the prediction on each
    train/test pair (`k` portfolios for `Kfold`).

    For combinatorial cross-validation
    like :class:`~skfolio.model_selection.CombinatorialPurgedCV`, the output is the
    predicted :class:`~skfolio.population.Population` of multiple
    :class:`~skfolio.portfolio.MultiPeriodPortfolio` (each test outputs are a
    collection of multiple paths instead of one single path).

    Parameters
    ----------
    estimator : BaseOptimization
        :ref:`Optimization estimators <optimization>` use to fit the data.

    X : array-like of shape (n_observations, n_assets)
        Price returns of the assets.

    y : array-like of shape (n_observations, n_targets), optional
        Target data (optional).
        For example, the price returns of the factors.

    cv : int | cross-validation generator, optional
        Determines the cross-validation splitting strategy.
        Possible inputs for cv are:

        * None, to use the default 5-fold cross validation,
        * int, to specify the number of folds in a `(Stratified)KFold`,
        * `CV splitter`,
        * An iterable that generates (train, test) splits as arrays of indices.

    n_jobs : int, optional
        The number of jobs to run in parallel for `fit` of all `estimators`.
        `None` means 1 unless in a `joblib.parallel_backend` context. -1 means
        using all processors.

    method : str
        Invokes the passed method name of the passed estimator.

    verbose : int, default=0
        The verbosity level.

    params : dict, optional
        Parameters to pass to the underlying estimator's ``fit`` and the CV splitter.

    pre_dispatch : int or str, default='2*n_jobs'
        Controls the number of jobs that get dispatched during parallel
        execution. Reducing this number can be useful to avoid an
        explosion of memory consumption when more jobs get dispatched
        than CPUs can process. This parameter can be:

            * None, in which case all the jobs are immediately
              created and spawned. Use this for lightweight and
              fast-running jobs, to avoid delays due to on-demand
              spawning of the jobs

            * An int, giving the exact number of total jobs that are
              spawned

            * A str, giving an expression as a function of n_jobs,
              as in '2*n_jobs'

    column_indices : ndarray, optional
        Indices of the `X` columns to cross-validate on.

    portfolio_params :  dict, optional
        Additional portfolio parameters passed to `MultiPeriodPortfolio`.

    Returns
    -------
    predictions : MultiPeriodPortfolio | Population
        This is the result of calling `predict`
    """
    params = {} if params is None else params

    X, y = safe_split(X, y, indices=column_indices, axis=1)
    X, y = sku.indexable(X, y)

    if _routing_enabled():
        # For estimators, a MetadataRouter is created in get_metadata_routing
        # methods. For these router methods, we create the router to use
        # `process_routing` on it.
        # noinspection PyTypeChecker
        router = (
            skm.MetadataRouter(owner="cross_validate")
            .add(
                splitter=cv,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="split"),
            )
            .add(
                estimator=estimator,
                method_mapping=skm.MethodMapping().add(caller="fit", callee="fit"),
            )
        )
        try:
            routed_params = skm.process_routing(router, "fit", **params)
        except ske.UnsetMetadataPassedError as e:
            # The default exception would mention `fit` since in the above
            # `process_routing` code, we pass `fit` as the caller. However,
            # the user is not calling `fit` directly, so we change the message
            # to make it more suitable for this case.
            unrequested_params = sorted(e.unrequested_params)
            raise ske.UnsetMetadataPassedError(
                message=(
                    f"{unrequested_params} are passed to `cross_val_predict` but are"
                    " not explicitly set as requested or not requested for"
                    f" cross_validate's estimator: {estimator.__class__.__name__} Call"
                    " `.set_fit_request({{metadata}}=True)` on the estimator for"
                    f" each metadata in {unrequested_params} that you want to use and"
                    " `metadata=False` for not using it. See the Metadata Routing User"
                    " guide <https://scikit-learn.org/stable/metadata_routing.html>"
                    " for more information."
                ),
                unrequested_params=e.unrequested_params,
                routed_params=e.routed_params,
            ) from None
    else:
        routed_params = sku.Bunch()
        routed_params.splitter = sku.Bunch(split={})
        routed_params.estimator = sku.Bunch(fit=params)

    cv = sks.check_cv(cv, y)
    splits = list(cv.split(X, y, **routed_params.splitter.split))

    portfolio_params = {} if portfolio_params is None else portfolio_params.copy()

    # We ensure that the folds are not shuffled
    if not isinstance(cv, BaseCombinatorialCV):
        try:
            if cv.shuffle:
                raise ValueError(
                    "`cross_val_predict` only works with cross-validation setting"
                    " `shuffle=False`"
                )
        except AttributeError:
            # If we cannot find the attribute shuffle, we check if the first folds
            # are shuffled
            for fold in splits[0]:
                if not np.all(np.diff(fold) > 0):
                    raise ValueError(
                        "`cross_val_predict` only works with un-shuffled folds"
                    ) from None

    # We clone the estimator to make sure that all the folds are independent
    # and that it is pickle-able.
    parallel = skp.Parallel(n_jobs=n_jobs, verbose=verbose, pre_dispatch=pre_dispatch)
    # TODO remove when https://github.com/joblib/joblib/issues/1071 is fixed
    # noinspection PyCallingNonCallable
    predictions = parallel(
        skp.delayed(fit_and_predict)(
            sk.clone(estimator),
            X,
            y,
            train=train,
            test=test,
            fit_params=routed_params.estimator.fit,
            method=method,
        )
        for train, test in splits
    )

    if isinstance(cv, BaseCombinatorialCV):
        path_ids = cv.get_path_ids()
        path_nb = np.max(path_ids) + 1
        portfolios = [[] for _ in range(path_nb)]
        for i, prediction in enumerate(predictions):
            for j, p in enumerate(prediction):
                path_id = path_ids[i, j]
                portfolios[path_id].append(p)
        name = portfolio_params.pop("name", "path")
        pred = Population(
            [
                MultiPeriodPortfolio(
                    name=f"{name}_{i}", portfolios=portfolios[i], **portfolio_params
                )
                for i in range(path_nb)
            ]
        )
    else:
        # We need to re-order the test folds in case they were un-ordered by the
        # CV generator.
        # Because the tests folds are not shuffled, we use the first index of each
        # fold to order them.
        test_indices = np.concatenate([test for _, test in splits])
        if np.unique(test_indices, axis=0).shape[0] != test_indices.shape[0]:
            raise ValueError(
                "`cross_val_predict` only works with non-duplicated test indices"
            )
        test_indices = [test for _, test in splits]
        sorted_fold_id = np.argsort([x[0] for x in test_indices])
        pred = MultiPeriodPortfolio(
            portfolios=[predictions[fold_id] for fold_id in sorted_fold_id],
            check_observations_order=False,
            **portfolio_params,
        )

    return pred

def _check_method_params(
    X: npt.ArrayLike, params: dict, indices: np.ndarray = None, axis: int = 0
):
    """Check and validate the parameters passed to a specific
    method like `fit`.

    Parameters
    ----------
    X : array-like of shape (n_samples, n_features)
        Data array.

    params : dict
        Dictionary containing the parameters passed to the method.

    indices : ndarray of shape (n_samples,), default=None
        Indices to be selected if the parameter has the same size as `X`.

    axis : int, default=0
        The axis along which `X` will be sub-sampled. `axis=0` will select
        rows while `axis=1` will select columns.

    Returns
    -------
    method_params_validated : dict
        Validated parameters. We ensure that the values support indexing.
    """
    # noinspection PyUnresolvedReferences
    n_observations = X.shape[0]
    method_params_validated = {}
    for param_key, param_value in params.items():
        if param_value.shape[0] != n_observations:
            raise ValueError(
                f"param_key has wrong number of observations, "
                f"received={param_value.shape[0]}, "
                f"expected={n_observations}"
            )
        method_params_validated[param_key] = _make_indexable(param_value)
        method_params_validated[param_key] = safe_indexing(
            X=method_params_validated[param_key], indices=indices, axis=axis
        )
    return method_params_validated

def fit_and_predict(
    estimator: Any,
    X: npt.ArrayLike,
    y: npt.ArrayLike | None,
    train: np.ndarray,
    test: np.ndarray | list[np.ndarray],
    fit_params: dict,
    method: str,
    column_indices: np.ndarray | None = None,
) -> npt.ArrayLike | list[npt.ArrayLike]:
    """Fit the estimator and predict values for a given dataset split.

    Parameters
    ----------
    estimator : estimator object implementing 'fit' and 'predict'
        The object to use to fit the data.

    X : array-like of shape (n_observations, n_assets)
        The data to fit.

    y : array-like of shape (n_observations, n_factors) or None
        The factor array if provided

    train : ndarray of int of shape (n_train_observations,)
        Indices of training samples.

    test : ndarray of int of shape (n_test_samples,) or list of ndarray
        Indices of test samples or list of indices.

    fit_params : dict
        Parameters that will be passed to `estimator.fit`.

    method : str
        Invokes the passed method name of the passed estimator.

    column_indices : ndarray, optional
        Indices of columns to select.
        The default (`None`) is to select all columns.

    Returns
    -------
    predictions : array-like or list of array-like
        If `test` is an array, it returns the array-like result of calling
        'estimator.method' on `test`.
        Otherwise, if `test` is a list of arrays, it returns the list of array-like
        results of calling 'estimator.method' on each test set in `test`.
    """
    fit_params = fit_params if fit_params is not None else {}
    fit_params = _check_method_params(X, params=fit_params, indices=train)

    X, y = safe_split(X, y, indices=column_indices, axis=1)
    X_train, y_train = safe_split(X, y, indices=train, axis=0)
    if y_train is None:
        estimator.fit(X_train, **fit_params)
    else:
        estimator.fit(X_train, y_train, **fit_params)
    func = getattr(estimator, method)

    if isinstance(test, list):
        predictions = []
        for t in test:
            X_test, _ = safe_split(X, indices=t, axis=0)
            predictions.append(func(X_test))
    else:
        X_test, _ = safe_split(X, indices=test, axis=0)
        predictions = func(X_test)

    return predictions


def _validate_groups(groups: npt.ArrayLike, name: str = "groups") -> np.ndarray:
    """Validate groups by checking its dim and if group names don't appear in multiple
    levels and convert to numpy array.

    Parameters
    ----------
    groups : array-like of shape (n_groups, n_assets)
        2D-array of strings.

    Returns
    -------
    groups : ndarray of shape (n_groups, n_assets)
        2D-array of strings.
    """
    groups = np.asarray(groups)
    if groups.ndim != 2:
        raise ValueError(
            f"`{name} must be a 2D array, got {groups.ndim}D array instead."
        )
    n = len(groups)
    group_sets = [set(groups[i]) for i in range(n)]
    for i in range(n - 1):
        for e in group_sets[i]:
            for j in range(i + 1, n):
                if e in group_sets[j]:
                    raise DuplicateGroupsError(
                        f"'{e}' appear in two levels: {list(groups[i])} "
                        f"and {list(groups[i])}. "
                        f"{name} must be in only one level."
                    )

    return groups

def equations_to_matrix(
    groups: npt.ArrayLike,
    equations: npt.ArrayLike,
    sum_to_one: bool = False,
    raise_if_group_missing: bool = False,
    names: tuple[str, str] = ("groups", "equations"),
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
    """Convert a list of linear equations into the left and right matrices of the
    inequality A <= B and equality A == B.

    Parameters
    ----------
    groups : array-like of shape (n_groups, n_assets)
        2D array of assets groups.

        For example:

             groups = np.array(
                [
                    ["SPX", "SX5E", "NKY", "TLT"],
                    ["Equity", "Equity", "Equity", "Bond"],
                    ["US", "Europe", "Japan", "US"],
                ]
            )

    equations : array-like of shape (n_equations,)
         1D array of equations.

         Example of valid equation patterns:
            * "number_1 * group_1 + number_3 <= number_4 * group_3 + number_5"
            * "group_1 == number * group_2"
            * "group_1 <= number"
            * "group_1 == number"

        "group_1" and "group_2" are the group names defined in `groups`.
        The second expression means that the sum of all assets in "group_1" should be
        less or equal to "number" times the sum of all assets in "group_2".

        For example:

             equations = [
                "Equity <= 3 * Bond",
                "US >= 1.5",
                "Europe >= 0.5 * Japan",
                "Japan == 1",
                "3*SPX + 5*SX5E == 2*TLT + 3",
            ]

    sum_to_one : bool
        If this is set to True, all elements in a group sum to one (used in the `views`
        of the Black-Litterman model).

    raise_if_group_missing : bool, default=False
        If this is set to True, an error is raised when a group is not found in the
        groups, otherwise only a warning is shown.
        The default is False.

    names : tuple[str, str], default=('groups', 'equations')
        The group and equation names used in error messages.
        The default is `('groups', 'equations')`.

    Returns
    -------
    left_equality: ndarray of shape (n_equations_equality, n_assets)
    right_equality: ndarray of shape (n_equations_equality,)
        The left and right matrices of the inequality A <= B.

    left_inequality: ndarray of shape (n_equations_inequality, n_assets)
    right_inequality: ndarray of shape (n_equations_inequality,)
        The left and right matrices of the equality A == B.
    """
    groups = _validate_groups(groups, name=names[0])
    equations = _validate_equations(equations, name=names[1])

    a_equality = []
    b_equality = []

    a_inequality = []
    b_inequality = []

    for string in equations:
        try:
            left, right, is_inequality = _string_to_equation(
                groups=groups,
                string=string,
                sum_to_one=sum_to_one,
            )
            if is_inequality:
                a_inequality.append(left)
                b_inequality.append(right)
            else:
                a_equality.append(left)
                b_equality.append(right)
        except GroupNotFoundError as e:
            if raise_if_group_missing:
                raise
            warnings.warn(str(e), stacklevel=2)
    return (
        np.array(a_equality),
        np.array(b_equality),
        np.array(a_inequality),
        np.array(b_inequality),
    )


def load_gzip_compressed_csv_data(
    data_filename: str,
    data_module: str = DATA_MODULE,
    encoding="utf-8",
    datetime_index: bool = True,
) -> pd.DataFrame:
    """Load gzip-compressed csv files with `importlib.resources`.

    1) Open resource file with `importlib.resources.open_binary`
    2) Decompress csv file with `gzip.open`
    3) Load decompressed data with `pd.read_csv`

    Parameters
    ----------
    data_filename : str
        Name of gzip-compressed csv file  (`'*.csv.gz'`) to be loaded from
        `data_module/data_file_name`. For example `'SPX500.csv.gz'`.

    data_module : str or module, default='skfolio.datasets.data'
        Module where data lives. The default is `'skfolio.datasets.data'`.

    encoding : str, default="utf-8"
        Name of the encoding that the gzip-decompressed file will be
        decoded with. The default is 'utf-8'.

    datetime_index: bool, default=True
        If this is set to True, the DataFrame index is converted to datetime with
        format="%Y-%m-%d".
        The default is `True`.

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        DataFrame with each row representing one observation and each column
        representing the asset price of a given observation.
    """
    path = resources.files(data_module).joinpath(data_filename)
    with path.open("rb") as compressed_file:
        compressed_file = gzip.open(compressed_file, mode="rt", encoding=encoding)
        df = pd.read_csv(compressed_file, sep=",", index_col=0)
        if datetime_index:
            df.index = pd.to_datetime(df.index, format="%Y-%m-%d")
        return df

def load_sp500_dataset() -> pd.DataFrame:
    """Load the prices of 20 assets from the S&P 500 Index composition.

    This dataset is composed of the daily prices of 20 assets from the S&P 500
    composition starting from 1990-01-02 up to 2022-12-28.

    The data comes from the Yahoo public API.
    The price is the adjusted close which is the closing price after adjustments for
    all applicable splits and dividend distributions.
    The adjustment uses appropriate split and dividend multipliers, adhering to
    the Center for Research in Security Prices (CRSP) standards.

    ==============   ==================
    Observations     8313
    Assets           20
    ==============   ==================

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        Prices DataFrame

    Examples
    --------
    >>> from skfolio.datasets import load_sp500_dataset
    >>> prices = load_sp500_dataset()
    >>> prices.head()
                    AAPL     AMD       BAC  ...       UNH       WMT      XOM
    1990-01-02  0.332589  4.1250  11.65625  ...  0.382813  5.890625  12.5000
    1990-01-03  0.334821  4.0000  11.75000  ...  0.375000  5.890625  12.3750
    1990-01-04  0.335938  3.9375  11.50000  ...  0.371094  5.859375  12.2500
    1990-01-05  0.337054  3.8125  11.25000  ...  0.355469  5.796875  12.1875
    1990-01-08  0.339286  3.8125  11.31250  ...  0.347656  5.875000  12.3750
    """
    data_filename = "sp500_dataset.csv.gz"
    df = load_gzip_compressed_csv_data(data_filename)
    return df

    def central(self) -> bool:
        """Determine whether this node is considered central.
        It is inherited from the associated edge's centrality.

        Returns
        -------
        central: bool
           True if the node is central; otherwise, False.
        """
        if self._central is None:
            self._central = self.ref.strongly_central
        return self._central

    def weakly_central(self) -> bool:
        """Determine if the edge is weakly central.
        An edge is weakly central if at least one of its two nodes is central.
        """
        return self.node1.central or self.node2.central

    def share_one_node(self, other: "Edge") -> bool:
        """Check whether two edges share exactly one node.

        Parameters
        ----------
        other : Edge
            Another edge to compare with.

        Returns
        -------
        bool
            True if the two edges share exactly one node; otherwise, False.
        """
        return len({self.node1, self.node2} & {other.node1, other.node2}) == 1

    def set_edges_from_mst(self, dependence_method: DependenceMethod) -> None:
        """Construct the Maximum Spanning Tree (MST) from the current nodes using
        the specified dependence method.

        The MST is built based on pairwise dependence measures computed between nodes.
        If any edge is (weakly) central, a central factor is added to the dependence
        measure to favor edges connected to central nodes.

        Parameters
        ----------
        dependence_method : DependenceMethod
            The method used to compute the dependence measure between nodes (e.g.,
            Kendall's tau).

        Returns
        -------
        None
        """
        n = len(self.nodes)
        dependence_matrix = np.zeros((n, n))
        eligible_edges = {}
        central = False
        for i, j in combinations(range(n), 2):
            node1 = self.nodes[i]
            node2 = self.nodes[j]
            if self.level == 0 or node1.ref.share_one_node(node2.ref):
                edge = Edge(
                    node1=node1, node2=node2, dependence_method=dependence_method
                )
                if not central and edge.weakly_central:
                    central = True
                # Negate the matrix to use minimum_spanning_tree for maximum spanning
                # Add a cst to ensure that even if dep is 0, we still build a valid MST
                dep = abs(edge.dependence) + 1e-5
                dependence_matrix[i, j] = dep
                eligible_edges[(i, j)] = edge

        if np.any(np.isnan(dependence_matrix)):
            raise RuntimeError("dependence_matrix contains NaNs")

        if central:
            max_dep = np.max(dependence_matrix)
            for (i, j), edge in eligible_edges.items():
                if edge.weakly_central:
                    if edge.strongly_central:
                        central_factor = 3 * max_dep
                    else:
                        central_factor = 2 * max_dep
                    dep = dependence_matrix[i, j] + central_factor
                    dependence_matrix[i, j] = dep

        # Compute the minimum spanning tree
        mst = ssc.minimum_spanning_tree(-dependence_matrix, overwrite=True)

        edges = []
        # Extract the indices of the non-zero entries (edges)
        for i, j in zip(*mst.nonzero(), strict=True):
            edge = eligible_edges[(i, j)]
            # connect Nodes to Edges
            edge.ref_to_nodes()
            edges.append(edge)

        self.edges = edges


def _split_equation_string(string: str) -> list[str]:
    """Split an equation strings by operators."""
    comp_pattern = "(?=" + "|".join([".+\\" + e for e in _COMPARISON_OPERATORS]) + ")"
    if not bool(re.match(comp_pattern, string)):
        raise EquationToMatrixError(
            f"The string must contains a comparison operator: "
            f"{list(_COMPARISON_OPERATORS)}"
        )

    # Regex to match only '>' and '<' but not '<=' or '>='
    invalid_pattern = r"(?<!<)(?<!<=)>(?!=)|(?<!>)<(?!=)"
    invalid_matches = re.findall(invalid_pattern, string)

    if len(invalid_matches) > 0:
        raise EquationToMatrixError(
            f"{invalid_matches[0]} is an invalid comparison operator. "
            f"Valid comparison operators are: {list(_COMPARISON_OPERATORS)}"
        )

    # '==' needs to be before '='
    operators = sorted(_OPERATORS, reverse=True)
    pattern = "((?:" + "|".join(["\\" + e for e in operators]) + "))"
    res = [x.strip() for x in re.split(pattern, string)]
    res = [x for x in res if x != ""]
    return res

def _string_to_equation(
    groups: np.ndarray,
    string: str,
    sum_to_one: bool,
) -> tuple[np.ndarray, float, bool]:
    """Convert a string to a left 1D-array and right float of the form:
    `groups @ left <= right` or `groups @ left == right` and return whether it's an
    equality or inequality.

    Parameters
    ----------
    groups : ndarray of shape (n_groups, n_assets)
        Groups 2D-array

    string : str
        String to convert

    sum_to_one : bool
        If this is set to True, the 1D-array is scaled to have a sum of one.

    Returns
    -------
    left : 1D-array of shape (n_assets,)
    right : float
    is_inequality : bool
    """
    n = groups.shape[1]
    err_msg = f"Wrong pattern encountered while converting the string '{string}'"

    iterator = iter(_split_equation_string(string))
    group_names = set(groups.flatten())

    def is_group(name: str) -> bool:
        return name in group_names

    left = np.zeros(n)
    right = 0
    main_sign = 1
    comparison_sign = None
    is_inequality = None
    e = next(iterator, None)
    i = 0
    while True:
        i += 1
        if i > 1e6:
            raise RecursionError(err_msg)
        if e is None:
            break
        sign = 1
        if e in _COMPARISON_OPERATORS:
            if e in _INEQUALITY_OPERATORS:
                is_inequality = True
            else:
                is_inequality = False
            main_sign = -1
            comparison_sign = _comparison_operator_sign(e)
            e = next(iterator, None)
            if e in _SUB_ADD_OPERATORS:
                sign *= _sub_add_operator_sign(e)
                e = next(iterator, None)
        elif e in _SUB_ADD_OPERATORS:
            sign *= _sub_add_operator_sign(e)
            e = next(iterator, None)
        elif e in _MUL_OPERATORS:
            raise EquationToMatrixError(
                f"{err_msg}: the character '{e}' is wrongly positioned"
            )
        sign *= main_sign
        # next can only be a number or a group
        if e is None or e in _OPERATORS:
            raise EquationToMatrixError(
                f"{err_msg}: the character '{e}' is wrongly positioned"
            )
        if is_group(e):
            arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
            # next can only be a '*' or an ['-', '+', '>=', '<=', '==', '='] or None
            e = next(iterator, None)
            if e is None or e in _NON_MUL_OPERATORS:
                left += sign * arr
            elif e in _MUL_OPERATORS:
                # next can only a number
                e = next(iterator, None)
                try:
                    number = float(e)
                except ValueError:
                    raise GroupNotFoundError(
                        f"{err_msg}: the group '{e}' is missing from the groups"
                        f" {groups}"
                    ) from None

                left += number * sign * arr
                e = next(iterator, None)
            else:
                raise EquationToMatrixError(
                    f"{err_msg}: the character '{e}' is wrongly positioned"
                )
        else:
            try:
                number = float(e)
            except ValueError:
                raise GroupNotFoundError(
                    f"{err_msg}: the group '{e}' is missing from the groups {groups}"
                ) from None
            # next can only be a '*' or an operator or None
            e = next(iterator, None)
            if e in _MUL_OPERATORS:
                # next can only a group
                e = next(iterator, None)
                if not is_group(e):
                    raise EquationToMatrixError(
                        f"{err_msg}: the character '{e}' is wrongly positioned"
                    )
                arr = _matching_array(values=groups, key=e, sum_to_one=sum_to_one)
                left += number * sign * arr
                e = next(iterator, None)
            elif e is None or e in _NON_MUL_OPERATORS:
                right += number * sign
            else:
                raise EquationToMatrixError(
                    f"{err_msg}: the character '{e}' is wrongly positioned"
                )

    left *= comparison_sign
    right *= -comparison_sign

    return left, right, is_inequality


def get_data_home(data_home: str | Path | None = None) -> str:
    """Return the path of the skfolio data directory.

    This folder is used by some large dataset loaders to avoid downloading the
    data several times.

    By default, the data directory is set to a folder named 'skfolio_data' in the
    user home folder.

    Alternatively, it can be set by the 'SKFOLIO_DATA' environment
    variable or programmatically by giving an explicit folder path. The '~'
    symbol is expanded to the user home folder.

    If the folder does not already exist, it is automatically created.

    Parameters
    ----------
    data_home : str, optional
        The path to skfolio data directory. If `None`, the default path
        is `~/skfolio_data`.

    Returns
    -------
    data_home: str or path-like, optional
        The path to skfolio data directory.
    """
    if data_home is None:
        data_home = os.environ.get("SKFOLIO_DATA", os.path.join("~", "skfolio_data"))
    data_home = os.path.expanduser(data_home)
    os.makedirs(data_home, exist_ok=True)
    return data_home

def download_dataset(
    data_filename: str,
    data_home: str | Path | None = None,
    download_if_missing: bool = True,
) -> pd.DataFrame:
    """Download and save locally a dataset from the remote GitHub dataset folder.

    Parameters
    ----------
    data_filename : str
        Name of gzip-compressed csv file  (`'*.csv.gz'`) to be loaded from a remote
        GitHub dataset folder.

    data_home : str or path-like, optional
        Specify another download and cache folder for the datasets. By default,
        all skfolio data is stored in `~/skfolio_data` sub-folders.

    download_if_missing : bool, default=True
        If False, raise an OSError if the data is not locally available
        instead of trying to download the data from the source site.
        The default is `True`.

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        DataFrame with each row representing one observation and each column
        representing the asset price of a given observation.
    """
    # Use a CORS proxy when triggering requests from the browser
    url_prefix = "https://corsproxy.io/?" if sys.platform == "emscripten" else ""
    url = url_prefix + (
        f"https://github.com/skfolio/skfolio-datasets/raw/main/"
        f"datasets/{data_filename}.csv.gz"
    )

    data_home = get_data_home(data_home=data_home)
    filepath = os.path.join(data_home, f"{data_filename}.pkz")

    if os.path.exists(filepath):
        return joblib.load(filepath)

    if not download_if_missing:
        raise OSError("Data not found and `download_if_missing` is False")

    archive_path = os.path.join(data_home, os.path.basename(url))
    ur.urlretrieve(url, archive_path)
    df = load_gzip_compressed_csv_data(archive_path)
    joblib.dump(df, filepath, compress=6)
    os.remove(archive_path)
    return df

def load_nasdaq_dataset(data_home=None, download_if_missing=True) -> pd.DataFrame:
    """Load the prices of 1455 assets from the NASDAQ Composite Index.

    This dataset is composed of the daily prices of 1455 assets from the NASDAQ
    Composite starting from 2018-01-02 up to 2023-05-31.

    The data comes from the Yahoo public API.
    The price is the adjusted close which is the closing price after adjustments for
    all applicable splits and dividend distributions.
    The adjustment uses appropriate split and dividend multipliers, adhering to
    the Center for Research in Security Prices (CRSP) standards.

    ==============   ==================
    Observations     1362
    Assets           1455
    ==============   ==================

    Parameters
    ----------
    data_home : str, optional
        Specify another download and cache folder for the datasets.
        By default, all skfolio data is stored in `~/skfolio_data` subfolders.

    download_if_missing : bool, default=True
        If False, raise an OSError if the data is not locally available
        instead of trying to download the data from the source site.

    Returns
    -------
    df : DataFrame of shape (n_observations, n_assets)
        Prices DataFrame

    Examples
    --------
    >>> from skfolio.datasets import load_nasdaq_dataset
    >>> prices = load_nasdaq_dataset()
    >>> prices.head()
                   AAL   AAOI    AAON    AAPL  ...  ZVRA   ZYME    ZYNE   ZYXI
    Date                                       ...
    2018-01-02  51.648  37.91  35.621  41.310  ...  66.4  7.933  12.995  2.922
    2018-01-03  51.014  37.89  36.247  41.303  ...  72.8  7.965  13.460  2.913
    2018-01-04  51.336  38.38  36.103  41.495  ...  78.4  8.430  12.700  2.869
    2018-01-05  51.316  38.89  36.681  41.967  ...  77.6  8.400  12.495  2.780
    2018-01-08  50.809  38.37  36.103  41.811  ...  82.4  8.310  12.550  2.825
    """
    data_filename = "nasdaq_dataset"
    df = download_dataset(
        data_filename, data_home=data_home, download_if_missing=download_if_missing
    )
    return df


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 format_measure(x: float, percent: bool = False) -> str:
    """Format a measure number into a user-friendly string.

    Parameters
    ----------
    x : float
        Number to format.

    percent : bool, default=False
        If this is set to True, the number is formatted in percentage.

    Returns
    -------
    formatted : str
        Formatted string.
    """
    if np.isnan(x):
        return str(x)
    if percent:
        xn = x * 100
        f = "%"
    else:
        xn = x
        f = "f"
    if xn == 0:
        n = 0
    else:
        n = optimal_rounding_decimals(xn)
    return "{value:{fmt}}".format(value=x, fmt=f".{n}{f}")

def safe_indexing(
    X: npt.ArrayLike | pd.DataFrame, indices: npt.ArrayLike | None, axis: int = 0
):
    """Return rows, items or columns of X using indices.

    Parameters
    ----------
    X : array-like
        Data from which to sample rows.

    indices : array-like, optional
        Indices of rows or columns.
        The default (`None`) is to select the entire data.

    axis : int, default=0
        The axis along which `X` will be sub-sampled. `axis=0` will select
        rows while `axis=1` will select columns.

    Returns
    -------
    subset :
        Subset of X on axis 0.
    """
    if indices is None:
        return X
    if hasattr(X, "iloc"):
        return X.take(indices, axis=axis)
    if axis == 0:
        return X[indices]
    return X[:, indices]

def safe_split(
    X: npt.ArrayLike,
    y: npt.ArrayLike | None = None,
    indices: np.ndarray | None = None,
    axis: int = 0,
):
    """Create subset of dataset.

    Slice X, y according to indices for cross-validation.

    Parameters
    ----------
    X : array-like
        Data to be indexed.

    y : array-like
        Data to be indexed.

    indices : ndarray of int, optional
        Rows or columns to select from X and y.
        The default (`None`) is to select the entire data.

    axis : int, default=0
        The axis along which `X` will be sub-sampled. `axis=0` will select
        rows while `axis=1` will select columns.

    Returns
    -------
    X_subset : array-like
        Indexed data.

    y_subset : array-like
        Indexed targets.
    """
    X_subset = safe_indexing(X, indices=indices, axis=axis)
    if y is not None:
        y_subset = safe_indexing(y, indices=indices, axis=axis)
    else:
        y_subset = None
    return X_subset, y_subset