Note
Go to the end to download the full example code or to run this example in your browser via JupyterLite.
Time-Series Factor Model#
skfolio supports time-series factor models through
TimeSeriesFactorModel and characteristics-based
cross-sectional factor models through
CharacteristicsFactorModel.
This tutorial uses TimeSeriesFactorModel to build a maximum
Sharpe ratio portfolio with MeanRisk. Factor returns
are supplied as time series, and each asset’s exposures are estimated by regressing
its returns on those factor returns.
CharacteristicsFactorModel builds exposures from
point-in-time asset characteristics and estimates factor returns through
cross-sectional regression at each date. See the Factor Models user guide for the methodology and the Characteristics Factor Model
tutorial
for a complete example.
A Prior Estimator in skfolio estimates a
ReturnDistribution containing the inputs to portfolio
optimization (\(\mu\), \(\Sigma\), returns, sample weights and a Cholesky
decomposition).
The term “prior” is used in a general optimization sense, not confined to Bayesian priors. It denotes any a priori assumption or estimation method for the return distribution before optimization, unifying Frequentist, Bayesian and Information-theoretic approaches into a single cohesive framework:
- Bayesian:
- Information-theoretic:
Prior estimators follow scikit-learn’s estimator API and store the fitted
ReturnDistribution in return_distribution_. Their inputs
depend on the model. TimeSeriesFactorModel takes asset returns in X and factor
returns through the factors keyword argument.
The ReturnDistribution is a dataclass containing:
mu: Estimated expected returns of shape (n_assets,)
covariance: Estimated covariance matrix of shape (n_assets, n_assets)
returns: (Estimated) asset returns of shape (n_observations, n_assets)
sample_weight: Sample weight for each observation of shape (n_observations,) (optional)
cholesky: Lower-triangular Cholesky factor of the covariance (optional)
The TimeSeriesFactorModel estimator estimates the ReturnDistribution by fitting
a factor model on asset returns alongside a specified prior estimator
for the factor returns.
The purpose of factor models is to impose a structure on financial variables and their covariance matrix by explaining them through a small number of common factors. This can help overcome estimation error by reducing the number of parameters, i.e., the dimensionality of the estimation problem, making portfolio optimization more robust against noise in the data. Factor models also provide a decomposition of financial risk into systematic and security-specific components.
Data#
We load the S&P 500 dataset composed of the daily prices of 20 assets from the SPX Index composition and the Factors dataset composed of the daily prices of 5 ETFs representing common factors:
from plotly.io import show
from sklearn import set_config
from sklearn.linear_model import RidgeCV
from sklearn.model_selection import train_test_split
from skfolio import Population, RiskMeasure
from skfolio.datasets import load_factors_dataset, load_sp500_dataset
from skfolio.moments import GerberCovariance, ShrunkMu
from skfolio.optimization import MeanRisk, ObjectiveFunction
from skfolio.preprocessing import prices_to_returns
from skfolio.prior import EmpiricalPrior, LoadingMatrixRegression, TimeSeriesFactorModel
set_config(enable_metadata_routing=True)
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
)
Time-Series Factor Model#
We create a Maximum Sharpe Ratio model using TimeSeriesFactorModel and fit it
on the training set:
model_factor_1 = MeanRisk(
risk_measure=RiskMeasure.VARIANCE,
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
prior_estimator=TimeSeriesFactorModel(),
portfolio_params=dict(name="Factor Model 1"),
)
model_factor_1.fit(X_train, factors=factors_train)
model_factor_1.weights_
array([1.03294289e-06, 1.27482685e-03, 4.19682804e-07, 3.34130826e-06,
7.36838288e-07, 1.28824408e-06, 5.13031432e-02, 6.35619183e-02,
6.14804834e-07, 1.79106051e-01, 5.03130911e-02, 7.13734379e-02,
4.13002526e-02, 2.27978407e-01, 5.13348034e-02, 1.44130375e-01,
2.99026117e-07, 6.19737850e-02, 5.63413085e-02, 8.67773198e-07])
We can change the BaseLoadingMatrix that estimates the loading
matrix (betas) of the factors.
The default is the LoadingMatrixRegression, which fit the factors using a
LassoCV on each asset separately.
For example, let’s change the LassoCV into a RidgeCV without intercept and use
parallelization:
model_factor_2 = MeanRisk(
risk_measure=RiskMeasure.VARIANCE,
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
prior_estimator=TimeSeriesFactorModel(
loading_matrix_estimator=LoadingMatrixRegression(
linear_regressor=RidgeCV(fit_intercept=False), n_jobs=-1
)
),
portfolio_params=dict(name="Factor Model 2"),
)
model_factor_2.fit(X_train, factors=factors_train)
model_factor_2.weights_
array([3.97758339e-02, 6.57843874e-03, 2.18405141e-02, 8.98258882e-03,
3.16197378e-02, 1.42391168e-02, 8.00124906e-02, 8.32090802e-02,
4.74782930e-02, 8.59470407e-02, 4.59776221e-02, 5.91778878e-02,
8.42236770e-02, 1.05684777e-01, 6.43841778e-02, 7.94729901e-02,
3.76786712e-05, 5.23695742e-02, 4.35215146e-02, 4.54669667e-02])
We can also change the prior estimator of the factors.
It is used to estimate the ReturnDistribution containing
expected factor returns and the factor covariance matrix.
For example, let’s estimate expected factor returns with James-Stein shrinkage and the factor covariance matrix with the Gerber covariance estimator:
model_factor_3 = MeanRisk(
risk_measure=RiskMeasure.VARIANCE,
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
prior_estimator=TimeSeriesFactorModel(
factor_prior_estimator=EmpiricalPrior(
mu_estimator=ShrunkMu(), covariance_estimator=GerberCovariance()
)
),
portfolio_params=dict(name="Factor Model 3"),
)
model_factor_3.fit(X_train, factors=factors_train)
model_factor_3.weights_
array([4.86490691e-07, 4.38230194e-07, 4.24408222e-08, 6.69653314e-08,
5.11878214e-08, 6.14581602e-08, 1.68436387e-02, 2.08439609e-06,
5.27854154e-08, 6.45513596e-02, 6.24004728e-02, 9.61498232e-02,
3.68209826e-01, 2.44692220e-01, 5.86512138e-07, 9.30385165e-06,
2.19939735e-08, 1.47139096e-01, 3.09340379e-07, 5.81316432e-08])
Factor Analysis#
Each fitted estimator is saved with a trailing underscore. For example, we can access the fitted prior estimator with:
prior_estimator = model_factor_3.prior_estimator_
We can access the return distribution with:
return_distribution = prior_estimator.return_distribution_
We can access the loading matrix with:
loading_matrix = prior_estimator.loading_matrix_estimator_.loading_matrix_
Empirical Model#
For comparison, we also create a Maximum Sharpe Ratio model using the default Empirical estimator:
model_empirical = MeanRisk(
risk_measure=RiskMeasure.VARIANCE,
objective_function=ObjectiveFunction.MAXIMIZE_RATIO,
portfolio_params=dict(name="Empirical"),
)
model_empirical.fit(X_train)
model_empirical.weights_
array([1.01561518e-01, 7.81165193e-02, 6.29030035e-07, 1.89005488e-02,
3.05610118e-07, 1.55770502e-07, 1.10594710e-01, 1.22328443e-06,
1.56471742e-06, 3.39453275e-06, 1.62631058e-01, 1.92171374e-06,
1.77783711e-01, 9.61805760e-02, 4.64493061e-07, 9.68566446e-03,
7.41771351e-08, 2.44533886e-01, 1.83984286e-06, 2.34178300e-07])
Prediction#
We predict all models on the test set:
ptf_factor_1_test = model_factor_1.predict(X_test)
ptf_factor_2_test = model_factor_2.predict(X_test)
ptf_factor_3_test = model_factor_3.predict(X_test)
ptf_empirical_test = model_empirical.predict(X_test)
population = Population(
[ptf_factor_1_test, ptf_factor_2_test, ptf_factor_3_test, ptf_empirical_test]
)
fig = population.plot_cumulative_returns()
show(fig)
Let’s plot the portfolios’ composition:
population.plot_composition()
Total running time of the script: (0 minutes 3.908 seconds)