Optimization#
The optimization module implements a set of methods intended for portfolio optimization.
They follow the same API as scikit-learn’s estimator: the fit method takes X as
the assets returns and stores the portfolio weights in its weights_ attribute.
X can be any array-like structure (numpy array, pandas DataFrame, etc.)
All optimization inputs (expected returns, covariance, return scenarios) are expressed
in the periodicity of X: with daily returns, the optimizer works with daily moments
and scenarios rather than annualized ones. Parameters that share the unit of expected
returns, such as transaction_costs and management_fees, must be expressed in the
same periodicity. See Periodicity Convention for the
rationale and the cost conversion rules.
Naive Allocation#
The naive module implements a set of naive allocations commonly used as benchmarks for comparing different models:
Example:
Naive inverse-volatility allocation:
from sklearn.model_selection import train_test_split
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import InverseVolatility
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = InverseVolatility()
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
Mean-Risk Optimization#
The MeanRisk estimator can solve the below 4 objective functions:
Minimize Risk:
\[\begin{split}\begin{cases} \begin{aligned} &\min_{w} & & risk_{i}(w) \\ &\text{s.t.} & & w^T\mu \ge min\_return \\ & & & A w \ge b \\ & & & risk_{j}(w) \le max\_risk_{j} \quad \forall \; j \ne i \end{aligned} \end{cases}\end{split}\]
Maximize Expected Return:
\[\begin{split}\begin{cases} \begin{aligned} &\max_{w} & & w^T\mu \\ &\text{s.t.} & & risk_{i}(w) \le max\_risk_{i} \\ & & & A w \ge b \\ & & & risk_{j}(w) \le max\_risk_{j} \quad \forall \; j \ne i \end{aligned} \end{cases}\end{split}\]
Maximize Utility:
\[\begin{split}\begin{cases} \begin{aligned} &\max_{w} & & w^T\mu - \lambda \times risk_{i}(w)\\ &\text{s.t.} & & risk_{i}(w) \le max\_risk_{i} \\ & & & w^T\mu \ge min\_return \\ & & & A w \ge b \\ & & & risk_{j}(w) \le max\_risk_{j} \quad \forall \; j \ne i \end{aligned} \end{cases}\end{split}\]
Maximize Ratio:
\[\begin{split}\begin{cases} \begin{aligned} &\max_{w} & & \frac{w^T\mu - r_{f}}{risk_{i}(w)}\\ &\text{s.t.} & & risk_{i}(w) \le max\_risk_{i} \\ & & & w^T\mu \ge min\_return \\ & & & A w \ge b \\ & & & risk_{j}(w) \le max\_risk_{j} \quad \forall \; j \ne i \end{aligned} \end{cases}\end{split}\]
With \(risk_{i}\) a risk measure among:
Variance
Semi-Variance
Standard-Deviation
Semi-Deviation
Mean Absolute Deviation
First Lower Partial Moment
CVaR (Conditional Value at Risk)
EVaR (Entropic Value at Risk)
Worst Realization (worst return)
CDaR (Conditional Drawdown at Risk)
Maximum Drawdown
Average Drawdown
EDaR (Entropic Drawdown at Risk)
Ulcer Index
Gini Mean Difference
It supports the following parameters:
Weight Constraints
Budget Constraints
Group Constraints
Transaction Costs
Management Fees
L1 and L2 Regularization
Turnover Constraint
Tracking Error Constraint
Uncertainty Set on Expected Returns
Uncertainty Set on Covariance
Expected Return Constraints
Risk Measure Constraints
Custom Objective
Custom Constraints
Prior Estimator
Example:
Maximum Sharpe Ratio portfolio:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = MeanRisk(
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
risk_measure=RiskMeasure.VARIANCE,
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.sharpe_ratio)
Prior Estimator#
Every portfolio optimization has a parameter named prior_estimator.
The prior estimator fits a ReturnDistribution containing
estimates of expected asset returns, covariance matrix, returns and Cholesky
decomposition of the covariance. It represents the investor’s prior beliefs about the
model used to estimate such distribution.
When the prior follows the native NaN-aware convention, compatible optimizers solve the
optimization problem on the investable subset and expand weights_ back to the full
input universe. See Missing Data and Changing Universes for
details.
The available prior estimators are:
Example:
Minimum Variance portfolio using a Factor Model:
from sklearn.model_selection import train_test_split
from skfolio.datasets import load_factors_dataset, load_sp500_dataset
from skfolio.optimization import MeanRisk
from skfolio.preprocessing import prices_to_returns
from skfolio.prior import TimeSeriesFactorModel
prices = load_sp500_dataset()
factor_prices = load_factors_dataset()
X, factors = prices_to_returns(prices, factor_prices)
X_train, X_test, factors_train, factors_test = train_test_split(X, factors, test_size=0.33, shuffle=False)
model = MeanRisk(prior_estimator=TimeSeriesFactorModel())
model.fit(X_train, factors=factors_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
Combining Prior Estimators#
Prior estimators can be combined together, making it possible to design complex models:
Example:
This example is purposely complex to demonstrate how multiple estimators can be combined.
The model below is a Maximum Sharpe Ratio optimization using a Factor Model for the estimation of the assets expected returns and covariance matrix. A Black & Litterman model is used for the estimation of the factors expected returns and covariance matrix, incorporating the analysts’ views on the factors. Finally, the Black & Litterman prior expected returns are estimated using an equal-weighted market equilibrium with a risk aversion of 2 and a denoised prior covariance matrix:
from sklearn.model_selection import train_test_split
from skfolio.datasets import load_factors_dataset, load_sp500_dataset
from skfolio.moments import DenoiseCovariance, EquilibriumMu
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
from skfolio.prior import BlackLitterman, EmpiricalPrior, TimeSeriesFactorModel
prices = load_sp500_dataset()
factor_prices = load_factors_dataset()
X, factors = prices_to_returns(prices, factor_prices)
X_train, X_test, factors_train, factors_test = train_test_split(X, factors, test_size=0.33, shuffle=False)
factor_views = ["MTUM - QUAL == 0.0003 ",
"SIZE - USMV == 0.0004",
"VLUE == 0.0006"]
model = MeanRisk(
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
prior_estimator=TimeSeriesFactorModel(
factor_prior_estimator=BlackLitterman(
prior_estimator=EmpiricalPrior(
mu_estimator=EquilibriumMu(risk_aversion=2),
covariance_estimator=DenoiseCovariance()
),
views=factor_views)
)
)
model.fit(X_train, factors=factors_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
Custom Estimator#
It is very common to use a custom implementation for the moments estimators. For example, you may want to use an in-house estimation for the covariance or a predictive model for the expected returns.
Below is a simple example of how you would implement a custom covariance estimator. For more complex cases and estimators, check the API Reference.
import numpy as np
from sklearn.utils.validation import validate_data
from skfolio.datasets import load_sp500_dataset
from skfolio.moments import BaseCovariance
from skfolio.optimization import MeanRisk
from skfolio.preprocessing import prices_to_returns
from skfolio.prior import EmpiricalPrior
prices = load_sp500_dataset()
X = prices_to_returns(prices)
class MyCustomCovariance(BaseCovariance):
def __init__(self, my_param=0):
super().__init__()
self.my_param = my_param
def fit(self, X, y=None):
X = validate_data(self, X)
# Your custom implementation goes here
covariance = np.cov(X.T, ddof=self.my_param)
self._set_covariance(covariance)
return self
model = MeanRisk(
prior_estimator=EmpiricalPrior(covariance_estimator=MyCustomCovariance(my_param=1)),
)
model.fit(X)
Worst-Case Optimization#
With the mu_uncertainty_set_estimator parameter, the expected returns of the assets
are modeled with a norm-ball uncertainty set. This
approach is known as worst-case optimization and falls under the class of robust
optimization. It mitigates the instability that arises from estimation errors of the
expected returns.
Example:
Worst-case maximum Mean/CDaR ratio (Conditional Drawdown at Risk) with an ellipsoidal uncertainty set for the expected returns of the assets:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
from skfolio.uncertainty_set import BootstrapMuUncertaintySet
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = MeanRisk(
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
risk_measure=RiskMeasure.CDAR,
mu_uncertainty_set_estimator=BootstrapMuUncertaintySet(confidence_level=0.9),
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
print(portfolio.cdar_ratio)
Covariance uncertainty is configured with covariance_uncertainty_set_estimator.
It is applied to the variance risk measure or a max_variance constraint. Generic
estimators use a lifted semidefinite formulation, while
OrthogonalCovarianceUncertaintySet uses a compact
representation in the factor model’s orthogonal space.
Going Further#
You can explore the remaining parameters (constraints, L1 and L2 regularization, costs,
turnover, tracking error, etc.) with the
Mean-Risk examples and the MeanRisk API.
Risk Budgeting#
The RiskBudgeting solves the below convex problem:
\[\begin{split}\begin{cases} \begin{aligned} &\min_{w} & & risk_{i}(w) \\ &\text{s.t.} & & b^T log(w) \ge c \\ & & & w^T\mu \ge min\_return \\ & & & A w \ge b \\ & & & w \ge0 \end{aligned} \end{cases}\end{split}\]
with \(b\) the risk budget vector and \(c\) an auxiliary variable of the log barrier.
And \(risk_{i}\) a risk measure among:
Variance
Semi-Variance
Standard-Deviation
Semi-Deviation
Mean Absolute Deviation
First Lower Partial Moment
CVaR (Conditional Value at Risk)
EVaR (Entropic Value at Risk)
Worst Realization (worst return)
CDaR (Conditional Drawdown at Risk)
Maximum Drawdown
Average Drawdown
EDaR (Entropic Drawdown at Risk)
Ulcer Index
Gini Mean Difference
First Lower Partial Moment
It supports the following parameters:
Weight Constraints
Budget Constraints
Group Constrains
Transaction Costs
Management Fees
Expected Return Constraints
Custom Objective
Custom constraints
Prior Estimator
Limitations are imposed on certain constraints, such as long-only weights, to ensure the problem remains convex.
Example:
CVaR (Conditional Value at Risk) Risk Parity portfolio:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import RiskBudgeting
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = RiskBudgeting(risk_measure=RiskMeasure.CVAR)
model.fit(X_train)
print(model.weights_)
portfolio_train = model.predict(X_train)
print(portfolio_train.annualized_sharpe_ratio)
print(portfolio_train.contribution(measure=RiskMeasure.CVAR))
portfolio_test = model.predict(X_test)
print(portfolio_test.annualized_sharpe_ratio)
print(portfolio_test.contribution(measure=RiskMeasure.CVAR))
Maximum Diversification#
The MaximumDiversification maximizes the diversification ratio, which is the
ratio of the weighted volatilities over the total volatility.
Example:
from sklearn.model_selection import train_test_split
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import MaximumDiversification
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = MaximumDiversification()
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.diversification)
Distributionally Robust CVaR#
The DistributionallyRobustCVaR constructs a Wasserstein ball in the space of
multivariate and non-discrete probability distributions centered at the uniform
distribution on the training samples and finds the allocation that minimizes the CVaR
of the worst-case distribution within this Wasserstein ball.
Esfahani and Kuhn proved that for piecewise linear objective functions,
which is the case of CVaR, the distributionally robust optimization problem
over a Wasserstein ball can be reformulated as finite convex programs.
A solver like Mosek that can handle a high number of constraints is preferred.
Example:
from sklearn.model_selection import train_test_split
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import DistributionallyRobustCVaR
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X = X["2020":]
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = DistributionallyRobustCVaR(wasserstein_ball_radius=0.01)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.cvar)
Hierarchical Risk Parity#
The HierarchicalRiskParity (HRP) is a portfolio optimization method developed
by Marcos Lopez de Prado.
This algorithm uses a distance matrix to compute hierarchical clusters using the Hierarchical Tree Clustering algorithm then employs seriation to rearrange the assets in the dendrogram, minimizing the distance between leaves.
The final step is the recursive bisection where each cluster is split between two sub-clusters by starting with the topmost cluster and traversing in a top-down manner. For each sub-cluster, we compute the total cluster risk of an inverse-risk allocation. A weighting factor is then computed from these two sub-cluster risks, which is used to update the cluster weight.
Note
The original paper uses the variance as the risk measure and the single-linkage method for the Hierarchical Tree Clustering algorithm. Here we generalize it to multiple risk measures and linkage methods. The default linkage method is set to the Ward variance minimization algorithm, which is more stable and has better properties than the single-linkage method.
It supports all prior estimators and risk measures as well as weight constraints.
It also supports all distance estimators through the
distance_estimator parameter. It fits a distance model for the
estimation of the codependence and the distance matrix used to compute the linkage
matrix:
Example:
Hierarchical Risk Parity with semi (downside) standard-deviation as the risk measure and mutual information as the distance estimator:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.distance import MutualInformation
from skfolio.optimization import HierarchicalRiskParity
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = HierarchicalRiskParity(
risk_measure=RiskMeasure.SEMI_DEVIATION, distance_estimator=MutualInformation()
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
print(portfolio.contribution(measure=RiskMeasure.SEMI_DEVIATION))
Seriation for HRP and Schur#
HierarchicalRiskParity and
SchurComplementary accept a seriation_estimator
to choose the asset order used by recursive allocation. The default,
HierarchicalSeriation, preserves Ward linkage and
optimal leaf ordering. SpectralSeriation orders a
distance matrix using a spectral coordinate and aligns its orientation across
partial_fit calls.
The distance estimator can use three different inputs:
Prior return scenarios (default). With
distance_from_prior=True, the distance estimator is fitted on the return scenarios produced by the prior. This accepts any distance estimator fitted from returns, such asPearsonDistance,SpearmanDistanceorCovarianceDistanceconfigured with a covariance estimator.Prior covariance. Set
distance_estimator=CovarianceDistance(covariance_estimator="precomputed")to convert the prior’s covariance into distances without estimating another covariance. This configuration requiresdistance_from_prior=Truefor bothfitandpartial_fit.Input returns. Set
distance_from_prior=Falseto fit the distance estimator on theXargument supplied to the optimization estimator’sfit(X)orpartial_fit(X), before the prior processes it. In a pipeline,Xis the output of the earlier steps. Forfit, the same distance estimators as in the prior return scenarios option are supported. Forpartial_fit, the distance must support incremental learning, as described below.
In all three cases, portfolio allocation uses the prior’s moments and return scenarios.
For example, a factor-model prior can supply the allocation covariance while a separate covariance estimator measures dependence from input returns:
from skfolio.datasets import load_factors_dataset
from skfolio.distance import CovarianceDistance
from skfolio.moments import EWCovariance
from skfolio.optimization import SchurComplementary
from skfolio.prior import TimeSeriesFactorModel
from skfolio.seriation import SpectralSeriation
X, factors = prices_to_returns(load_sp500_dataset(), load_factors_dataset())
factor_prior = TimeSeriesFactorModel()
model = SchurComplementary(
prior_estimator=factor_prior,
distance_estimator=CovarianceDistance(EWCovariance()),
distance_from_prior=False,
seriation_estimator=SpectralSeriation(),
)
model.fit(X, factors=factors)
Custom distances inherit from BaseDistance. Override
its read-only requires_covariance_input property to return True when X must
contain covariance. The property must reflect the current parameters before
fitting. The base class derives the scikit-learn pairwise input tag from it.
HRP and Schur supply the prior covariance to these distances, which require
distance_from_prior=True.
See Seriation Estimators for the available algorithms, input requirements and online updates, and Seriation and Turnover for their potential effect on turnover.
HERC and NCO use clustering estimators because their allocations require clusters.
Online Learning#
HRP and Schur support partial_fit when their prior and its components support
incremental learning. Each call updates the prior once from new observations, then
computes distances, seriation and portfolio weights. Distance updates depend on the
chosen input:
With
distance_from_prior=True, a distance estimated from prior return scenarios is fitted afresh on the current scenarios. This supports batch-only distances, such asPearsonDistance, and priors that truncate or regenerate scenarios.With
CovarianceDistance("precomputed"), the optimizer calls the distance’sfiton the current prior covariance. No additional covariance is estimated, and the distance itself does not need to supportpartial_fit.With
distance_from_prior=False, the distance must supportpartial_fit, for exampleCovarianceDistance(EWCovariance()). It consumes each new batch once, including observations for assets that are not yet investable. Batch-only configurations, such asPearsonDistanceandCovarianceDistance()with its defaultGerberCovariance, requiredistance_from_prior=Trueduring online learning.
The optimizer updates SpectralSeriation through partial_fit and refits
HierarchicalSeriation through fit. Refitting the distance does not reset the
spectral estimator’s history. See Seriation Estimators for their respective behavior.
The prior needs enough warm-up observations to produce valid moments. A separate distance estimator must also provide the required distances for every investable asset.
With the default PearsonDistance, update cost grows with the prior’s stored history.
For long runs, CovarianceDistance("precomputed") avoids repeated estimation, or the
prior’s max_history can limit the number of scenarios.
For a worked example with late listings, delistings, holidays and asset warm-up, see Online Schur Allocation with a Changing Universe.
Hierarchical Equal Risk Contribution#
The HierarchicalEqualRiskContribution (HERC) is a portfolio optimization method
developed by Thomas Raffinot.
This algorithm uses a distance matrix to compute hierarchical clusters using the Hierarchical Tree Clustering algorithm. It then computes, for each cluster, the total cluster risk of an inverse-risk allocation.
The final step is the top-down recursive division of the dendrogram, where the assets weights are updated using a naive risk parity within clusters.
It differs from the Hierarchical Risk Parity by exploiting the dendrogram shape during the top-down recursive division instead of bisecting it.
Note
The default linkage method is set to the Ward variance minimization algorithm, which is more stable and has better properties than the single-linkage method.
It supports all prior estimators and risk measures as well as weight constraints.
It also supports all distance estimators through the
distance_estimator parameter. It fits a distance model for the
estimation of the codependence and the distance matrix used to compute the linkage
matrix:
Example:
Hierarchical Equal Risk Contribution with CVaR (Conditional Value at Risk) as the risk measure and mutual information as the distance estimator:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.distance import MutualInformation
from skfolio.optimization import HierarchicalEqualRiskContribution
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = HierarchicalEqualRiskContribution(
risk_measure=RiskMeasure.CVAR,
distance_estimator = MutualInformation()
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
print(portfolio.contribution(measure=RiskMeasure.CVAR))
Nested Clusters Optimization#
The NestedClustersOptimization (NCO) is a portfolio optimization method
developed by Marcos Lopez de Prado.
It uses a distance matrix to compute clusters using a clustering algorithm ( Hierarchical Tree Clustering, KMeans, etc.). For each cluster, the inner-cluster weights are computed by fitting the inner-estimator on each cluster using the whole training data. Then the outer-cluster weights are computed by training the outer-estimator using out-of-sample estimates of the inner-estimators with cross-validation. Finally, the final assets weights are the dot-product of the inner-weights and outer-weights.
Note
The original paper uses KMeans as the clustering algorithm, minimum Variance for
the inner-estimator and equal-weighted for the outer-estimator. Here we generalize
it to all sklearn and skfolio clustering algorithms (Hierarchical Tree
Clustering, KMeans, etc.), all portfolio optimizations (Mean-Variance, HRP, etc.)
and risk measures (variance, CVaR, etc.).
To avoid data leakage at the outer-estimator, we use out-of-sample estimates to
fit the outer estimator.
It supports all distance estimators
and clustering estimator (both skfolio and sklearn)
Example:
Nested Clusters Optimization with KMeans as the clustering algorithm, Kendall Distance as the distance estimator, Minimum Semi-Variance as the inner estimator, and CVaR Risk Parity as the outer (meta) estimator trained on the out-of-sample estimates from the KFold cross-validation and run with parallelization:
from sklearn.cluster import KMeans
from sklearn.model_selection import KFold, train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.distance import KendallDistance
from skfolio.optimization import MeanRisk, NestedClustersOptimization, RiskBudgeting
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
model = NestedClustersOptimization(
inner_estimator=MeanRisk(risk_measure=RiskMeasure.SEMI_VARIANCE),
outer_estimator=RiskBudgeting(risk_measure=RiskMeasure.CVAR),
distance_estimator=KendallDistance(),
clustering_estimator=KMeans(n_init="auto"),
cv=KFold(),
n_jobs=-1,
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
print(portfolio.contribution(measure=RiskMeasure.CVAR))
The cv parameter can also be a combinatorial cross-validation, such as
CombinatorialPurgedCV, in which case each cluster’s
out-of-sample outputs are a collection of multiple paths instead of one single path.
The selected out-of-sample path among this collection of paths is chosen according to
the quantile and quantile_measure parameters.
Stacking Optimization#
StackingOptimization is an ensemble method that consists of stacking the outputs
of individual portfolio optimizations with a final portfolio optimization.
The final weights are the dot product of the individual optimizations’ weights and the final optimization’s weights.
Stacking leverages the strengths of each individual portfolio optimization by using their outputs as inputs to a final portfolio optimization.
To avoid data leakage, out-of-sample estimates are used to fit the outer optimization.
Example:
Stacking Optimization with Minimum Semi-Variance and CVaR Risk Parity stacked together using Minimum Variance as the final (meta) estimator.
from sklearn.model_selection import KFold, train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import MeanRisk, RiskBudgeting, StackingOptimization
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
estimators = [
('model1', MeanRisk(risk_measure=RiskMeasure.SEMI_VARIANCE)),
('model2', RiskBudgeting(risk_measure=RiskMeasure.CVAR))
]
model = StackingOptimization(
estimators=estimators,
final_estimator=MeanRisk(),
cv=KFold(),
n_jobs=-1
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
The cv parameter can also be a combinatorial cross-validation, such as
CombinatorialPurgedCV, in which case each out-of-sample outputs are a
collection of multiple paths instead of one single path. The selected out-of-sample path
among this collection of paths is chosen according to the quantile and
quantile_measure parameters.
Tracking Error Optimization#
Tracking error measures the deviation between a portfolio’s performance and a benchmark.
skfolio provides three approaches for tracking error optimization:
Return-based tracking error constraint (via
max_tracking_error): Constrains the tracking error while optimizing another objective (e.g., minimize CVaR).Weight-based target (via
target_weights): Minimizes tracking error by finding weights that minimize deviation from a target portfolio allocation.Return-based target (via
BenchmarkTracker): Minimizes tracking error by optimizing on excess returns (portfolio returns minus benchmark returns).
Example 1: Return-based tracking error constraint
Minimize CVaR while constraining the tracking error to 0.50% vs a benchmark:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset, load_sp500_index
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
spx_prices = load_sp500_index()
X, y = prices_to_returns(prices, spx_prices)
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.33, shuffle=False
)
model = MeanRisk(
objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.CVAR,
max_tracking_error=0.005, # 0.50% tracking error constraint
)
model.fit(X_train, y_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.cvar)
Example 2: Weight-based target
Minimize tracking error vs an equal-weighted target portfolio:
from sklearn.model_selection import train_test_split
import numpy as np
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
X = prices_to_returns(prices)
X_train, X_test = train_test_split(X, test_size=0.33, shuffle=False)
# Define target portfolio (e.g., equal-weighted)
n_assets = X.shape[1]
target_weights = np.ones(n_assets) / n_assets
model = MeanRisk(
objective_function=ObjectiveFunction.MINIMIZE_RISK,
risk_measure=RiskMeasure.STANDARD_DEVIATION,
target_weights=target_weights,
)
model.fit(X_train)
print(model.weights_)
portfolio = model.predict(X_test)
print(portfolio.annualized_sharpe_ratio)
Example 3: Return-based target
Minimize tracking error vs a benchmark’s returns:
from sklearn.model_selection import train_test_split
from skfolio import RiskMeasure
from skfolio.datasets import load_sp500_dataset, load_sp500_index
from skfolio.optimization import BenchmarkTracker
from skfolio.preprocessing import prices_to_returns
prices = load_sp500_dataset()
benchmark_prices = load_sp500_index()
X, y = prices_to_returns(prices, benchmark_prices)
X_train, X_test, y_train, y_test = train_test_split(
X, y["SP500"], test_size=0.33, shuffle=False
)
model = BenchmarkTracker(
risk_measure=RiskMeasure.STANDARD_DEVIATION,
)
model.fit(X_train, y_train)
print(model.weights_)
portfolio = model.predict(X_test)
# Compare portfolio returns to benchmark
excess_returns = portfolio.returns - y_test.values
tracking_error = np.std(excess_returns, ddof=1)
print(f"Tracking Error: {tracking_error:0.2%}")
Fallbacks#
Optimization can sometimes fail during a given rebalancing. For example, a convex mean-variance problem with strict risk or sector constraints may become infeasible on specific dates.
All optimization estimators accept a fallback parameter. It can be None, a fallback
estimator, "previous_weights", or a list combining estimators and
"previous_weights". If the primary estimator raises an error during fit, the
configured fallbacks are tried in order until one succeeds. This includes errors from
input validation or from fitting the prior.
Each fallback estimator is cloned and fitted independently on the original inputs. Its
weights, asset names and asset count are copied to the primary estimator, so fit still
returns the original estimator instance. The fitted fallback is available through
fallback_. Its estimates remain separate from those of the primary estimator.
The "previous_weights" fallback reuses the allocation supplied through
previous_weights. If the current investable universe is known, assets outside it
receive zero weight. The remaining weights stay unchanged, without rescaling or checking
them against the primary optimizer’s constraints, so part of the portfolio may remain in
cash. In a manual fitting loop, the caller supplies the holdings to retain through
previous_weights before each call.
Each attempt is recorded in fallback_chain_. After a successful fallback,
fallback_ contains the fitted estimator or the string "previous_weights".
This mechanism allows automated production pipelines to continue when the primary optimization fails and a fallback succeeds. The configured fallback sequence supports reproducibility, while the recorded attempts provide traceability and auditability. Fallbacks can also switch allocation methods or deliberately relax constraints in a controlled sequence when the original problem is infeasible or a solver cannot converge.
If every fallback fails, raise_on_failure=True raises the final error. With
raise_on_failure=False, the estimator emits a warning and sets weights_ to None,
so subsequent calls to predict return a FailedPortfolio.
See Failure Handling.
Example: The primary model is a minimum-variance optimization made intentionally infeasible (the assets’ minimum weights are set to 10%, which exceeds the feasible upper bound of 1/n_assets = 5%). As a fallback, we provide a feasible minimum-variance model with a 2% minimum weight constraint:
model = MeanRisk(
min_weights=0.1, # intentionally infeasible
fallback=MeanRisk(min_weights=0.02), # feasible fallback
)
model.fit(X_train)
print(model.weights_)
# Let's retrieve the fitted fallback that produced the final result:
print(model.fallback_)
# Let's display the sequence of attempts and their outcomes:
print(model.fallback_chain_)
# The fallback audit trail is also propagated to the predicted portfolio:
portfolio = model.predict(X_test)
assert portfolio.fallback_chain == model.fallback_chain_
When calling predict, the selected fallback and the full attempt log are propagated
to the resulting portfolio via fallback_chain.
For a step-by-step tutorial and more details, see Failure and Fallbacks.
Failure Handling#
In research, cross-validation and hyperparameter tuning (e.g., walk-forward, multiple randomized cross-validation), it’s often useful to let all runs complete while keeping a full record of failures instead of stopping on the first failed rebalancing.
During fit, the configured fallbacks can handle input validation errors, failures
while fitting the prior or other estimators, and optimization failures. If a fallback
succeeds, fit returns normally with its allocation for either value of
raise_on_failure. When no fallback succeeds:
If
raise_on_failure=True(default), the final error is raised after all configured fallbacks have failed. Without a fallback, this is the primary error. This setting is useful in production when the primary optimization or a fallback is expected to succeed. After a raised error, further predictions or updates require a fresh estimator or a new successfulfit.If
raise_on_failure=False, the estimator emits a warning and setsweights_to None. Subsequent calls topredictreturn aFailedPortfoliocontaining the failure diagnostics.
FailedPortfolio behaves like an augmented NaN: it marks a failed period while
retaining diagnostics and compatibility with downstream portfolio analytics.
Research evaluations can finish while preserving the full timeline of successful
and failed rebalances.
The estimator records the outcome in the following attributes:
error_contains the final error message, or None after a successful allocation or fallback. For multiple portfolios, it is a list as described below.fallback_contains the successful fallback estimator or"previous_weights". It is None when no fallback succeeds.fallback_chain_contains the sequence of attempts, starting with the primary estimator. Each entry records"success"or an error message. This attribute is None when no fallback chain was attempted.
During partial_fit, the estimator first updates the prior and other estimators, then
computes portfolio weights. The fallback and raise_on_failure settings apply only to
optimization failures after those updates have completed. Errors from input validation
or from updating the prior and other estimators are always raised. Online fitting
supports only fallback=None or fallback="previous_weights", because fallback
estimators have not accumulated the primary model’s online history. Estimator fallbacks
are available during batch fit, where each one is fitted independently on the supplied
history.
See Updates and Failure Handling for the online continuation and restart rules.
Expected failures while computing portfolio weights raise
OptimizationError. Convex optimization steps, including
HERC’s constraint adjustment, raise its subtype
ConvexOptimizationError. Catching OptimizationError
allows the same error handler to cover portfolio optimization failures across
estimators. Catching ConvexOptimizationError limits it to failures of convex
optimization steps.
A successful batch fallback can provide weights even if the primary model did not finish
fitting. A batch error suppressed with raise_on_failure=False can also leave the
primary model incompletely fitted. In both cases, online learning requires a fresh
estimator or a successful fit of the primary model before the next partial_fit.
Example: proceed without raising and retrieve failure diagnostics
from skfolio.optimization import MeanRisk
# Configure an intentionally infeasible problem
model = MeanRisk(
min_weights=1.0,
raise_on_failure=False, # do not raise; collect diagnostics instead
)
model.fit(X_train) # does not raise; weights_ is None on failure
print(model.error_) # stringified error message
print(model.fallback_chain_) # None because no fallback was configured
ptf = model.predict(X_test) # returns a FailedPortfolio sentinel
print(type(ptf).__name__) # "FailedPortfolio"
print(ptf.optimization_error)
print(ptf.fallback_chain)
Multiple Portfolios#
Convex optimizers can compute several portfolios in one call, for example along
an efficient frontier or with array-valued constraints. With
raise_on_failure=False, successful portfolios are retained and each failed
portfolio is recorded as an all-NaN row in weights_. The corresponding error_
entry contains its error message, or None for a successful portfolio. predict
returns a Population containing a
FailedPortfolio for each failed row.
With raise_on_failure=False, fallback is attempted only when every requested portfolio
fails. If some portfolios succeed, individual failures remain recorded in the result
without separate fallback attempts. With raise_on_failure=True, the first optimization
failure stops the calculation, and the configured fallbacks are tried for the fitting
call as a whole.
For a complete tutorial illustrating failure handling and fallbacks, see Failure and Fallbacks.