Skip to main content

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_method parameter 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. If None, defaults to a 10-point log-spaced grid spanning [min(eig)/eig_range, eig_range*max(eig)] (padded by eig_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 default z_list grid.
    • constraint: if True, 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 attributes best_z, upsa, and eff_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). Pass output_scale=True to 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

universal_upsa-0.2.0.tar.gz (9.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

universal_upsa-0.2.0-py3-none-any.whl (9.2 kB view details)

Uploaded Python 3

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

Hashes for universal_upsa-0.2.0.tar.gz
Algorithm Hash digest
SHA256 567a7fd691054a9c1b2908d3e0553535f9f7945f1b1fb2fe85acb1cdaaaaaba0
MD5 73d6a70313f02dca5d77c59845fec1ea
BLAKE2b-256 9733d4daaa3770ccbfafe91fe739b1e8eca52abf8b9e6e38257e2906d9f50016

See more details on using hashes here.

Provenance

The following attestation bundles were made for universal_upsa-0.2.0.tar.gz:

Publisher: publish.yml on pourmohammadimohammad/Universal_Portfolio_Shrinkage

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for universal_upsa-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a2e451f028f8faf40c768554a0b2d2f984c7508a1d98b0c0eca35fb389b35fbb
MD5 3e984234857a513b06bc0c6a10d8a5ea
BLAKE2b-256 efc7f45acb72aa11d9fcc18e32489cd153c342a777f49c6cbd4199aa000952a7

See more details on using hashes here.

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

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page