diff --git a/.gitignore b/.gitignore index 41862b80..dacc87f7 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,6 @@ bin # uv uv.lock + +# Local generated artifacts +weights.csv diff --git a/example/examples.py b/example/examples.py index ca45bf70..5f23e7a2 100644 --- a/example/examples.py +++ b/example/examples.py @@ -37,8 +37,8 @@ def deviation_risk_parity(w, cov_matrix): # Black-Litterman spy_prices = pd.read_csv( - "tests/resources/spy_prices.csv", parse_dates=True, index_col=0, squeeze=True -) + "tests/resources/spy_prices.csv", parse_dates=True, index_col=0 +).squeeze() delta = black_litterman.market_implied_risk_aversion(spy_prices) mcaps = { @@ -147,7 +147,8 @@ def deviation_risk_parity(w, cov_matrix): # Crticial Line Algorithm -cla = CLA(mu, S) +# Set use_cvxcla=True to use the optional high-performance cvxcla backend. +cla = CLA(mu, S, use_cvxcla=True) print(cla.max_sharpe()) cla.portfolio_performance(verbose=True) plotting.plot_efficient_frontier(cla) # to plot diff --git a/pypfopt/cla.py b/pypfopt/cla.py index bde53b19..dab5ffbd 100644 --- a/pypfopt/cla.py +++ b/pypfopt/cla.py @@ -4,6 +4,8 @@ by Marcos Lopez de Prado and David Bailey. """ +import warnings + import numpy as np import pandas as pd @@ -47,7 +49,9 @@ class CLA(BaseOptimizer): - ``save_weights_to_file()`` saves the weights to csv, json, or txt. """ - def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)): + def __init__( + self, expected_returns, cov_matrix, weight_bounds=(0, 1), use_cvxcla=False + ): """ Parameters ---------- @@ -59,6 +63,9 @@ def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)): weight_bounds : tuple (float, float) or (list/ndarray, list/ndarray) or list(tuple(float, float)) minimum and maximum weight of an asset, defaults to (0, 1). Must be changed to (-1, 1) for portfolios with shorting. + use_cvxcla : bool, optional + whether to use the ``cvxcla`` library as a high-performance backend, + defaults to False. Requires ``cvxcla`` to be installed Raises ------ @@ -103,6 +110,31 @@ def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)): tickers = list(range(len(self.mean))) super().__init__(len(tickers), tickers) + # Optional cvxcla backend + self.use_cvxcla = False + self._cvxcla_engine = None + if use_cvxcla: + try: + from cvxcla import CLA as _CVXCLAEngine + + n = self.n_assets + self._cvxcla_engine = _CVXCLAEngine( + mean=self.expected_returns, + covariance=self.cov_matrix, + lower_bounds=self.lB.flatten(), + upper_bounds=self.uB.flatten(), + a=np.ones((1, n)), + b=np.ones(1), + ) + self.use_cvxcla = True + except ImportError: + warnings.warn( + "cvxcla is not installed – falling back to the standard CLA " + "implementation. Install it with: pip install cvxcla", + RuntimeWarning, + stacklevel=2, + ) + @staticmethod def _infnone(x): """ @@ -159,7 +191,7 @@ def _compute_w(self, covarF_inv, covarFB, meanF, wB): w1 = np.dot(g4, wB) g4 = np.dot(onesF.T, w1) g = -self.ls[-1] * g1 / g2 + (1 - g3 + g4) / g2 - g = float(g[0, 0]) + g = float(g.item() if hasattr(g, "item") else g) # 2) compute weights w2 = np.dot(covarF_inv, onesF) w3 = np.dot(covarF_inv, meanF) @@ -189,7 +221,7 @@ def _compute_lambda(self, covarF_inv, covarFB, meanF, wB, i, bi): l3 = np.dot(l2, wB) l2 = np.dot(onesF.T, l3) res = ((1 - l1 + l2) * c4[i] - c1 * (bi + l3[i])) / c - res = float(res[0, 0]) + res = float(res.item() if hasattr(res, "item") else res) return res, bi def _get_matrices(self, f): @@ -406,6 +438,11 @@ def max_sharpe(self): OrderedDict asset weights for the max-sharpe portfolio """ + if self.use_cvxcla: + _, weights = self._cvxcla_engine.frontier.max_sharpe + self.weights = np.asarray(weights, dtype=float) + return self._make_output_weights() + if not self.w: self._solve() # 1) Compute the local max SR portfolio between any two neighbor turning points @@ -430,6 +467,12 @@ def min_volatility(self): OrderedDict asset weights for the volatility-minimising portfolio """ + if self.use_cvxcla: + # Last turning point on the frontier is the minimum variance portfolio + weights = self._cvxcla_engine.frontier.weights[-1] + self.weights = np.asarray(weights, dtype=float) + return self._make_output_weights() + if not self.w: self._solve() var = [] @@ -459,6 +502,14 @@ def efficient_frontier(self, points=100): (float list, float list, np.ndarray list) return list, std list, weight list """ + if self.use_cvxcla: + frontier = self._cvxcla_engine.frontier.interpolate(points) + mu = list(frontier.returns) + sigma = list(frontier.volatility) + weights = [np.asarray(w, dtype=float) for w in frontier.weights] + self.frontier_values = (mu, sigma, weights) + return mu, sigma, weights + if not self.w: self._solve() diff --git a/pyproject.toml b/pyproject.toml index b2c99ee1..0d81ddc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ all_extras = [ "ecos>=2.0.14,<2.1", "plotly>=5.0.0,<7", "cvxopt; python_version < '3.14'", + "cvxcla>=1.6.2; python_version >= '3.11'", ] # dev - the developer dependency set, for contributors and CI diff --git a/tests/test_cla.py b/tests/test_cla.py index c5f69e23..e2bf3080 100644 --- a/tests/test_cla.py +++ b/tests/test_cla.py @@ -150,3 +150,62 @@ def test_cla_efficient_frontier(): # higher return = higher risk assert sigma[-1] < sigma[0] and mu[-1] < mu[0] assert weights[0].shape == (20, 1) + + +def test_cla_cvxcla_fallback_warning(): + """cvxcla unavailable → RuntimeWarning, falls back to standard backend.""" + import sys + + original = sys.modules.get("cvxcla") + sys.modules["cvxcla"] = None + try: + with pytest.warns(RuntimeWarning, match="cvxcla is not installed"): + cla = setup_cla(use_cvxcla=True) + assert cla.use_cvxcla is False + finally: + if original is None: + sys.modules.pop("cvxcla", None) + else: + sys.modules["cvxcla"] = original + + +def test_cla_cvxcla_max_sharpe(): + """cvxcla backend max_sharpe matches standard implementation.""" + pytest.importorskip("cvxcla") + cla = setup_cla(use_cvxcla=True) + assert cla.use_cvxcla is True + w = cla.max_sharpe() + assert isinstance(w, dict) + assert set(w.keys()) == set(cla.tickers) + np.testing.assert_almost_equal(cla.weights.sum(), 1) + np.testing.assert_allclose( + cla.portfolio_performance(risk_free_rate=0.02), + (0.2994470912768992, 0.21764331657015668, 1.283968171780824), + rtol=1e-4, + ) + + +def test_cla_cvxcla_min_volatility(): + """cvxcla backend min_volatility matches standard implementation.""" + pytest.importorskip("cvxcla") + cla = setup_cla(use_cvxcla=True) + w = cla.min_volatility() + assert isinstance(w, dict) + assert set(w.keys()) == set(cla.tickers) + np.testing.assert_almost_equal(cla.weights.sum(), 1) + np.testing.assert_allclose( + cla.portfolio_performance(risk_free_rate=0.02), + (0.1505682139948257, 0.15915084514118688, 0.8204054077060994), + rtol=1e-4, + ) + + +def test_cla_cvxcla_efficient_frontier(): + """cvxcla backend efficient_frontier sets frontier_values and returns valid lists.""" + pytest.importorskip("cvxcla") + cla = setup_cla(use_cvxcla=True) + mu, sigma, weights = cla.efficient_frontier(points=50) + assert len(mu) > 0 + assert len(sigma) == len(mu) + assert len(weights) == len(mu) + assert cla.frontier_values is not None