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
60 changes: 48 additions & 12 deletions pypfopt/discrete_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -297,25 +342,16 @@ 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
-------
(dict, float)
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}
Expand Down
28 changes: 28 additions & 0 deletions tests/test_discrete_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)