diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index f4033e0d..ac64c10d 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -297,7 +297,7 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): print error analysis? Defaults to False. solver : str, optional the CVXPY solver to use (must support mixed-integer programs). - Defaults to "ECOS_BB" if ecos is installed, else None. + Defaults to "ECOS_BB" when that solver is installed, else None. Returns ------- @@ -306,7 +306,13 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): of funds leftover. """ # todo 1.7.0: remove this defaulting behavior - if solver is None and _check_soft_dependencies("ecos", severity="none"): + # cvxpy 1.6 dropped ECOS_BB. The ecos package can still be installed, + # but selecting a solver cvxpy no longer registers fails the allocation. + if ( + solver is None + and _check_soft_dependencies("ecos", severity="none") + and "ECOS_BB" in cp.installed_solvers() + ): solver = "ECOS_BB" warn( "The default solver for lp_portfolio will change from ECOS_BB to" diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index f0953186..56f26d17 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -1,3 +1,4 @@ +import cvxpy as cp from cvxpy.error import SolverError import numpy as np import pandas as pd @@ -223,6 +224,71 @@ def test_greedy_portfolio_allocation_short_different_params_reinvest(): np.testing.assert_almost_equal(long_total + short_total + leftover, 100000) +def test_lp_portfolio_skips_ecos_bb_when_cvxpy_removed_it(monkeypatch): + requested = {} + + def fake_solve(self, *args, **kwargs): + requested["solver"] = kwargs.get("solver") + raise SolverError("stop before a real solve") + + monkeypatch.setattr( + "pypfopt.discrete_allocation._check_soft_dependencies", + lambda *args, **kwargs: True, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.installed_solvers", + lambda: ["OSQP", "SCS"], + ) + monkeypatch.setattr("pypfopt.discrete_allocation.cp.Problem.solve", fake_solve) + + prices = pd.Series({"A": 10.0, "B": 20.0}) + da = DiscreteAllocation({"A": 0.5, "B": 0.5}, prices, total_portfolio_value=100) + with pytest.raises(SolverError): + da.lp_portfolio() + assert requested["solver"] is None + + +def test_lp_portfolio_still_defaults_to_ecos_bb_when_installed(monkeypatch): + requested = {} + + def fake_solve(self, *args, **kwargs): + requested["solver"] = kwargs.get("solver") + raise SolverError("stop before a real solve") + + monkeypatch.setattr( + "pypfopt.discrete_allocation._check_soft_dependencies", + lambda *args, **kwargs: True, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.installed_solvers", + lambda: ["ECOS_BB", "OSQP"], + ) + monkeypatch.setattr("pypfopt.discrete_allocation.cp.Problem.solve", fake_solve) + + prices = pd.Series({"A": 10.0, "B": 20.0}) + da = DiscreteAllocation({"A": 0.5, "B": 0.5}, prices, total_portfolio_value=100) + with pytest.warns(FutureWarning, match="ECOS_BB"): + with pytest.raises(SolverError): + da.lp_portfolio() + assert requested["solver"] == "ECOS_BB" + + +@pytest.mark.skipif( + "ECOS_BB" in cp.installed_solvers(), + reason="ECOS_BB is registered, so the missing-solver path is not active", +) +def test_lp_portfolio_solves_when_ecos_bb_is_absent(): + """cvxpy 1.6 dropped ECOS. A two-asset allocation must still solve.""" + prices = pd.Series({"A": 10.0, "B": 20.0}) + da = DiscreteAllocation({"A": 0.5, "B": 0.5}, prices, total_portfolio_value=1000) + + allocation, leftover = da.lp_portfolio() + + assert allocation["A"] * 10 + allocation["B"] * 20 + leftover == pytest.approx(1000) + assert allocation["A"] > 0 + assert allocation["B"] > 0 + + def test_lp_portfolio_allocation(): ef = setup_efficient_frontier() w = ef.max_sharpe(risk_free_rate=0.02)