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.

from plotly.io import show
import numpy as np

from skfolio import Population
from skfolio.datasets import load_sp500_dataset
from skfolio.measures import RatioMeasure
from skfolio.model_selection import OnlineGridSearch, online_predict, online_score
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"),
)

tuned_prediction = online_predict(
    portfolio_search.best_estimator_,
    X,
    warmup_size=252,
    test_size=5,
    portfolio_params=dict(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.0077% 0.0078%
Annualized Variance 1.94% 1.95%
Semi-Variance 0.0040% 0.0041%
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 provides the same walk-forward evaluation as a single scalar, which is useful for quick comparisons in code.

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,
)

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 29.002 seconds)

Gallery generated by Sphinx-Gallery