diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2d29a97f..7d1d2e5f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -50,9 +50,50 @@ jobs: echo "No changed files to check." fi - pytest: + pytest-nosoftdeps: needs: code-quality - name: py${{ matrix.python-version }} on ${{ matrix.os }} + name: nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }}) + runs-on: ${{ matrix.os }} + env: + MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + fail-fast: false # to not fail all combinations if just one fails + + steps: + - uses: actions/checkout@v5 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v6 + with: + python-version: ${{ matrix.python-version }} + + - name: Display Python version + run: python -c "import sys; print(sys.version)" + + - name: Install dependencies + shell: bash + run: uv pip install ".[dev]" --no-cache-dir + env: + UV_SYSTEM_PYTHON: 1 + + - name: Show dependencies + run: uv pip list + + - name: Test with pytest + run: | + pytest ./tests + + pytest: + needs: pytest-nosoftdeps + name: (${{ matrix.python-version }}, ${{ matrix.os }}) runs-on: ${{ matrix.os }} env: MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434 @@ -92,7 +133,7 @@ jobs: pytest ./tests codecov: - name: py${{ matrix.python-version }} on ${{ matrix.os }} + name: coverage (${{ matrix.python-version }} on ${{ matrix.os }} runs-on: ${{ matrix.os }} needs: code-quality env: @@ -141,8 +182,8 @@ jobs: fail_ci_if_error: true notebooks: - runs-on: ubuntu-latest needs: code-quality + runs-on: ubuntu-latest strategy: matrix: diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index d67f9116..4e4d8d4c 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -4,10 +4,12 @@ """ import collections +from warnings import warn import cvxpy as cp import numpy as np import pandas as pd +from skbase.utils.dependencies import _check_soft_dependencies from . import exceptions @@ -252,7 +254,8 @@ def greedy_portfolio(self, reinvest=False, verbose=False): self._allocation_rmse_error(verbose) return self.allocation, available_funds - def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"): + # todo 1.7.0: remove ECOS_BB defaulting behavior from docstring + def lp_portfolio(self, reinvest=False, verbose=False, solver=None): """ Convert continuous weights into a discrete portfolio allocation using integer programming. @@ -262,11 +265,23 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"): :param verbose: print error analysis? :type verbose: bool :param solver: the CVXPY solver to use (must support mixed-integer programs) - :type solver: str, defaults to "ECOS_BB" + :type solver: str, defaults to "ECOS_BB" if ecos is installed, else None :return: the number of shares of each ticker that should be purchased, along with the amount of funds leftover. :rtype: (dict, float) """ + # 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 + if any([w < 0 for _, w in self.weights]): longs = {t: w for t, w in self.weights if w >= 0} shorts = {t: -w for t, w in self.weights if w < 0} diff --git a/pypfopt/plotting.py b/pypfopt/plotting.py index 54fcf48a..4bc3c8fa 100644 --- a/pypfopt/plotting.py +++ b/pypfopt/plotting.py @@ -16,10 +16,15 @@ from . import CLA, EfficientFrontier, exceptions, risk_models -try: - import matplotlib.pyplot as plt -except (ModuleNotFoundError, ImportError): # pragma: no cover - raise ImportError("Please install matplotlib via pip or poetry") + +def _import_matplotlib(): + """Helper function to import matplotlib only when needed""" + try: + import matplotlib.pyplot as plt + + return plt + except (ModuleNotFoundError, ImportError): # pragma: no cover + raise ImportError("Please install matplotlib via pip or poetry") def _get_plotly(): @@ -46,6 +51,8 @@ def _plot_io(**kwargs): :param showfig: whether to plt.show() the figure, defaults to False :type showfig: bool, optional """ + plt = _import_matplotlib() + filename = kwargs.get("filename", None) showfig = kwargs.get("showfig", False) dpi = kwargs.get("dpi", 300) @@ -73,6 +80,8 @@ def plot_covariance(cov_matrix, plot_correlation=False, show_tickers=True, **kwa :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + if plot_correlation: matrix = risk_models.cov_to_corr(cov_matrix) else: @@ -110,6 +119,8 @@ def plot_dendrogram(hrp, ax=None, show_tickers=True, **kwargs): :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + ax = ax or plt.gca() if hrp.clusters is None: @@ -337,6 +348,8 @@ def plot_efficient_frontier( :return: matplotlib axis :rtype: matplotlib.axes object """ + plt = _import_matplotlib() + if interactive: go, _ = _get_plotly() ax = go.Figure() @@ -393,6 +406,8 @@ def plot_weights(weights, ax=None, **kwargs): :return: matplotlib axis :rtype: matplotlib.axes """ + plt = _import_matplotlib() + ax = ax or plt.gca() desc = sorted(weights.items(), key=lambda x: x[1], reverse=True) diff --git a/pyproject.toml b/pyproject.toml index 9e7a45f8..f18b8fbc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "pandas>=0.19", "scikit-learn>=0.24.1", "scipy>=1.3.0", + "scikit-base<0.14.0", ] [project.optional-dependencies] diff --git a/tests/test_efficient_cdar.py b/tests/test_efficient_cdar.py index 4ed6d57b..746efdec 100644 --- a/tests/test_efficient_cdar.py +++ b/tests/test_efficient_cdar.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import EfficientCDaR, expected_returns, objective_functions from pypfopt.exceptions import OptimizationError @@ -151,6 +152,10 @@ def test_min_cdar_extra_constraints(): assert w["GOOG"] >= 0.025 and w["MA"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cdar_different_solver(): cd = setup_efficient_cdar(solver="ECOS") w = cd.min_cdar() @@ -182,6 +187,10 @@ def test_min_cdar_tx_costs(): assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum() +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cdar_L2_reg(): cd = setup_efficient_cdar(solver="ECOS") cd.add_objective(objective_functions.L2_reg, gamma=0.1) diff --git a/tests/test_efficient_cvar.py b/tests/test_efficient_cvar.py index 14a2d863..2d20c79e 100644 --- a/tests/test_efficient_cvar.py +++ b/tests/test_efficient_cvar.py @@ -1,5 +1,6 @@ import numpy as np import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import EfficientCVaR, expected_returns, objective_functions from pypfopt.exceptions import OptimizationError @@ -156,6 +157,10 @@ def test_min_cvar_extra_constraints(): assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cvar_different_solver(): cv = setup_efficient_cvar(solver="ECOS") w = cv.min_cvar() @@ -186,6 +191,10 @@ def test_min_cvar_tx_costs(): assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum() +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_cvar_L2_reg(): cv = setup_efficient_cvar(solver="ECOS") cv.add_objective(objective_functions.L2_reg, gamma=0.1) diff --git a/tests/test_efficient_frontier.py b/tests/test_efficient_frontier.py index 580f2dd5..4028e1a7 100644 --- a/tests/test_efficient_frontier.py +++ b/tests/test_efficient_frontier.py @@ -5,6 +5,7 @@ import pandas as pd import pytest import scipy.optimize as sco +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( EfficientFrontier, @@ -106,6 +107,10 @@ def test_min_volatility(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_volatility_different_solver(): ef = setup_efficient_frontier(solver="ECOS") w = ef.min_volatility() @@ -1042,6 +1047,10 @@ def test_efficient_risk_market_neutral_L2_reg(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_risk_market_neutral_warning(): ef = setup_efficient_frontier(solver=cp.ECOS) with pytest.warns(RuntimeWarning) as w: @@ -1088,6 +1097,10 @@ def test_efficient_frontier_error(): EfficientFrontier(ef.expected_returns, 0.01) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_return_many_values(): ef = setup_efficient_frontier(solver=cp.ECOS) for target_return in np.arange(0.25, 0.28, 0.01): @@ -1217,6 +1230,10 @@ def test_efficient_return_market_neutral_unbounded(): assert long_only_sharpe < sharpe +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_efficient_return_market_neutral_warning(): # This fails ef = setup_efficient_frontier(solver=cp.ECOS) diff --git a/tests/test_efficient_semivariance.py b/tests/test_efficient_semivariance.py index 4aa9cc51..7da9655b 100644 --- a/tests/test_efficient_semivariance.py +++ b/tests/test_efficient_semivariance.py @@ -1,6 +1,7 @@ +from cvxpy.error import SolverError import numpy as np import pytest -from cvxpy.error import SolverError +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( EfficientFrontier, @@ -176,6 +177,10 @@ def test_min_semivariance_extra_constraints(): assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035 +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_min_semivariance_different_solver(): es = setup_efficient_semivariance(solver="ECOS") w = es.min_semivariance() @@ -347,6 +352,10 @@ def test_max_quadratic_utility_with_shorts(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["ecos"], severity="none"), + reason="skip test if ecos is not installed in environment", +) def test_max_quadratic_utility_market_neutral(): es = setup_efficient_semivariance(solver="ECOS", weight_bounds=(-1, 1)) es.max_quadratic_utility(market_neutral=True) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 17697924..a4e5a3fb 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,11 +1,10 @@ import os import tempfile -import matplotlib -import matplotlib.pyplot as plt import numpy as np import pandas as pd import pytest +from skbase.utils.dependencies import _check_soft_dependencies from pypfopt import ( CLA, @@ -18,7 +17,13 @@ from tests.utilities_for_tests import get_data, setup_efficient_frontier +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_correlation_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() S = risk_models.CovarianceShrinkage(df).ledoit_wolf() @@ -49,7 +54,14 @@ def test_correlation_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_dendrogram_plot(): + import matplotlib + import matplotlib.pyplot as plt + plt.figure() df = get_data() returns = df.pct_change().dropna(how="all") @@ -83,7 +95,13 @@ def test_dendrogram_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_cla_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() rets = expected_returns.mean_historical_return(df) @@ -100,7 +118,13 @@ def test_cla_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_cla_plot_ax(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() rets = expected_returns.mean_historical_return(df) @@ -114,7 +138,13 @@ def test_cla_plot_ax(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_default_ef_plot(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ax = plotting.plot_efficient_frontier(ef, show_assets=True) @@ -131,7 +161,13 @@ def test_default_ef_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_default_ef_plot_labels(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ax = plotting.plot_efficient_frontier(ef, show_assets=True, show_tickers=True) @@ -139,7 +175,13 @@ def test_default_ef_plot_labels(): plt.clf() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_utility(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() delta_range = np.arange(0.001, 50, 1) @@ -151,7 +193,13 @@ def test_ef_plot_utility(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_errors(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() delta_range = np.arange(0.001, 50, 1) @@ -169,7 +217,13 @@ def test_ef_plot_errors(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_risk(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ef.min_volatility() @@ -185,7 +239,13 @@ def test_ef_plot_risk(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_return(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() # Internally _max_return() is used, so subtract epsilon @@ -199,7 +259,13 @@ def test_ef_plot_return(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_ef_plot_utility_short(): + import matplotlib.pyplot as plt + plt.figure() ef = EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) @@ -213,7 +279,13 @@ def test_ef_plot_utility_short(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_constrained_ef_plot_utility(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() ef.add_constraint(lambda w: w[0] >= 0.2) @@ -229,7 +301,13 @@ def test_constrained_ef_plot_utility(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_constrained_ef_plot_risk(): + import matplotlib.pyplot as plt + plt.figure() ef = EfficientFrontier( *setup_efficient_frontier(data_only=True), weight_bounds=(None, None) @@ -249,7 +327,13 @@ def test_constrained_ef_plot_risk(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot(): + import matplotlib.pyplot as plt + plt.figure() df = get_data() returns = df.pct_change().dropna(how="all") @@ -262,7 +346,13 @@ def test_weight_plot(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot_multi(): + import matplotlib.pyplot as plt + ef = setup_efficient_frontier() w1 = ef.min_volatility() ef = setup_efficient_frontier() @@ -278,7 +368,13 @@ def test_weight_plot_multi(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_weight_plot_add_attribute(): + import matplotlib.pyplot as plt + plt.figure() ef = setup_efficient_frontier() @@ -289,7 +385,13 @@ def test_weight_plot_add_attribute(): plt.close() +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_plotting_edge_case(): + import matplotlib.pyplot as plt + # raised in issue #333 mu = pd.Series([0.043389, 0.036194]) S = pd.DataFrame([[0.000562, 0.002273], [0.002273, 0.027710]]) @@ -306,6 +408,10 @@ def test_plotting_edge_case(): ) +@pytest.mark.skipif( + not _check_soft_dependencies(["matplotlib"], severity="none"), + reason="skip test if matplotlib is not installed in environment", +) def test_plot_efficient_frontier(): ef = setup_efficient_frontier() ef.min_volatility()