diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index f4033e0d..b7a21397 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -14,6 +14,19 @@ from . import exceptions +_MIP_SOLVER_PREFERENCE = ( + "HIGHS", + "GLPK_MI", + "CBC", + "SCIP", + "GUROBI", + "CPLEX", + "XPRESS", + "COPT", + "MOSEK", +) + + def get_latest_prices(prices): """ A helper tool which retrieves the most recent asset prices from a dataframe of @@ -116,6 +129,38 @@ def _remove_zero_positions(allocation): """ return {k: v for k, v in allocation.items() if v != 0} + @staticmethod + def _choose_mip_solver(solver): + """ + Select a mixed-integer solver for the LP allocation path. + + If the caller specifies a solver explicitly, leave it unchanged. Otherwise, + preserve the legacy ECOS_BB preference when ecos is installed, and fall back + to another installed mixed-integer solver such as HiGHS. + """ + if solver is not None: + return solver + + if _check_soft_dependencies("ecos", severity="none"): + warn( + "The default solver for lp_portfolio will change from ECOS_BB to" + "None, the cvxpy default solver, in release 1.7.0." + "To continue using ECOS_BB as the solver, " + "please set solver='ECOS_BB' explicitly.", + FutureWarning, + ) + return "ECOS_BB" + + installed_solvers = set(cp.installed_solvers()) + for candidate in _MIP_SOLVER_PREFERENCE: + if candidate in installed_solvers: + return candidate + + raise exceptions.OptimizationError( + "Please install a mixed-integer solver such as HiGHS or ecos, " + "or pass a compatible solver explicitly." + ) + def _allocation_rmse_error(self, verbose=True): """ Utility function to calculate and print RMSE error between discretised @@ -297,7 +342,8 @@ 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" if ecos is installed, otherwise the first + installed mixed-integer solver supported by cvxpy. Returns ------- @@ -305,17 +351,7 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): the number of shares of each ticker that should be purchased, along with the amount of funds leftover. """ - # todo 1.7.0: remove this defaulting behavior - if solver is None and _check_soft_dependencies("ecos", severity="none"): - solver = "ECOS_BB" - warn( - "The default solver for lp_portfolio will change from ECOS_BB to" - "None, the cvxpy default solver, in release 1.7.0." - "To continue using ECOS_BB as the solver, " - "please set solver='ECOS_BB' explicitly.", - FutureWarning, - ) - # end todo + solver = self._choose_mip_solver(solver) if any([w < 0 for _, w in self.weights]): longs = {t: w for t, w in self.weights if w >= 0} diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index f0953186..0ad22868 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -3,6 +3,7 @@ import pandas as pd import pytest +from pypfopt import exceptions from pypfopt.discrete_allocation import DiscreteAllocation, get_latest_prices from tests.utilities_for_tests import get_data, setup_efficient_frontier @@ -454,3 +455,30 @@ def test_allocation_errors(): latest_prices.iloc[0] = np.nan with pytest.raises(TypeError): DiscreteAllocation(w, latest_prices) + + +def test_choose_mip_solver_falls_back_to_highs(monkeypatch): + monkeypatch.setattr( + "pypfopt.discrete_allocation._check_soft_dependencies", + lambda *args, **kwargs: False, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.installed_solvers", + lambda: ["OSQP", "HIGHS", "SCS"], + ) + + assert DiscreteAllocation._choose_mip_solver(None) == "HIGHS" + + +def test_choose_mip_solver_requires_mip_backend(monkeypatch): + monkeypatch.setattr( + "pypfopt.discrete_allocation._check_soft_dependencies", + lambda *args, **kwargs: False, + ) + monkeypatch.setattr( + "pypfopt.discrete_allocation.cp.installed_solvers", + lambda: ["OSQP", "SCS", "CLARABEL"], + ) + + with pytest.raises(exceptions.OptimizationError, match="mixed-integer solver"): + DiscreteAllocation._choose_mip_solver(None)