Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions pypfopt/cla.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,45 @@ def _compute_lambda(self, covarF_inv, covarFB, meanF, wB, i, bi):
res = float(res[0, 0])
return res, bi

@staticmethod
def _invert_covar_free(covarF):
"""
Invert the covariance submatrix of the currently free assets.

The critical line algorithm requires this submatrix to be invertible. A
singular covariance matrix – e.g. one containing perfectly correlated or
duplicated assets, or estimated from fewer observations than assets – can
make it singular, in which case numpy raises a bare ``LinAlgError`` from
deep inside the algorithm. Translate it into an actionable error instead.

Parameters
----------
covarF : np.ndarray
covariance submatrix of the free assets

Raises
------
ValueError
if the submatrix is singular

Returns
-------
np.ndarray
inverse of ``covarF``
"""
try:
return np.linalg.inv(covarF)
except np.linalg.LinAlgError:
raise ValueError(
"The covariance matrix is singular, so the critical line algorithm "
"cannot invert it. This usually means some assets are perfectly "
"correlated or duplicated, or that the covariance matrix was "
"estimated from fewer observations than there are assets. Consider "
"removing redundant assets, or using a shrinkage estimator such as "
"risk_models.CovarianceShrinkage, which returns a positive definite "
"matrix."
) from None

def _get_matrices(self, f):
# Slice covarF,covarFB,covarB,meanF,meanB,wF,wB
covarF = self._reduce_matrix(self.cov_matrix, f, f)
Expand Down Expand Up @@ -339,7 +378,7 @@ def _solve(self):
l_in = None
if len(f) > 1:
covarF, covarFB, meanF, wB = self._get_matrices(f)
covarF_inv = np.linalg.inv(covarF)
covarF_inv = self._invert_covar_free(covarF)
j = 0
for i in f:
lam, bi = self._compute_lambda(
Expand All @@ -354,7 +393,7 @@ def _solve(self):
b = self._get_b(f)
for i in b:
covarF, covarFB, meanF, wB = self._get_matrices(f + [i])
covarF_inv = np.linalg.inv(covarF)
covarF_inv = self._invert_covar_free(covarF)
lam, bi = self._compute_lambda(
covarF_inv,
covarFB,
Expand All @@ -371,7 +410,7 @@ def _solve(self):
# 3) compute minimum variance solution
self.ls.append(0)
covarF, covarFB, meanF, wB = self._get_matrices(f)
covarF_inv = np.linalg.inv(covarF)
covarF_inv = self._invert_covar_free(covarF)
meanF = np.zeros(meanF.shape)
else:
# 4) decide lambda
Expand All @@ -383,7 +422,7 @@ def _solve(self):
self.ls.append(l_out)
f.append(i_out)
covarF, covarFB, meanF, wB = self._get_matrices(f)
covarF_inv = np.linalg.inv(covarF)
covarF_inv = self._invert_covar_free(covarF)
# 5) compute solution vector
wF, g = self._compute_w(covarF_inv, covarFB, meanF, wB)
for i in range(len(f)):
Expand Down
38 changes: 37 additions & 1 deletion tests/test_cla.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import numpy as np
import pytest

from pypfopt import risk_models
from pypfopt import expected_returns, risk_models
from pypfopt.cla import CLA
from tests.utilities_for_tests import get_data, setup_cla

Expand Down Expand Up @@ -96,6 +96,42 @@ def test_cla_two_assets():
assert CLA(mu, cov)


def test_cla_singular_covariance_raises():
# Perfectly correlated assets give a positive semidefinite but singular
# covariance matrix, which CLA cannot invert. The user should get an
# actionable error rather than a bare numpy LinAlgError.
mu = np.array([0.1, 0.2])
cov = np.array([[0.01, 0.01], [0.01, 0.01]])

for method in ("max_sharpe", "min_volatility", "efficient_frontier"):
with pytest.raises(ValueError, match="cannot invert") as e:
getattr(CLA(mu, cov), method)()
# np.linalg.LinAlgError subclasses ValueError, so assert explicitly that
# the raw numpy error is not what surfaces to the user.
assert not isinstance(e.value, np.linalg.LinAlgError)


def test_cla_singular_covariance_fixed_by_shrinkage():
# The remedy suggested by the error message should actually work: shrinkage
# returns a positive definite matrix, which CLA can handle.
df = get_data()
# duplicate a column to make the sample covariance singular
df = df.iloc[:, :3].copy()
df[df.columns[1]] = df[df.columns[0]]
mu = expected_returns.mean_historical_return(df)

sample_cov = risk_models.sample_cov(df)
assert np.min(np.linalg.eigvalsh(sample_cov)) < 1e-12
with pytest.raises(ValueError, match="cannot invert"):
CLA(mu, sample_cov).min_volatility()

shrunk_cov = risk_models.CovarianceShrinkage(df).ledoit_wolf()
assert np.min(np.linalg.eigvalsh(shrunk_cov)) > 0
w = CLA(mu, shrunk_cov).min_volatility()
assert len(w) == 3
np.testing.assert_almost_equal(sum(w.values()), 1)


def test_cla_max_sharpe_semicovariance():
df = get_data()
cla = setup_cla()
Expand Down
Loading