From a8d9ac6410414251e0a649b4bece0bb27cccdc28 Mon Sep 17 00:00:00 2001 From: xvruipeng-cyber <317357528+xvruipeng-cyber@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:47:03 +0800 Subject: [PATCH 1/3] Skip the removed ECOS_BB solver when cvxpy no longer provides it. --- pypfopt/discrete_allocation.py | 10 +++++-- tests/test_discrete_allocation.py | 49 +++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) 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..a1b78682 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -223,6 +223,55 @@ 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" + + def test_lp_portfolio_allocation(): ef = setup_efficient_frontier() w = ef.max_sharpe(risk_free_rate=0.02) From 0775848f1b6416472ba85a93b03932b3c101ccef Mon Sep 17 00:00:00 2001 From: xvruipeng-cyber <317357528+xvruipeng-cyber@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:17:51 +0800 Subject: [PATCH 2/3] Solve a small allocation when cvxpy does not provide ECOS_BB. --- tests/test_discrete_allocation.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index a1b78682..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 @@ -272,6 +273,22 @@ def fake_solve(self, *args, **kwargs): 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) From 45fb8c9caa9fd98177f183151cc0ec47d9561960 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 27 Oct 2026 07:45:58 +0000 Subject: [PATCH 3/3] Test lp_portfolio when ECOS_BB is unregistered. Monkeypatch installed_solvers to omit ECOS_BB while the ecos check still passes, and solve a small allocation. That fails on main, which still requests ECOS_BB, instead of skipping when the solver is registered. Co-authored-by: xvruipeng-cyber --- pypfopt/discrete_allocation.py | 4 +-- tests/test_discrete_allocation.py | 41 +++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index ac64c10d..ffe5cbdc 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -306,8 +306,8 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): of funds leftover. """ # todo 1.7.0: remove this defaulting behavior - # cvxpy 1.6 dropped ECOS_BB. The ecos package can still be installed, - # but selecting a solver cvxpy no longer registers fails the allocation. + # The ecos distribution can be present while cvxpy does not register + # ECOS_BB. Selecting that missing solver raises SolverError. if ( solver is None and _check_soft_dependencies("ecos", severity="none") diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index 56f26d17..d2323208 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -273,17 +273,44 @@ def fake_solve(self, *args, **kwargs): 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.""" +def test_lp_portfolio_solves_when_ecos_imports_but_ecos_bb_is_unregistered( + monkeypatch, +): + """Solve when the ecos check passes but cvxpy does not list ECOS_BB. + + ``installed_solvers`` is patched to drop ECOS_BB and the ecos + soft-dependency check is forced true. main still requests ECOS_BB, + so this fails there. Here the solver stays unset and a two-asset + allocation solves. + """ + requested = {} + real_solve = cp.Problem.solve + real_installed_solvers = cp.installed_solvers + + def solve_and_record(self, *args, **kwargs): + requested["solver"] = kwargs.get("solver") + return real_solve(self, *args, **kwargs) + + def installed_solvers_without_ecos_bb(): + return [name for name in real_installed_solvers() if name != "ECOS_BB"] + + monkeypatch.setattr( + "pypfopt.discrete_allocation._check_soft_dependencies", + lambda *args, **kwargs: True, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.installed_solvers", + installed_solvers_without_ecos_bb, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.Problem.solve", solve_and_record + ) + 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 requested["solver"] is None assert allocation["A"] * 10 + allocation["B"] * 20 + leftover == pytest.approx(1000) assert allocation["A"] > 0 assert allocation["B"] > 0