Universal Portfolio Shrinkage Approximator (UPSA) with LOO or k-fold CV
Project description
Universal Portfolio Shrinkage (UPSA)
A Python implementation of the Universal Portfolio Shrinkage Approximator (UPSA), a flexible spectral shrinkage method that directly optimizes out-of-sample portfolio performance, as introduced in:
Kelly, Bryan T., Semyon Malamud, Mo Pourmohammadi, and Fabio Trojani. "Universal Portfolio Shrinkage." Forthcoming, Review of Financial Studies. Available at SSRN: https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4660670
Motivation
Classical Markowitz portfolios suffer from severe estimation noise when the number of assets or factors (N) is large relative to sample size (T), leading to large gaps between in-sample and out-of-sample performance . Traditional shrinkage methods impose restrictive forms or optimize statistical proxies rather than the portfolio objective, limiting efficacy. UPSA overcomes these limitations by providing a universal spectral approximator for nonlinear shrinkage functions and tuning shrinkage directly on expected out-of-sample performance via cross-validation.
Key Features
- Universal spectral approximation: Represents positive, matrix-monotone-decreasing shrinkage functions as a nonnegative combination of basic ridge shrinkages, via the Löwner/Stieltjes integral representation. (Allowing signed weights extends approximation to any continuous function on a compact interval; this package imposes nonnegativity and so targets the matrix-monotone class.)
- Objective-aligned tuning: Chooses shrinkage weights by maximizing expected out-of-sample quadratic utility using leave-one-out cross-validation, as in the paper (k-fold is also available here).
- Efficient closed-form computation: Employs spectral formulas to compute LOO estimators for ridge portfolios without refitting eigen-decomposition for each leave-out.
- Constraint support: UPSA combination weights are always nonnegative, matching the paper's specification. An optional sum-to-one normalization (
constraint=True) is an implementation extension with no counterpart in the paper. - Flexible CV interface: Single
cv_methodparameter accepts'loo'or integer >1 for k-fold CV. - Scalable: Handles high-dimensional settings (N ≫ T or T ≫ N) via efficient eigen-decomposition routines.
- Empirical robustness: Outperforms ridge, Ledoit–Wolf, PCA-based, and benchmark factor models in anomaly portfolio tests, achieving higher Sharpe and lower pricing errors.
Installation
Requires Python 3.7+ and dependencies: numpy, scikit-learn, cvxopt, pandas (optional).
# Install from PyPI
pip install universal-upsa
# Or install development version
git clone https://github.com/pourmohammadimohammad/Universal_Portfolio_Shrinkage.git
cd Universal_Portfolio_Shrinkage
pip install -e .
Quickstart
import numpy as np
import pandas as pd
from upsa.upsa import UPSA
# 1) Load returns (T×P) as DataFrame or ndarray
returns_df = pd.read_csv("returns.csv", index_col="date")
returns = returns_df.values
# 2) Define shrinkage grid (e.g., logspace spanning empirical eigenvalues)
z_list = np.logspace(-4, 2, 20)
# 3a) Fit with leave-one-out CV (default)
model_loo = UPSA(z_list=z_list).fit(returns, cv_method='loo', constraint=False)
w_loo = model_loo.get_upsa_weights()
# 3b) Or fit with 5-fold CV for larger T
model_kf = UPSA(z_list=z_list).fit(returns, cv_method=5, constraint=False)
w_kf = model_kf.get_upsa_weights()
# 4) Apply out-of-sample: compute portfolio returns
oos_df = pd.read_csv("oos_returns.csv", index_col="date")
oos = oos_df.values
port_ret = oos @ w_loo
# 5) Annualized Sharpe ratio
sharpe = np.sqrt(12) * port_ret.mean() / port_ret.std()
print(f"Annualized Sharpe: {sharpe:.3f}")
By default the combination weights are nonnegative but unnormalized. To additionally require that they sum to one:
model_c = UPSA(z_list=z_list).fit(returns, cv_method='loo', constraint=True)
w_c = model_c.get_upsa_weights()
API Reference
-
UPSA(z_list: np.ndarray = None): Initialize with optional shrinkage grid. IfNone, defaults to a 10-point log-spaced grid spanning[min(eig)/eig_range, eig_range*max(eig)](padded byeig_range, default 10). An integer is read as the number of grid points. -
.fit(returns, constraint=False, cv_method='loo', eig_range=10.0): Fit UPSA model. The defaults reproduce the paper's specification.returns: T×P array or DataFrame.cv_method:'loo'or integer >1 for k-fold. The paper uses leave-one-out.eig_range: padding factor for the defaultz_listgrid.constraint: ifTrue, additionally impose the sum-to-one equality constraint on the UPSA combination weights. Nonnegativity of those weights is imposed unconditionally and is not controlled by this flag (see "A note on the constraints" below). Returns fitted instance with attributesbest_z,upsa, andeff_port(matrix of ridge portfolios).
-
.get_upsa_weights(output_scale=False): Returns the UPSA portfolio weight vector (length P), trace-scaled so the shrunk eigenvalues sum to the same total as the sample eigenvalues (the paper's normalization). Passoutput_scale=Trueto also return the scale factor. -
.get_ridge_weights(output_scale=False): Returns the ridge portfolio weight vector (length P) at the CV-selected penalty, trace-scaled the same way.
A note on the constraints
The constraints act on the combination weights self.upsa — the weights placed on the
grid of ridge portfolios, one per element of z_list — and not on the final asset weights.
The quadratic program solved in _markowitz_constrained is
minimize (1/2) x' Σ x - μ' x
subject to x >= 0 (always imposed)
1' x = 1 (only when constraint=True)
where μ and Σ are the mean and second-moment matrix of the cross-validated ridge
portfolio returns. Nonnegativity is intrinsic to the method rather than a user option: the universal
approximation result represents the nonlinear shrinkage function as a nonnegative
combination of ridge shrinkages, so x >= 0 is what makes the estimated combination a
valid shrinkage function. The paper imposes nonnegativity only; the sum-to-one
equality is an implementation extension and is not used to produce the paper's results.
Consequently:
constraint=False→ combination weights are nonnegative but unnormalized.constraint=True→ combination weights are nonnegative and sum to one.
Neither setting constrains the asset weights returned by get_upsa_weights(), which are
eff_port @ upsa. Those can be negative and generally do not sum to one, since the
underlying ridge portfolios are themselves long-short.
Empirical Evidence Summary
On 153 anomaly portfolios from Jensen et al. (1971–2022), UPSA achieves out-of-sample Sharpe ≈ 1.92 vs. 1.59 for best ridge, 1.31 for Ledoit–Wolf, and 1.45 for PCA-based portfolios; cross-sectional R² ≈ 67% vs. 39% for ridge. Robustness holds across subsamples and additional regularization scenarios.
Testing
Run basic sanity tests with pytest:
pip install pytest
pytest tests/test_upsa.py
Citation
Please cite the paper when using UPSA:
@article{kelly2024universal,
title={Universal Portfolio Shrinkage},
author={Kelly, Bryan T and Malamud, Semyon and Pourmohammadi, Mo and Trojani, Fabio},
journal={Review of Financial Studies},
year={2024},
note={Forthcoming. Available at SSRN: \url{https://papers.ssrn.com/sol3/papers.cfm?abstract_id=4660670}}
}
License
Distributed under the MIT License. See LICENSE for details.
Contributing
Contributions welcome! Please open issues or pull requests on GitHub: https://github.com/pourmohammadimohammad/Universal_Portfolio_Shrinkage.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file universal_upsa-0.2.0.tar.gz.
File metadata
- Download URL: universal_upsa-0.2.0.tar.gz
- Upload date:
- Size: 9.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
567a7fd691054a9c1b2908d3e0553535f9f7945f1b1fb2fe85acb1cdaaaaaba0
|
|
| MD5 |
73d6a70313f02dca5d77c59845fec1ea
|
|
| BLAKE2b-256 |
9733d4daaa3770ccbfafe91fe739b1e8eca52abf8b9e6e38257e2906d9f50016
|
Provenance
The following attestation bundles were made for universal_upsa-0.2.0.tar.gz:
Publisher:
publish.yml on pourmohammadimohammad/Universal_Portfolio_Shrinkage
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
universal_upsa-0.2.0.tar.gz -
Subject digest:
567a7fd691054a9c1b2908d3e0553535f9f7945f1b1fb2fe85acb1cdaaaaaba0 - Sigstore transparency entry: 2207042755
- Sigstore integration time:
-
Permalink:
pourmohammadimohammad/Universal_Portfolio_Shrinkage@36201488374612d0138a38817773d9a80c6de8ab -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/pourmohammadimohammad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@36201488374612d0138a38817773d9a80c6de8ab -
Trigger Event:
push
-
Statement type:
File details
Details for the file universal_upsa-0.2.0-py3-none-any.whl.
File metadata
- Download URL: universal_upsa-0.2.0-py3-none-any.whl
- Upload date:
- Size: 9.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a2e451f028f8faf40c768554a0b2d2f984c7508a1d98b0c0eca35fb389b35fbb
|
|
| MD5 |
3e984234857a513b06bc0c6a10d8a5ea
|
|
| BLAKE2b-256 |
efc7f45acb72aa11d9fcc18e32489cd153c342a777f49c6cbd4199aa000952a7
|
Provenance
The following attestation bundles were made for universal_upsa-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on pourmohammadimohammad/Universal_Portfolio_Shrinkage
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
universal_upsa-0.2.0-py3-none-any.whl -
Subject digest:
a2e451f028f8faf40c768554a0b2d2f984c7508a1d98b0c0eca35fb389b35fbb - Sigstore transparency entry: 2207042761
- Sigstore integration time:
-
Permalink:
pourmohammadimohammad/Universal_Portfolio_Shrinkage@36201488374612d0138a38817773d9a80c6de8ab -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/pourmohammadimohammad
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@36201488374612d0138a38817773d9a80c6de8ab -
Trigger Event:
push
-
Statement type: