"""Base classes and utilities for portfolio optimization estimators.
This module defines the abstract `BaseOptimization` estimator that all
optimization algorithms in skfolio should inherit from.
"""
# Copyright (c) 2023-2026
# Author: Hugo Delatte <hugo.delatte@skfoliolabs.com>
# SPDX-License-Identifier: BSD-3-Clause
# Implementation derived from:
# scikit-portfolio, Copyright (c) 2022, Carlo Nicolini, Licensed under MIT Licence.
# scikit-learn, Copyright (c) 2007-2010 David Cournapeau, Fabian Pedregosa, Olivier
# Grisel Licensed under BSD 3 clause.
from __future__ import annotations
import warnings
from abc import ABC, abstractmethod
from collections.abc import Mapping
from functools import wraps
from typing import Any, Literal
import numpy as np
import pandas as pd
import sklearn as sk
import sklearn.base as skb
from sklearn.utils.validation import check_is_fitted
import skfolio.typing as skt
from skfolio._constants import (
_MANAGEMENT_FEES,
_PREVIOUS_WEIGHTS,
_RISK_FREE_RATE,
_TRANSACTION_COSTS,
)
from skfolio.measures import RatioMeasure
from skfolio.population import Population
from skfolio.portfolio import FailedPortfolio, Portfolio
from skfolio.prior import ReturnDistribution
from skfolio.typing import ArrayLike, FloatArray, StrArray
from skfolio.utils.tools import input_to_array
[docs]
class BaseOptimization(skb.BaseEstimator, ABC):
"""Base class for all portfolio optimizations in skfolio.
Parameters
----------
portfolio_params : dict, optional
Portfolio parameters forwarded to the resulting `Portfolio` in `predict`.
If not provided and if available on the estimator, the following attributes are
propagated to the portfolio by default: `name`, `transaction_costs`,
`management_fees`, `previous_weights` and `risk_free_rate`.
fallback : BaseOptimization | "previous_weights" | list[BaseOptimization | "previous_weights"], optional
Fallback estimator or a list of estimators to try, in order, when the primary
optimization raises during `fit`. Alternatively, use `"previous_weights"`
(alone or in a list) to fall back to the estimator's `previous_weights`.
When a fallback succeeds, its fitted `weights_` are copied back to the primary
estimator so that `fit` still returns the original instance. For traceability,
`fallback_` stores the successful estimator (or the string `"previous_weights"`)
and `fallback_chain_` stores each attempt with the associated outcome.
previous_weights : float | dict[str, float] | array-like of shape (n_assets,), optional
Previous asset weights. Some estimators use this to compute costs or turnover.
Additionally, when `fallback="previous_weights"`, failures will fall back to
these weights if provided.
raise_on_failure : bool, default=True
Controls error handling when fitting fails.
If True, any failure during `fit` is raised immediately, no `weights_` are
set and subsequent calls to `predict` will raise a `NotFittedError`.
If False, errors are not raised; instead, a warning is emitted, `weights_`
is set to `None` and subsequent calls to `predict` will return a
`FailedPortfolio`. When fallbacks are specified, this behavior applies only
after all fallbacks have been exhausted.
Attributes
----------
weights_ : ndarray of shape (n_assets,) or (n_optimizations, n_assets)
Weights of the assets.
n_features_in_ : int
Number of assets seen during `fit`.
feature_names_in_ : ndarray of shape (`n_features_in_`,)
Names of assets seen during `fit`. Defined only when `X`
has assets names that are all strings.
fallback_ : BaseOptimization | "previous_weights" | None
The fallback estimator instance, or the string `"previous_weights"`, that
produced the final result. `None` if no fallback was used.
fallback_chain_ : list[tuple[str, str]] | None
Sequence describing the optimization fallback attempts. Each element is a
pair `(estimator_repr, outcome)` where `estimator_repr` is the string
representation of the primary estimator or a fallback (e.g. `"EqualWeighted()"`,
`"previous_weights"`), and `outcome` is `"success"` if that step produced
a valid solution, otherwise the stringified error message. For successful
fits without any fallback, this is `None`.
error_ : str | list[str] | None
Captured error message(s) when `fit` fails. For multi-portfolio outputs
(`weights_` is 2D), this is a list aligned with portfolios.
Notes
-----
All estimators should specify all parameters as explicit keyword arguments in
`__init__` (no `*args` or `**kwargs`), following scikit-learn conventions.
"""
weights_: FloatArray
n_features_in_: int
feature_names_in_: StrArray
fallback_: BaseOptimization | Literal["previous_weights"] | None
fallback_chain_: list[tuple[str, str]] | None
error_: str | list[str] | None
def __init__(
self,
portfolio_params: dict | None = None,
fallback: skt.Fallback = None,
previous_weights: skt.MultiInput | None = None,
raise_on_failure: bool = True,
):
self.portfolio_params = portfolio_params
self.fallback = fallback
self.previous_weights = previous_weights
self.raise_on_failure = raise_on_failure
# Automatically wrap all subclasses' fit to add fallback behavior
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
original_fit = cls.__dict__.get("fit")
if original_fit is None or getattr(original_fit, "_fallback_wrapped", False):
return
@wraps(original_fit)
def _wrapped_fit(self, X: ArrayLike, y: ArrayLike | None = None, **fit_params):
self.fallback_ = None
self.fallback_chain_ = None
self.error_ = None
try:
original_fit(self, X, y, **fit_params)
except Exception as primary_error:
try:
self._run_fallback_chain(
X=X, y=y, primary_error=primary_error, **fit_params
)
except Exception as last_error:
self.error_ = str(last_error)
if self.raise_on_failure:
raise
warnings.warn(
(
f"{self.__class__.__name__}.fit failed: {last_error}. "
"Because raise_on_failure=False, weights_ is set to None. "
"Inspect 'error_' and 'fallback_chain_' for details."
),
stacklevel=2,
)
self.weights_ = None
return self
_wrapped_fit._fallback_wrapped = True
cls.fit = _wrapped_fit
def _run_fallback_chain(
self,
X: ArrayLike,
y: ArrayLike | None,
primary_error: Exception,
**fit_params,
) -> None:
"""Execute the configured fallback chain after a primary `fit` failure.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Training data passed to `fit`.
y : array-like or None
Optional target data.
primary_error : Exception
The exception raised by the primary estimator.
**fit_params : dict
Additional keyword arguments forwarded to each fallback's `fit`.
Raises
------
Exception
Re-raises the last encountered error if all fallbacks fail.
"""
fallback = self.fallback
if fallback is None:
raise primary_error
# Log the primary error in fallback_chain_ only when fallbacks are provided
self.fallback_chain_ = [(str(self), str(primary_error))]
n_assets = X.shape[1]
if not isinstance(fallback, list | tuple):
fallback = [fallback]
if len(fallback) == 0:
raise primary_error
last_error: Exception = primary_error
for fb in fallback:
try:
fb = _validate_fallback(fb)
if fb == _PREVIOUS_WEIGHTS:
self._fallback_to_previous_weights_or_raise(n_assets=n_assets)
return
fb_est = sk.clone(fb)
# previous_weights are propagated to the fallbacks
if self.previous_weights is not None:
if fb_est.previous_weights is not None:
warnings.warn(
(
"previous_weights are automatically propagated to "
"fallback estimators. To silence this warning, leave "
"the fallback's previous_weights as None."
),
stacklevel=2,
)
fb_est.set_params(previous_weights=self.previous_weights)
fb_est.fit(X, y, **fit_params)
# Success: copy learned artifacts back to self
for name in ("weights_", "n_features_in_", "feature_names_in_"):
setattr(self, name, getattr(fb_est, name))
self.fallback_ = fb_est
self.fallback_chain_.append((str(fb_est), "success"))
return
except Exception as err: # try next fallback
last_error = err
self.fallback_chain_.append((str(fb), str(err)))
continue
# All fallbacks failed
if last_error is not None:
# Defer raising to the caller which decides based on raise_on_failure
raise last_error
raise RuntimeError(
"All fallback estimators failed; inspect 'fallback_chain_' for details."
)
def _fallback_to_previous_weights_or_raise(self, n_assets: int) -> None:
"""Fallback to `previous_weights` or raise if unavailable/invalid.
Parameters
----------
n_assets : int
Number of assets used to validate the shape of `previous_weights`.
Raises
------
RuntimeError
If `previous_weights` is `None` when the fallback is requested.
"""
try:
if self.previous_weights is None:
raise RuntimeError(
"Fallback 'previous_weights' requested, but 'previous_weights' is None. "
"Provide valid previous weights or remove this fallback."
)
investable_mask = getattr(self, "investable_mask_", None)
if investable_mask is not None:
n_assets = int(np.count_nonzero(investable_mask))
weights = self._clean_previous_weights(n_assets=n_assets)
self.weights_ = self._expand_weights_to_full_universe(weights=weights)
self.fallback_ = _PREVIOUS_WEIGHTS
self.fallback_chain_.append((_PREVIOUS_WEIGHTS, "success"))
except Exception as error:
self.fallback_chain_.append((_PREVIOUS_WEIGHTS, str(error)))
raise
@abstractmethod
def fit(self, X: ArrayLike, y: ArrayLike | None = None):
pass
[docs]
def predict(self, X: ArrayLike | ReturnDistribution) -> Portfolio | Population:
"""Predict the `Portfolio` or a `Population` of portfolios on `X`.
Optimization estimators can return a 1D or a 2D array of `weights`.
For a 1D array, the prediction is a single `Portfolio`.
For a 2D array, the prediction is a `Population` of `Portfolio`.
If `name` is not provided in the portfolio parameters, the estimator
class name is used.
Parameters
----------
X : array-like of shape (n_observations, n_assets) | ReturnDistribution
Asset returns or a `ReturnDistribution` carrying returns and optional
sample weights.
Returns
-------
Portfolio | Population
The predicted `Portfolio` or `Population` based on the fitted `weights`.
"""
check_is_fitted(self, "weights_")
if self.portfolio_params is None:
ptf_kwargs = {}
else:
ptf_kwargs = self.portfolio_params.copy()
# Set X and sample_weight
if isinstance(X, ReturnDistribution):
ptf_kwargs["sample_weight"] = X.sample_weight
if hasattr(self, "feature_names_in_"):
ptf_kwargs["X"] = pd.DataFrame(
X.returns, columns=self.feature_names_in_
)
else:
ptf_kwargs["X"] = X.returns
else:
ptf_kwargs["X"] = X
# Set the default portfolio parameters equal to the optimization parameters
for param in [
_TRANSACTION_COSTS,
_MANAGEMENT_FEES,
_PREVIOUS_WEIGHTS,
_RISK_FREE_RATE,
]:
if param not in ptf_kwargs and hasattr(self, param):
ptf_kwargs[param] = getattr(self, param)
# If 'name' is not provided in the portfolio arguments, we use the first
name = ptf_kwargs.pop("name", type(self).__name__)
# Add fallback chain (partial_fit doesn't set fallback_chain_)
ptf_kwargs["fallback_chain"] = getattr(self, "fallback_chain_", None)
# If weights are None and raise_on_failure is False, we return a FailedPortfolio
if self.weights_ is None:
return FailedPortfolio(
name=name, optimization_error=self.error_, **ptf_kwargs
)
# Optimization estimators can return a 1D or a 2D array of weights.
# For a 1D array we return a portfolio.
if self.weights_.ndim == 1:
return Portfolio(weights=self.weights_, name=name, **ptf_kwargs)
# For a 2D array we return a population of portfolios.
n_portfolios = self.weights_.shape[0]
population = Population([])
for i in range(n_portfolios):
ptf_name = f"ptf{i} - {name}"
if np.isnan(self.weights_[i]).all():
error = self.error_[i] if isinstance(self.error_, list) else None
population.append(
FailedPortfolio(
name=ptf_name,
optimization_error=error,
**ptf_kwargs,
)
)
else:
population.append(
Portfolio(weights=self.weights_[i], name=ptf_name, **ptf_kwargs)
)
return population
[docs]
def score(self, X: ArrayLike | ReturnDistribution, y: ArrayLike = None) -> float:
"""Prediction score using the Sharpe Ratio.
If the prediction is a single `Portfolio`, the score is its Sharpe Ratio.
If the prediction is a `Population`, the score is the mean Sharpe Ratio
across portfolios.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
y : Ignored
Not used, present here for API consistency by convention.
Returns
-------
score : float
The Sharpe Ratio of the portfolio if the prediction is a single `Portfolio`
or the mean of all the portfolio Sharpe Ratios if the prediction is a
`Population` of `Portfolio`.
"""
result = self.predict(X)
if isinstance(result, Population):
return result.measures_mean(RatioMeasure.SHARPE_RATIO)
return result.sharpe_ratio
[docs]
def fit_predict(self, X):
"""Perform `fit` on `X` and returns the predicted `Portfolio` or
`Population` of `Portfolio` on `X` based on the fitted `weights`.
For factor models, use `fit(X, factors=...)` then `predict(X)` separately.
If fitting fails and `raise_on_failure=False`, this returns a
`FailedPortfolio`.
Parameters
----------
X : array-like of shape (n_observations, n_assets)
Price returns of the assets.
Returns
-------
Portfolio | Population
The predicted `Portfolio` or `Population` based on the fitted `weights`.
"""
return self.fit(X).predict(X)
@property
def needs_previous_weights(self) -> bool:
"""Whether `previous_weights` must be propagated between folds/rebalances.
Used by `cross_val_predict` to decide whether to run sequentially and pass
the weights from the previous rebalancing to the next. This is `True` when
transaction costs, a maximum turnover, or a fallback depending on
`previous_weights` are present.
"""
if _has_transaction_cost(getattr(self, _TRANSACTION_COSTS, None)):
return True
if getattr(self, "max_turnover", None) is not None:
return True
fallback = self.fallback
if fallback is not None:
if not isinstance(fallback, list | tuple):
fallback = [fallback]
for fb in fallback:
fb = _validate_fallback(fb)
if fb == _PREVIOUS_WEIGHTS or fb.needs_previous_weights:
return True
return False
def _prepare_investable_distribution(
self, return_distribution: ReturnDistribution, slim: bool = False
) -> ReturnDistribution:
"""Prepare the return distribution used by the optimizer.
The input `return_distribution` is defined on the full asset universe. This
method stores its `investable_mask` in `investable_mask_`, then returns the
distribution restricted to assets that can be used in the optimization problem.
Downstream helpers use `investable_mask_` to map user inputs and optimized
weights between the full universe and the investable subset.
Parameters
----------
return_distribution : ReturnDistribution
Full-universe return distribution. Non-investable assets may be represented
by NaNs in `mu`, `covariance` or both.
slim : bool, default=False
If True, drop heavy diagnostic fields from the nested factor model when the
investable subset is built.
Returns
-------
ReturnDistribution
Return distribution restricted to investable assets.
"""
self.investable_mask_ = return_distribution.investable_mask
return return_distribution.investable_subset(slim=slim)
def _expand_weights_to_full_universe(self, weights: FloatArray) -> FloatArray:
"""Expand investable-subset weights to the full asset universe.
Optimization is performed on the investable subset prepared by
`_prepare_investable_distribution`. This method maps the optimized weights back
to the full universe, filling non-investable positions with zero so that
`weights_` stays aligned with the original assets passed to `fit`.
If `investable_mask_` is missing or None, all assets are investable and
`weights` is returned unchanged.
Parameters
----------
weights : ndarray of shape (n_investable_assets,) or (..., n_investable_assets)
Optimized weights on the investable subset.
Returns
-------
ndarray of shape (n_assets,) or (..., n_assets)
Weights aligned with the full asset universe.
"""
investable_mask = getattr(self, "investable_mask_", None)
if investable_mask is None:
return weights
n_full_universe = len(investable_mask)
if weights.ndim == 1:
full_weights = np.zeros(n_full_universe, dtype=weights.dtype)
full_weights[investable_mask] = weights
else:
full_weights = np.zeros(
(*weights.shape[:-1], n_full_universe), dtype=weights.dtype
)
full_weights[..., investable_mask] = weights
return full_weights
def _clean_input(
self,
value: float | dict | ArrayLike | None,
n_assets: int,
fill_value: Any,
name: str,
) -> float | FloatArray:
"""Convert input to a cleaned float or 1D ndarray.
When `investable_mask_` has been set (by `_prepare_investable_distribution`),
dictionary keys are resolved against the full-universe names and the result is
subsetted and array-like inputs sized for the full universe are sliced to match.
Parameters
----------
value : float | dict | array-like | None
Input value to clean.
n_assets : int
Number of investable assets. Used to verify the shape of the converted array.
fill_value : Any
When `value` is a dictionary, keys not present in the asset names are filled
with `fill_value` in the converted array.
name : str
Name used for error messages.
Returns
-------
float | ndarray of shape (n_assets,)
The cleaned scalar or 1D array.
"""
if value is None:
return fill_value
if np.isscalar(value):
return float(value)
return input_to_array(
items=value,
n_assets=n_assets,
fill_value=fill_value,
dim=1,
assets_names=getattr(self, "feature_names_in_", None),
investable_mask=getattr(self, "investable_mask_", None),
name=name,
)
def _clean_previous_weights(self, n_assets: int) -> FloatArray:
"""Return validated previous weights as a 1D array of length `n_assets`.
Converts `previous_weights` to a numpy array using `_clean_input`, accepting
scalars, mappings keyed by asset name, or array-like inputs. Scalars are
broadcast to all assets. Missing assets in mappings are filled with zeros.
Parameters
----------
n_assets : int
Number of assets; used to validate shape and for broadcasting.
Returns
-------
ndarray of shape (n_assets,)
Cleaned previous weights.
"""
previous_weights = self._clean_input(
self.previous_weights,
n_assets=n_assets,
fill_value=0,
name=_PREVIOUS_WEIGHTS,
)
if np.isscalar(previous_weights):
previous_weights = np.full(n_assets, float(previous_weights))
return previous_weights
def _validate_fallback(
fallback: Literal["previous_weights"] | BaseOptimization,
) -> Literal["previous_weights"] | BaseOptimization:
"""Validate the fallback specification.
Parameters
----------
fallback : BaseOptimization | "previous_weights"
The configured fallback.
Returns
-------
BaseOptimization | "previous_weights"
The validated fallback, unchanged.
Raises
------
ValueError
If `fallback` is a string different from `"previous_weights"`.
TypeError
If `fallback` is not a string and not an instance of `BaseOptimization`.
"""
if isinstance(fallback, str):
if fallback != _PREVIOUS_WEIGHTS:
raise ValueError(
f"Unsupported string fallback: {fallback!r}. Only 'previous_weights' is allowed."
)
return _PREVIOUS_WEIGHTS
if not isinstance(fallback, BaseOptimization):
raise TypeError(
f"Fallback estimators must inherit from BaseOptimization (got {type(fallback).__name__})."
)
return fallback
def _has_transaction_cost(x: Any) -> bool:
"""Return True if any non-zero transaction cost is present in `x`.
Accepts scalars, arrays, nested mappings, or structures convertible to arrays.
Zero or empty values are treated as no cost.
"""
if x is None:
return False
if isinstance(x, Mapping):
# Empty dict -> no costs; otherwise recurse
return any(_has_transaction_cost(v) for v in x.values())
try:
arr = np.asarray(x, dtype=float)
except Exception:
# If coercion fails, assume non-zero to be conservative
return True
if arr.size == 0:
return False
return not np.allclose(arr, 0.0, atol=1e-15, rtol=1e-18, equal_nan=False)