diff --git a/.gitignore b/.gitignore index 4b974123..9ae377f5 100644 --- a/.gitignore +++ b/.gitignore @@ -48,4 +48,7 @@ dist artifacts +# uv lock file +uv.lock + bin diff --git a/example/examples.py b/example/examples.py index ca45bf70..85416792 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 = { @@ -116,7 +116,7 @@ def deviation_risk_parity(w, cov_matrix): weights = hrp.optimize() hrp.portfolio_performance(verbose=True) print(weights) -plotting.plot_dendrogram(hrp) # to plot dendrogram +plotting.plot_dendrogram(hrp, showfig=False) # Use showfig=True to display plot """ Expected annual return: 10.8% @@ -146,11 +146,11 @@ def deviation_risk_parity(w, cov_matrix): """ -# Crticial Line Algorithm -cla = CLA(mu, S) +# Critical Line Algorithm (CLA) +cla = CLA(mu, S, use_cvxcla=False) # Use use_cvxcla=True for faster performance print(cla.max_sharpe()) cla.portfolio_performance(verbose=True) -plotting.plot_efficient_frontier(cla) # to plot +plotting.plot_efficient_frontier(cla, interactive=True, showfig=False) # Use showfig=True to open browser for interactive plot """ {'GOOG': 0.020889868669945022, diff --git a/pypfopt/cla.py b/pypfopt/cla.py index 884fbb58..5894ce09 100644 --- a/pypfopt/cla.py +++ b/pypfopt/cla.py @@ -47,7 +47,7 @@ class CLA(base_optimizer.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): """ :param expected_returns: expected returns for each asset. Set to None if optimising for volatility only. @@ -57,15 +57,78 @@ def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)): :param weight_bounds: minimum and maximum weight of an asset, defaults to (0, 1). Must be changed to (-1, 1) for portfolios with shorting. :type weight_bounds: tuple (float, float) or (list/ndarray, list/ndarray) or list(tuple(float, float)) + :param use_cvxcla: if True, use cvxcla backend for faster performance. Defaults to False. + :type use_cvxcla: bool :raises TypeError: if ``expected_returns`` is not a series, list or array :raises TypeError: if ``cov_matrix`` is not a dataframe or array """ - # Initialize the class + # Store backend choice + self.use_cvxcla = use_cvxcla + + # Setup cvxcla backend if requested + if use_cvxcla: + try: + from cvxcla import CLA as CVXCLAEngine + # Convert to cvxcla format + self.mean = np.asarray(expected_returns).flatten() + self.expected_returns = self.mean # For backward compatibility + n_assets = len(self.mean) + + # Handle weight bounds + if len(weight_bounds) == len(self.mean) and not isinstance(weight_bounds[0], (float, int)): + self.lower_bounds = np.array([b[0] for b in weight_bounds]) + self.upper_bounds = np.array([b[1] for b in weight_bounds]) + else: + self.lower_bounds = np.full(n_assets, weight_bounds[0]) + self.upper_bounds = np.full(n_assets, weight_bounds[1]) + + # Store cvxcla initialization parameters BEFORE setting cov_matrix property + self._cvxcla_mean = self.mean + self._cvxcla_bounds = (self.lower_bounds, self.upper_bounds) + self._cvxcla_cov_matrix = np.asarray(cov_matrix) # Direct assignment to avoid property setter during init + self.n_assets = n_assets # Set n_assets before creating engine + + # Create cvxcla engine + self._cvxcla_engine = CVXCLAEngine( + mean=self.mean, + covariance=self._cvxcla_cov_matrix, + lower_bounds=self.lower_bounds, + upper_bounds=self.upper_bounds, + a=np.ones((1, n_assets)), # Fully invested constraint + b=np.ones(1) + ) + + # Store ticker mapping for backward compatibility + if hasattr(expected_returns, 'index'): + self.tickers = list(expected_returns.index) + else: + self.tickers = list(range(n_assets)) + + # Set n_assets for backward compatibility + self.n_assets = n_assets + + # Add frontier_values for plotting compatibility + self.frontier_values = None + + # Initialize parent class + super().__init__(n_assets, self.tickers) + return # Skip the original initialization + + except ImportError: + import warnings + warnings.warn( + "cvxcla not available, falling back to standard implementation. " + "Install with: pip install cvxcla", + RuntimeWarning + ) + self.use_cvxcla = False + + # Original initialization code self.mean = np.array(expected_returns).reshape((len(expected_returns), 1)) # if (self.mean == np.ones(self.mean.shape) * self.mean.mean()).all(): # self.mean[-1, 0] += 1e-5 self.expected_returns = self.mean.reshape((len(self.mean),)) - self.cov_matrix = np.asarray(cov_matrix) + self._cov_matrix = np.asarray(cov_matrix) # Use _cov_matrix for original implementation # Bounds if len(weight_bounds) == len(self.mean) and not isinstance( @@ -96,6 +159,33 @@ def __init__(self, expected_returns, cov_matrix, weight_bounds=(0, 1)): tickers = list(range(len(self.mean))) super().__init__(len(tickers), tickers) + def _recreate_cvxcla_engine(self): + """Recreate cvxcla engine when parameters change (e.g., covariance matrix).""" + if self.use_cvxcla and hasattr(self, '_cvxcla_mean') and hasattr(self, '_cvxcla_bounds'): + from cvxcla import CLA as CVXCLAEngine + self._cvxcla_engine = CVXCLAEngine( + mean=self._cvxcla_mean, + covariance=self._cvxcla_cov_matrix, + lower_bounds=self._cvxcla_bounds[0], + upper_bounds=self._cvxcla_bounds[1], + a=np.ones((1, self.n_assets)), # Fully invested constraint + b=np.ones(1) + ) + + @property + def cov_matrix(self): + """Get the covariance matrix.""" + return self._cvxcla_cov_matrix if self.use_cvxcla else self._cov_matrix + + @cov_matrix.setter + def cov_matrix(self, new_cov_matrix): + """Set the covariance matrix and update cvxcla engine if needed.""" + if self.use_cvxcla: + self._cvxcla_cov_matrix = np.asarray(new_cov_matrix) + self._recreate_cvxcla_engine() + else: + self._cov_matrix = new_cov_matrix + @staticmethod def _infnone(x): """ @@ -138,14 +228,16 @@ def _compute_w(self, covarF_inv, covarFB, meanF, wB): g1 = np.dot(np.dot(onesF.T, covarF_inv), meanF) g2 = np.dot(np.dot(onesF.T, covarF_inv), onesF) if wB is None: - g, w1 = float(-self.ls[-1] * g1 / g2 + 1 / g2), 0 + g_result = -self.ls[-1] * g1 / g2 + 1 / g2 + g, w1 = float(g_result.item() if hasattr(g_result, 'item') else g_result), 0 else: onesB = np.ones(wB.shape) g3 = np.dot(onesB.T, wB) g4 = np.dot(covarF_inv, covarFB) w1 = np.dot(g4, wB) g4 = np.dot(onesF.T, w1) - g = float(-self.ls[-1] * g1 / g2 + (1 - g3 + g4) / g2) + g_result = -self.ls[-1] * g1 / g2 + (1 - g3 + g4) / g2 + g = float(g_result.item() if hasattr(g_result, 'item') else g_result) # 2) compute weights w2 = np.dot(covarF_inv, onesF) w3 = np.dot(covarF_inv, meanF) @@ -167,14 +259,16 @@ def _compute_lambda(self, covarF_inv, covarFB, meanF, wB, i, bi): # 3) Lambda if wB is None: # All free assets - return float((c4[i] - c1 * bi) / c), bi + result = (c4[i] - c1 * bi) / c + return float(result.item() if hasattr(result, 'item') else result), bi else: onesB = np.ones(wB.shape) l1 = np.dot(onesB.T, wB) l2 = np.dot(covarF_inv, covarFB) l3 = np.dot(l2, wB) l2 = np.dot(onesF.T, l3) - return float(((1 - l1 + l2) * c4[i] - c1 * (bi + l3[i])) / c), bi + result = ((1 - l1 + l2) * c4[i] - c1 * (bi + l3[i])) / c + return float(result.item() if hasattr(result, 'item') else result), bi def _get_matrices(self, f): # Slice covarF,covarFB,covarB,meanF,meanB,wF,wB @@ -376,6 +470,14 @@ def max_sharpe(self): :return: asset weights for the max-sharpe portfolio :rtype: OrderedDict """ + # Use cvxcla backend if enabled + if self.use_cvxcla: + _, weights = self._cvxcla_engine.frontier.max_sharpe + self.weights = weights + # Convert to OrderedDict with tickers + return dict(zip(self.tickers, weights)) + + # Original implementation if not self.w: self._solve() # 1) Compute the local max SR portfolio between any two neighbor turning points @@ -398,6 +500,15 @@ def min_volatility(self): :return: asset weights for the volatility-minimising portfolio :rtype: OrderedDict """ + # Use cvxcla backend if enabled + if self.use_cvxcla: + # Last point on efficient frontier = minimum variance portfolio + weights = self._cvxcla_engine.frontier.weights[-1] + self.weights = weights + # Convert to OrderedDict with tickers + return dict(zip(self.tickers, weights)) + + # Original implementation if not self.w: self._solve() var = [] @@ -418,6 +529,16 @@ def efficient_frontier(self, points=100): :return: return list, std list, weight list :rtype: (float list, float list, np.ndarray list) """ + # Use cvxcla backend if enabled + if self.use_cvxcla: + frontier = self._cvxcla_engine.frontier.interpolate(points) + mu = frontier.returns.tolist() + sigma = frontier.volatility.tolist() + weights = [w for w in frontier.weights] + self.frontier_values = (mu, sigma, weights) + return mu, sigma, weights + + # Original implementation if not self.w: self._solve() diff --git a/pypfopt/plotting.py b/pypfopt/plotting.py index 4bc3c8fa..1f1c3b57 100644 --- a/pypfopt/plotting.py +++ b/pypfopt/plotting.py @@ -386,6 +386,10 @@ def plot_efficient_frontier( xaxis_title="Volatility", yaxis_title="Return", ) + # Handle showfig for interactive plotly plots + showfig = kwargs.get("showfig", False) + if showfig: + ax.show() else: ax.legend() ax.set_xlabel("Volatility") diff --git a/pyproject.toml b/pyproject.toml index 4a6e4cfd..acefb836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,9 +32,12 @@ classifiers=[ # core dependencies of pyportfolioopt # this set should be kept minimal! dependencies = [ + "cvxcla>=1.5.1", "cvxpy>=1.1.19", "numpy>=1.26.0", + "packaging>=26.0", "pandas>=0.19", + "plotly>=6.5.2", "scikit-base<0.14.0", "scikit-learn>=0.24.1", "scipy>=1.3.0", @@ -108,6 +111,12 @@ indent-style = "space" line-ending = "auto" skip-magic-trailing-comma = false +[dependency-groups] +dev = [ + "pytest>=9.0.2", + "pytest-cov>=7.0.0", +] + [tool.ruff.lint.isort] known-first-party = ["pypfopt"] combine-as-imports = true diff --git a/tests/test_cvxcla_backend.py b/tests/test_cvxcla_backend.py new file mode 100644 index 00000000..3ca71974 --- /dev/null +++ b/tests/test_cvxcla_backend.py @@ -0,0 +1,111 @@ +import numpy as np + +from pypfopt import risk_models +from tests.utilities_for_tests import get_data, setup_cla + + +def test_cvxcla_backend_available(): + """Test that cvxcla backend can be enabled successfully.""" + cla_fast = setup_cla(use_cvxcla=True) + assert cla_fast.use_cvxcla is True + assert hasattr(cla_fast, '_cvxcla_engine') + + +def test_cvxcla_max_sharpe_parity(): + """Test that both backends produce equivalent max Sharpe results.""" + # Original implementation + cla_original = setup_cla(use_cvxcla=False) + weights_original = cla_original.max_sharpe() + perf_original = cla_original.portfolio_performance(risk_free_rate=0.02) + + # cvxcla implementation + cla_fast = setup_cla(use_cvxcla=True) + weights_fast = cla_fast.max_sharpe() + perf_fast = cla_fast.portfolio_performance(risk_free_rate=0.02) + + # Check weights and performance are similar + weights_orig_array = np.array(list(weights_original.values())) + weights_fast_array = np.array(list(weights_fast.values())) + np.testing.assert_allclose(weights_orig_array, weights_fast_array, rtol=1e-4, atol=1e-6) + np.testing.assert_allclose(perf_original, perf_fast, rtol=1e-4, atol=1e-6) + + +def test_cvxcla_min_volatility_parity(): + """Test that both backends produce equivalent min volatility results.""" + # Original implementation + cla_original = setup_cla(use_cvxcla=False) + weights_original = cla_original.min_volatility() + perf_original = cla_original.portfolio_performance(risk_free_rate=0.02) + + # cvxcla implementation + cla_fast = setup_cla(use_cvxcla=True) + weights_fast = cla_fast.min_volatility() + perf_fast = cla_fast.portfolio_performance(risk_free_rate=0.02) + + # Check weights and performance are similar + weights_orig_array = np.array(list(weights_original.values())) + weights_fast_array = np.array(list(weights_fast.values())) + np.testing.assert_allclose(weights_orig_array, weights_fast_array, rtol=1e-4, atol=1e-6) + np.testing.assert_allclose(perf_original, perf_fast, rtol=1e-4, atol=1e-6) + + +def test_cvxcla_cov_matrix_update(): + """Test that covariance matrix updates work correctly in cvxcla backend.""" + df = get_data() + S2 = risk_models.exp_cov(df) + + # Test cvxcla backend with covariance update + cla_fast = setup_cla(use_cvxcla=True) + weights_1 = cla_fast.max_sharpe() + + # Update covariance matrix + cla_fast.cov_matrix = S2.values + weights_2 = cla_fast.max_sharpe() + + # Results should be different after covariance update + weights_1_array = np.array(list(weights_1.values())) + weights_2_array = np.array(list(weights_2.values())) + assert not np.allclose(weights_1_array, weights_2_array) + + # Compare with original implementation + cla_original = setup_cla(use_cvxcla=False) + cla_original.cov_matrix = S2.values + weights_original = cla_original.max_sharpe() + weights_orig_array = np.array(list(weights_original.values())) + + # Should match original implementation + np.testing.assert_allclose(weights_2_array, weights_orig_array, rtol=1e-4, atol=1e-6) + + +def test_cvxcla_efficient_frontier(): + """Test that efficient frontier computation produces similar results.""" + # Original implementation + cla_original = setup_cla(use_cvxcla=False) + mu_orig, sigma_orig, weights_orig = cla_original.efficient_frontier(points=50) + + # cvxcla implementation + cla_fast = setup_cla(use_cvxcla=True) + mu_fast, sigma_fast, weights_fast = cla_fast.efficient_frontier(points=50) + + # Both should produce non-empty frontiers + assert len(mu_orig) > 0 + assert len(mu_fast) > 0 + assert len(sigma_orig) == len(mu_orig) + assert len(sigma_fast) == len(mu_fast) + assert len(weights_orig) == len(mu_orig) + assert len(weights_fast) == len(mu_fast) + + # Check that frontier endpoints are similar + min_return_orig, max_return_orig = min(mu_orig), max(mu_orig) + min_return_fast, max_return_fast = min(mu_fast), max(mu_fast) + + assert abs(min_return_orig - min_return_fast) < 0.01 + assert abs(max_return_orig - max_return_fast) < 0.01 + + # Check that minimum volatility points are similar + min_vol_orig = min(sigma_orig) + min_vol_fast = min(sigma_fast) + assert abs(min_vol_orig - min_vol_fast) < 0.01 + + # frontier_values should be set for plotting compatibility + assert cla_fast.frontier_values is not None \ No newline at end of file diff --git a/tests/test_efficient_cdar.py b/tests/test_efficient_cdar.py index 746efdec..3e142740 100644 --- a/tests/test_efficient_cdar.py +++ b/tests/test_efficient_cdar.py @@ -86,7 +86,7 @@ def test_cdar_example_weekly(): def test_cdar_example_monthly(): beta = 0.90 df = get_data() - df = df.resample("M").first() + df = df.resample("ME").first() mu = expected_returns.mean_historical_return(df, frequency=12) historical_rets = expected_returns.returns_from_prices(df).dropna() cd = EfficientCDaR(mu, historical_rets, beta=beta) diff --git a/tests/test_efficient_cvar.py b/tests/test_efficient_cvar.py index 2d20c79e..942a2ab8 100644 --- a/tests/test_efficient_cvar.py +++ b/tests/test_efficient_cvar.py @@ -94,7 +94,7 @@ def test_cvar_example_weekly(): def test_cvar_example_monthly(): beta = 0.95 df = get_data() - df = df.resample("M").first() + df = df.resample("ME").first() mu = expected_returns.mean_historical_return(df, frequency=12) historical_rets = expected_returns.returns_from_prices(df).dropna() cv = EfficientCVaR(mu, historical_rets, beta=beta) diff --git a/tests/test_efficient_semivariance.py b/tests/test_efficient_semivariance.py index 7da9655b..c5655f6b 100644 --- a/tests/test_efficient_semivariance.py +++ b/tests/test_efficient_semivariance.py @@ -120,7 +120,7 @@ def test_es_example_weekly(): def test_es_example_monthly(): df = get_data() - df = df.resample("M").first() + df = df.resample("ME").first() mu = expected_returns.mean_historical_return(df, frequency=12) historical_rets = expected_returns.returns_from_prices(df).dropna() es = EfficientSemivariance(mu, historical_rets, frequency=12)