Online Evaluation of Portfolio Optimization#

This tutorial shows how to tune a MeanRisk estimator with online search and evaluate it out-of-sample with an online walk-forward procedure.

Unlike the previous tutorial, which tuned the covariance estimator in isolation, here we optimize the portfolio model end-to-end using a portfolio-level metric.

The online approach is equivalent to combining scikit-learn’s GridSearchCV with WalkForward using expand_train=True, but instead of refitting every candidate from scratch at each split, it calls partial_fit to incrementally update each estimator. This is significantly faster for estimators that support this method.

Data#

We load the S&P 500 dataset composed of the daily prices of 20 assets from the S&P 500 Index composition starting from 2010-01-04 up to 2022-12-28.

import numpy as np
from plotly.io import show

from skfolio import Population
from skfolio.datasets import load_sp500_dataset
from skfolio.model_selection import OnlineGridSearch, online_predict
from skfolio.moments import EWMu, RegimeAdjustedEWCovariance
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
from skfolio.prior import EmpiricalPrior

prices = load_sp500_dataset()
X = prices_to_returns(prices)
X = X["2010":]

Baseline Portfolio Model#

We start with a simple Minimum Variance optimization via MeanRisk. All sub-estimators support partial_fit, so the entire pipeline can be updated incrementally during walk-forward evaluation.

baseline_model = MeanRisk(
    prior_estimator=EmpiricalPrior(
        mu_estimator=EWMu(half_life=40),
        covariance_estimator=RegimeAdjustedEWCovariance(
            half_life=40,
            corr_half_life=80,
            regime_half_life=20,
        ),
    ),
)

Online Evaluation#

online_predict walks forward through the data, updates the estimator via partial_fit at each step, and predicts on the next test window. The result is a MultiPeriodPortfolio.

baseline_prediction = online_predict(
    baseline_model,
    X,
    warmup_size=252,
    test_size=5,
    portfolio_params=dict(name="Baseline"),
)

The tuned model’s walk-forward prediction can be obtained in two ways.

The first calls the same function on the tuned estimator:

tuned_prediction = online_predict(
    portfolio_search.best_estimator_,
    X,
    warmup_size=252,
    test_size=5,
    portfolio_params=dict(name="Tuned"),
)

The second, used below, reads the prediction that the search already retained through return_predictions=True. Both return the same portfolio here, because the search evaluated its candidates on the same data with the same warmup_size and test_size, so reading it back avoids a second walk-forward pass. Call online_predict to evaluate on different data or with different window sizes.

tuned_prediction = portfolio_search.cv_results_["predictions"][
    portfolio_search.best_index_
]
tuned_prediction.name = "Tuned"

Portfolio Comparison#

We collect both portfolio evaluations into a Population for side-by-side comparison.

population = Population([baseline_prediction, tuned_prediction])
population.summary()
Baseline Tuned
Mean 0.052% 0.054%
Annualized Mean 13.05% 13.51%
Variance 0.000077 0.000078
Annualized Variance 1.94% 1.95%
Semi-Variance 0.000040 0.000041
Annualized Semi-Variance 1.01% 1.02%
Standard Deviation 0.88% 0.88%
Annualized Standard Deviation 13.93% 13.98%
Semi-Deviation 0.63% 0.64%
Annualized Semi-Deviation 10.04% 10.12%
Mean Absolute Deviation 0.58% 0.58%
CVaR at 95% 2.07% 2.07%
EVaR at 95% 3.96% 4.22%
Worst Realization 7.32% 8.00%
CDaR at 95% 11.78% 12.32%
MAX Drawdown 25.72% 26.99%
Average Drawdown 2.57% 2.66%
EDaR at 95% 15.50% 16.28%
First Lower Partial Moment 0.29% 0.29%
Ulcer Index 0.040 0.042
Gini Mean Difference 0.87% 0.87%
Value at Risk at 95% 1.27% 1.25%
Drawdown at Risk at 95% 9.40% 9.95%
Entropic Risk Measure at 95% 3.00 3.00
Fourth Central Moment 0.000010% 0.000011%
Fourth Lower Partial Moment 0.000004% 0.000005%
Skew 5.98% -4.20%
Kurtosis 1736.20% 1858.97%
Sharpe Ratio 0.059 0.061
Annualized Sharpe Ratio 0.94 0.97
Sortino Ratio 0.082 0.084
Annualized Sortino Ratio 1.30 1.33
Mean Absolute Deviation Ratio 0.089 0.092
First Lower Partial Moment Ratio 0.18 0.18
Value at Risk Ratio at 95% 0.041 0.043
CVaR Ratio at 95% 0.025 0.026
Entropic Risk Measure Ratio at 95% 0.00017 0.00018
EVaR Ratio at 95% 0.013 0.013
Worst Realization Ratio 0.0071 0.0067
Drawdown at Risk Ratio at 95% 0.0055 0.0054
CDaR Ratio at 95% 0.0044 0.0043
Calmar Ratio 0.0020 0.0020
Average Drawdown Ratio 0.020 0.020
EDaR Ratio at 95% 0.0033 0.0033
Ulcer Index Ratio 0.013 0.013
Gini Mean Difference Ratio 0.059 0.061
Avg nb of Assets per Portfolio 20.0 20.0
Number of Portfolios 603 603
Number of Failed Portfolios 0 0
Number of Fallback Portfolios 0 0


fig = population.plot_cumulative_returns()
show(fig)

Online Score#

online_score runs the same walk-forward evaluation and returns a scalar score. This is useful when the prediction path is not needed:

from skfolio.measures import RatioMeasure
from skfolio.model_selection import online_score

baseline_score = online_score(
    baseline_model,
    X,
    warmup_size=252,
    test_size=5,
    scoring=RatioMeasure.ANNUALIZED_SHARPE_RATIO,
)
tuned_score = online_score(
    portfolio_search.best_estimator_,
    X,
    warmup_size=252,
    test_size=5,
    scoring=RatioMeasure.ANNUALIZED_SHARPE_RATIO,
)

Both prediction paths are already available here, so the scores are read from them below rather than running two additional walk-forward passes:

baseline_score = baseline_prediction.annualized_sharpe_ratio
tuned_score = tuned_prediction.annualized_sharpe_ratio

print(f"Baseline Sharpe: {baseline_score:.4f}")
print(f"Tuned Sharpe: {tuned_score:.4f}")
Baseline Sharpe: 0.9365
Tuned Sharpe: 0.9662

Conclusion#

This tutorial demonstrated the portfolio-level online workflow:

  1. Define an incremental MeanRisk estimator whose sub-estimators all support partial_fit.

  2. Tune it with OnlineGridSearch using a portfolio-level metric.

  3. Evaluate the tuned estimator out-of-sample with online_predict and visualize the results with Population.

  4. Summarize the walk-forward performance as a scalar with online_score.

This complements the previous tutorial: covariance tuning improves the statistical forecast, while direct portfolio search optimizes the full allocation problem end-to-end.

Total running time of the script: (1 minutes 10.591 seconds)

Gallery generated by Sphinx-Gallery