Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions pypfopt/discrete_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand All @@ -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"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_discrete_allocation.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import cvxpy as cp
from cvxpy.error import SolverError
import numpy as np
import pandas as pd
Expand Down Expand Up @@ -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)
Expand Down