From 0883aa6af0f1f621c91bd41a19730104bce4e5e4 Mon Sep 17 00:00:00 2001 From: PiPi_Mac Date: Thu, 22 Oct 2026 03:04:07 -0400 Subject: [PATCH 1/3] fix: align discrete allocation prices with weight ticker order --- pypfopt/discrete_allocation.py | 3 ++- tests/test_discrete_allocation.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index f4033e0d..b5f5e35a 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -356,7 +356,8 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): self.allocation = self._remove_zero_positions(self.allocation) return self.allocation, long_leftover + short_leftover - p = self.latest_prices.values + # Match the weight order by ticker; Series order and extra prices may differ. + p = self.latest_prices.loc[[ticker for ticker, _ in self.weights]].values n = len(p) w = np.fromiter([i[1] for i in self.weights], dtype=float) diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index f0953186..e5da0368 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -7,6 +7,21 @@ from tests.utilities_for_tests import get_data, setup_efficient_frontier +@pytest.mark.parametrize( + "prices", + [ + pd.Series({"B": 100.0, "A": 10.0}), + pd.Series({"UNUSED": 5.0, "B": 100.0, "A": 10.0}), + ], +) +def test_lp_portfolio_aligns_prices_with_weight_tickers(prices): + da = DiscreteAllocation({"A": 0.5, "B": 0.5}, prices, total_portfolio_value=200) + allocation, leftover = da.lp_portfolio(solver="SCIPY") + assert allocation == {"A": 10, "B": 1} + assert leftover == pytest.approx(0) + assert sum(prices[ticker] * shares for ticker, shares in allocation.items()) == 200 + + def test_get_latest_prices(): df = get_data() latest_prices = get_latest_prices(df) From 5a8083683962289245f435abe964450d7a97ab0c Mon Sep 17 00:00:00 2001 From: PiPi_Mac Date: Thu, 22 Oct 2026 03:05:36 -0400 Subject: [PATCH 2/3] ci: exercise discrete allocation with both integer solvers --- .github/workflows/discrete-allocation.yml | 24 +++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/discrete-allocation.yml diff --git a/.github/workflows/discrete-allocation.yml b/.github/workflows/discrete-allocation.yml new file mode 100644 index 00000000..d66c8a99 --- /dev/null +++ b/.github/workflows/discrete-allocation.yml @@ -0,0 +1,24 @@ +name: Discrete allocation regressions +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + discrete-allocation: + runs-on: ubuntu-latest + timeout-minutes: 10 + strategy: + matrix: + python-version: ['3.11', '3.12'] + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: astral-sh/setup-uv@v7 + with: + python-version: ${{ matrix.python-version }} + - run: uv venv + - run: uv pip install -e . pytest ecos + - run: uv run --no-sync pytest tests/test_discrete_allocation.py -q From 6e614f297acd84e98b7006f4f4c6b9ca07a28949 Mon Sep 17 00:00:00 2001 From: PiPi_Mac Date: Thu, 22 Oct 2026 03:07:57 -0400 Subject: [PATCH 3/3] fix: validate clustering methods through public scipy linkage API --- pypfopt/hierarchical_portfolio.py | 4 +--- tests/test_hrp.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/pypfopt/hierarchical_portfolio.py b/pypfopt/hierarchical_portfolio.py index 6dc76d07..ae1eb616 100644 --- a/pypfopt/hierarchical_portfolio.py +++ b/pypfopt/hierarchical_portfolio.py @@ -176,9 +176,6 @@ def optimize(self, linkage_method="single"): OrderedDict weights for the HRP portfolio """ - if linkage_method not in sch._LINKAGE_METHODS: - raise ValueError("linkage_method must be one recognised by scipy") - if self.returns is None: cov = self.cov_matrix corr = cov_to_corr(self.cov_matrix).round(6) @@ -192,6 +189,7 @@ def optimize(self, linkage_method="single"): matrix = np.sqrt(np.clip((1.0 - corr) / 2.0, a_min=0.0, a_max=1.0)) dist = ssd.squareform(matrix, checks=False) + # Let the public API validate methods; SciPy's private registry can change. self.clusters = sch.linkage(dist, linkage_method) sort_ix = HRPOpt._get_quasi_diag(self.clusters) ordered_tickers = corr.index[sort_ix].tolist() diff --git a/tests/test_hrp.py b/tests/test_hrp.py index 369a1727..7c933bf1 100644 --- a/tests/test_hrp.py +++ b/tests/test_hrp.py @@ -6,6 +6,17 @@ from tests.utilities_for_tests import get_data, resource +@pytest.mark.parametrize( + "method", ["single", "complete", "average", "weighted", "centroid", "median", "ward"] +) +def test_documented_linkage_methods(method): + cov = pd.DataFrame(np.eye(3), index=list("ABC"), columns=list("ABC")) + weights = HRPOpt(cov_matrix=cov).optimize(linkage_method=method) + assert set(weights) == set("ABC") + assert sum(weights.values()) == pytest.approx(1) + assert all(weight > 0 for weight in weights.values()) + + def test_hrp_errors(): with pytest.raises(ValueError): hrp = HRPOpt()