Skip to content
Open
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,7 @@ dist

artifacts

# uv lock file
uv.lock

bin
12 changes: 6 additions & 6 deletions example/examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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%
Expand Down Expand Up @@ -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,
Expand Down
123 changes: 120 additions & 3 deletions pypfopt/cla.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -387,6 +477,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
Expand All @@ -409,6 +507,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 = []
Expand All @@ -429,6 +536,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()

Expand Down
4 changes: 4 additions & 0 deletions pypfopt/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
111 changes: 111 additions & 0 deletions tests/test_cvxcla_backend.py
Original file line number Diff line number Diff line change
@@ -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