Metadata-Version: 2.4
Name: universal-upsa
Version: 0.2.0
Summary: Universal Portfolio Shrinkage Approximator (UPSA) with LOO or k-fold CV
Author-email: Mo Pourmohammadi <mo.pourmohammadi@yale.edu>
License: MIT
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.20
Requires-Dist: scikit-learn>=1.0
Requires-Dist: cvxopt>=1.3
Dynamic: license-file

# 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).

```bash
# 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

```python
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:

```python
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:

```bash
pip install pytest
pytest tests/test_upsa.py
```

## Citation

Please cite the paper when using UPSA:

```bibtex
@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](LICENSE) for details.

## Contributing

Contributions welcome! Please open issues or pull requests on GitHub: [https://github.com/pourmohammadimohammad/Universal_Portfolio_Shrinkage](https://github.com/pourmohammadimohammad/Universal_Portfolio_Shrinkage).
