Skip to content
Merged
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
49 changes: 45 additions & 4 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -141,8 +182,8 @@ jobs:
fail_ci_if_error: true

notebooks:
runs-on: ubuntu-latest
needs: code-quality
runs-on: ubuntu-latest

strategy:
matrix:
Expand Down
19 changes: 17 additions & 2 deletions pypfopt/discrete_allocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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}
Expand Down
23 changes: 19 additions & 4 deletions pypfopt/plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions tests/test_efficient_cdar.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions tests/test_efficient_cvar.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions tests/test_efficient_frontier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion tests/test_efficient_semivariance.py
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
Loading