Skip to content

Commit 15bcaa9

Browse files
authored
[MNT] testing package without soft dependencies, isolate matplotlib (#662)
* adds a CI job, `pytest-nosoftdeps`, to test the package without soft dependencies. * isolates soft dependency `matplotlib` in tests and the plotting module * isolates the `ecos` dependency almost entirely. There is one default that remains, which is deprecated and scheduled for removal in 1.7.0. * adds `scikit-base` soft dependency (without further dependencies) to manage soft dependencies This is also useful to check whether soft dependencies are properly isolated.
1 parent 8a0bb50 commit 15bcaa9

File tree

9 files changed

+235
-13
lines changed

9 files changed

+235
-13
lines changed

.github/workflows/main.yml

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,50 @@ jobs:
5050
echo "No changed files to check."
5151
fi
5252
53-
pytest:
53+
pytest-nosoftdeps:
5454
needs: code-quality
55-
name: py${{ matrix.python-version }} on ${{ matrix.os }}
55+
name: nosoftdeps (${{ matrix.python-version }}, ${{ matrix.os }})
56+
runs-on: ${{ matrix.os }}
57+
env:
58+
MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434
59+
strategy:
60+
matrix:
61+
os: [ubuntu-latest, macos-latest, windows-latest]
62+
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
63+
fail-fast: false # to not fail all combinations if just one fails
64+
65+
steps:
66+
- uses: actions/checkout@v5
67+
68+
- name: Install uv
69+
uses: astral-sh/setup-uv@v7
70+
with:
71+
enable-cache: true
72+
73+
- name: Set up Python ${{ matrix.python-version }}
74+
uses: actions/setup-python@v6
75+
with:
76+
python-version: ${{ matrix.python-version }}
77+
78+
- name: Display Python version
79+
run: python -c "import sys; print(sys.version)"
80+
81+
- name: Install dependencies
82+
shell: bash
83+
run: uv pip install ".[dev]" --no-cache-dir
84+
env:
85+
UV_SYSTEM_PYTHON: 1
86+
87+
- name: Show dependencies
88+
run: uv pip list
89+
90+
- name: Test with pytest
91+
run: |
92+
pytest ./tests
93+
94+
pytest:
95+
needs: pytest-nosoftdeps
96+
name: (${{ matrix.python-version }}, ${{ matrix.os }})
5697
runs-on: ${{ matrix.os }}
5798
env:
5899
MPLBACKEND: Agg # https://github.com/orgs/community/discussions/26434
@@ -92,7 +133,7 @@ jobs:
92133
pytest ./tests
93134
94135
codecov:
95-
name: py${{ matrix.python-version }} on ${{ matrix.os }}
136+
name: coverage (${{ matrix.python-version }} on ${{ matrix.os }}
96137
runs-on: ${{ matrix.os }}
97138
needs: code-quality
98139
env:
@@ -141,8 +182,8 @@ jobs:
141182
fail_ci_if_error: true
142183

143184
notebooks:
144-
runs-on: ubuntu-latest
145185
needs: code-quality
186+
runs-on: ubuntu-latest
146187

147188
strategy:
148189
matrix:

pypfopt/discrete_allocation.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
"""
55

66
import collections
7+
from warnings import warn
78

89
import cvxpy as cp
910
import numpy as np
1011
import pandas as pd
12+
from skbase.utils.dependencies import _check_soft_dependencies
1113

1214
from . import exceptions
1315

@@ -252,7 +254,8 @@ def greedy_portfolio(self, reinvest=False, verbose=False):
252254
self._allocation_rmse_error(verbose)
253255
return self.allocation, available_funds
254256

255-
def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"):
257+
# todo 1.7.0: remove ECOS_BB defaulting behavior from docstring
258+
def lp_portfolio(self, reinvest=False, verbose=False, solver=None):
256259
"""
257260
Convert continuous weights into a discrete portfolio allocation
258261
using integer programming.
@@ -262,11 +265,23 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver="ECOS_BB"):
262265
:param verbose: print error analysis?
263266
:type verbose: bool
264267
:param solver: the CVXPY solver to use (must support mixed-integer programs)
265-
:type solver: str, defaults to "ECOS_BB"
268+
:type solver: str, defaults to "ECOS_BB" if ecos is installed, else None
266269
:return: the number of shares of each ticker that should be purchased, along with the amount
267270
of funds leftover.
268271
:rtype: (dict, float)
269272
"""
273+
# todo 1.7.0: remove this defaulting behavior
274+
if solver is None and _check_soft_dependencies("ecos", severity="none"):
275+
solver = "ECOS_BB"
276+
warn(
277+
"The default solver for lp_portfolio will change from ECOS_BB to"
278+
"None, the cvxpy default solver, in release 1.7.0."
279+
"To continue using ECOS_BB as the solver, "
280+
"please set solver='ECOS_BB' explicitly.",
281+
FutureWarning,
282+
)
283+
# end todo
284+
270285
if any([w < 0 for _, w in self.weights]):
271286
longs = {t: w for t, w in self.weights if w >= 0}
272287
shorts = {t: -w for t, w in self.weights if w < 0}

pypfopt/plotting.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,15 @@
1616

1717
from . import CLA, EfficientFrontier, exceptions, risk_models
1818

19-
try:
20-
import matplotlib.pyplot as plt
21-
except (ModuleNotFoundError, ImportError): # pragma: no cover
22-
raise ImportError("Please install matplotlib via pip or poetry")
19+
20+
def _import_matplotlib():
21+
"""Helper function to import matplotlib only when needed"""
22+
try:
23+
import matplotlib.pyplot as plt
24+
25+
return plt
26+
except (ModuleNotFoundError, ImportError): # pragma: no cover
27+
raise ImportError("Please install matplotlib via pip or poetry")
2328

2429

2530
def _get_plotly():
@@ -46,6 +51,8 @@ def _plot_io(**kwargs):
4651
:param showfig: whether to plt.show() the figure, defaults to False
4752
:type showfig: bool, optional
4853
"""
54+
plt = _import_matplotlib()
55+
4956
filename = kwargs.get("filename", None)
5057
showfig = kwargs.get("showfig", False)
5158
dpi = kwargs.get("dpi", 300)
@@ -73,6 +80,8 @@ def plot_covariance(cov_matrix, plot_correlation=False, show_tickers=True, **kwa
7380
:return: matplotlib axis
7481
:rtype: matplotlib.axes object
7582
"""
83+
plt = _import_matplotlib()
84+
7685
if plot_correlation:
7786
matrix = risk_models.cov_to_corr(cov_matrix)
7887
else:
@@ -110,6 +119,8 @@ def plot_dendrogram(hrp, ax=None, show_tickers=True, **kwargs):
110119
:return: matplotlib axis
111120
:rtype: matplotlib.axes object
112121
"""
122+
plt = _import_matplotlib()
123+
113124
ax = ax or plt.gca()
114125

115126
if hrp.clusters is None:
@@ -337,6 +348,8 @@ def plot_efficient_frontier(
337348
:return: matplotlib axis
338349
:rtype: matplotlib.axes object
339350
"""
351+
plt = _import_matplotlib()
352+
340353
if interactive:
341354
go, _ = _get_plotly()
342355
ax = go.Figure()
@@ -393,6 +406,8 @@ def plot_weights(weights, ax=None, **kwargs):
393406
:return: matplotlib axis
394407
:rtype: matplotlib.axes
395408
"""
409+
plt = _import_matplotlib()
410+
396411
ax = ax or plt.gca()
397412

398413
desc = sorted(weights.items(), key=lambda x: x[1], reverse=True)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies = [
3737
"pandas>=0.19",
3838
"scikit-learn>=0.24.1",
3939
"scipy>=1.3.0",
40+
"scikit-base<0.14.0",
4041
]
4142

4243
[project.optional-dependencies]

tests/test_efficient_cdar.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pytest
3+
from skbase.utils.dependencies import _check_soft_dependencies
34

45
from pypfopt import EfficientCDaR, expected_returns, objective_functions
56
from pypfopt.exceptions import OptimizationError
@@ -151,6 +152,10 @@ def test_min_cdar_extra_constraints():
151152
assert w["GOOG"] >= 0.025 and w["MA"] <= 0.035
152153

153154

155+
@pytest.mark.skipif(
156+
not _check_soft_dependencies(["ecos"], severity="none"),
157+
reason="skip test if ecos is not installed in environment",
158+
)
154159
def test_min_cdar_different_solver():
155160
cd = setup_efficient_cdar(solver="ECOS")
156161
w = cd.min_cdar()
@@ -182,6 +187,10 @@ def test_min_cdar_tx_costs():
182187
assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum()
183188

184189

190+
@pytest.mark.skipif(
191+
not _check_soft_dependencies(["ecos"], severity="none"),
192+
reason="skip test if ecos is not installed in environment",
193+
)
185194
def test_min_cdar_L2_reg():
186195
cd = setup_efficient_cdar(solver="ECOS")
187196
cd.add_objective(objective_functions.L2_reg, gamma=0.1)

tests/test_efficient_cvar.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import numpy as np
22
import pytest
3+
from skbase.utils.dependencies import _check_soft_dependencies
34

45
from pypfopt import EfficientCVaR, expected_returns, objective_functions
56
from pypfopt.exceptions import OptimizationError
@@ -156,6 +157,10 @@ def test_min_cvar_extra_constraints():
156157
assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035
157158

158159

160+
@pytest.mark.skipif(
161+
not _check_soft_dependencies(["ecos"], severity="none"),
162+
reason="skip test if ecos is not installed in environment",
163+
)
159164
def test_min_cvar_different_solver():
160165
cv = setup_efficient_cvar(solver="ECOS")
161166
w = cv.min_cvar()
@@ -186,6 +191,10 @@ def test_min_cvar_tx_costs():
186191
assert np.abs(prev_w - w2).sum() < np.abs(prev_w - w1).sum()
187192

188193

194+
@pytest.mark.skipif(
195+
not _check_soft_dependencies(["ecos"], severity="none"),
196+
reason="skip test if ecos is not installed in environment",
197+
)
189198
def test_min_cvar_L2_reg():
190199
cv = setup_efficient_cvar(solver="ECOS")
191200
cv.add_objective(objective_functions.L2_reg, gamma=0.1)

tests/test_efficient_frontier.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import pandas as pd
66
import pytest
77
import scipy.optimize as sco
8+
from skbase.utils.dependencies import _check_soft_dependencies
89

910
from pypfopt import (
1011
EfficientFrontier,
@@ -106,6 +107,10 @@ def test_min_volatility():
106107
)
107108

108109

110+
@pytest.mark.skipif(
111+
not _check_soft_dependencies(["ecos"], severity="none"),
112+
reason="skip test if ecos is not installed in environment",
113+
)
109114
def test_min_volatility_different_solver():
110115
ef = setup_efficient_frontier(solver="ECOS")
111116
w = ef.min_volatility()
@@ -1042,6 +1047,10 @@ def test_efficient_risk_market_neutral_L2_reg():
10421047
)
10431048

10441049

1050+
@pytest.mark.skipif(
1051+
not _check_soft_dependencies(["ecos"], severity="none"),
1052+
reason="skip test if ecos is not installed in environment",
1053+
)
10451054
def test_efficient_risk_market_neutral_warning():
10461055
ef = setup_efficient_frontier(solver=cp.ECOS)
10471056
with pytest.warns(RuntimeWarning) as w:
@@ -1088,6 +1097,10 @@ def test_efficient_frontier_error():
10881097
EfficientFrontier(ef.expected_returns, 0.01)
10891098

10901099

1100+
@pytest.mark.skipif(
1101+
not _check_soft_dependencies(["ecos"], severity="none"),
1102+
reason="skip test if ecos is not installed in environment",
1103+
)
10911104
def test_efficient_return_many_values():
10921105
ef = setup_efficient_frontier(solver=cp.ECOS)
10931106
for target_return in np.arange(0.25, 0.28, 0.01):
@@ -1217,6 +1230,10 @@ def test_efficient_return_market_neutral_unbounded():
12171230
assert long_only_sharpe < sharpe
12181231

12191232

1233+
@pytest.mark.skipif(
1234+
not _check_soft_dependencies(["ecos"], severity="none"),
1235+
reason="skip test if ecos is not installed in environment",
1236+
)
12201237
def test_efficient_return_market_neutral_warning():
12211238
# This fails
12221239
ef = setup_efficient_frontier(solver=cp.ECOS)

tests/test_efficient_semivariance.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
from cvxpy.error import SolverError
12
import numpy as np
23
import pytest
3-
from cvxpy.error import SolverError
4+
from skbase.utils.dependencies import _check_soft_dependencies
45

56
from pypfopt import (
67
EfficientFrontier,
@@ -176,6 +177,10 @@ def test_min_semivariance_extra_constraints():
176177
assert w["GOOG"] >= 0.025 and w["AAPL"] <= 0.035
177178

178179

180+
@pytest.mark.skipif(
181+
not _check_soft_dependencies(["ecos"], severity="none"),
182+
reason="skip test if ecos is not installed in environment",
183+
)
179184
def test_min_semivariance_different_solver():
180185
es = setup_efficient_semivariance(solver="ECOS")
181186
w = es.min_semivariance()
@@ -347,6 +352,10 @@ def test_max_quadratic_utility_with_shorts():
347352
)
348353

349354

355+
@pytest.mark.skipif(
356+
not _check_soft_dependencies(["ecos"], severity="none"),
357+
reason="skip test if ecos is not installed in environment",
358+
)
350359
def test_max_quadratic_utility_market_neutral():
351360
es = setup_efficient_semivariance(solver="ECOS", weight_bounds=(-1, 1))
352361
es.max_quadratic_utility(market_neutral=True)

0 commit comments

Comments
 (0)