From 5d32335f8299f4cfb3f98d387a7dae9759fa84b0 Mon Sep 17 00:00:00 2001 From: Geochelone666 Date: Mon, 26 Oct 2026 22:31:38 -0400 Subject: [PATCH] fix: validate DiscreteAllocation prices; reject empty weights on allocate --- pypfopt/discrete_allocation.py | 14 ++++++++++++++ tests/test_discrete_allocation.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index df6f6391..fb1cfab4 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -84,6 +84,11 @@ def __init__( if ``latest_prices`` isn't a series ValueError if ``short_ratio < 0`` + ValueError + if a weighted ticker has no price in ``latest_prices``, or such + a price is not positive and finite. An empty ``weights`` dict is + accepted here (for utility methods) but rejected by the + allocation methods. """ if not isinstance(weights, dict): raise TypeError("weights should be a dictionary of {ticker: weight}") @@ -95,6 +100,11 @@ def __init__( raise ValueError("total_portfolio_value must be greater than zero") if short_ratio is not None and short_ratio < 0: raise ValueError("short_ratio must be non-negative") + missing = [t for t in weights if t not in latest_prices.index] + if missing: + raise ValueError(f"latest_prices is missing tickers: {missing}") + if any(not np.isfinite(latest_prices[t]) or latest_prices[t] <= 0 for t in weights): + raise ValueError("latest_prices must be positive and finite") # Drop any companies with negligible weights. Use a tuple because order matters. self.weights = list(weights.items()) @@ -173,6 +183,8 @@ def greedy_portfolio(self, reinvest=False, verbose=False): the number of shares of each ticker that should be purchased, along with the amount of funds leftover. """ + if len(self.weights) == 0: + raise ValueError("weights must contain at least one ticker") # Sort in descending order of weight self.weights.sort(key=lambda x: x[1], reverse=True) @@ -319,6 +331,8 @@ def lp_portfolio(self, reinvest=False, verbose=False, solver=None): ) # end todo + if len(self.weights) == 0: + raise ValueError("weights must contain at least one ticker") 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/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index 4fda59d7..68a49c5d 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -481,3 +481,35 @@ def test_allocation_errors(): latest_prices.iloc[0] = np.nan with pytest.raises(TypeError): DiscreteAllocation(w, latest_prices) + + +def test_discrete_allocation_empty_weights_rejected_on_allocate(): + da = DiscreteAllocation({}, pd.Series({"A": 10.0}), total_portfolio_value=100) + with pytest.raises(ValueError, match="at least one ticker"): + da.greedy_portfolio() + with pytest.raises(ValueError, match="at least one ticker"): + da.lp_portfolio() + + +def test_discrete_allocation_rejects_missing_prices(): + with pytest.raises(ValueError, match="missing tickers"): + DiscreteAllocation( + {"A": 0.5, "C": 0.5}, pd.Series({"A": 10.0}), total_portfolio_value=100 + ) + + +@pytest.mark.parametrize("bad_price", [0.0, -5.0, float("inf")]) +def test_discrete_allocation_rejects_nonpositive_or_infinite_prices(bad_price): + with pytest.raises(ValueError, match="positive and finite"): + DiscreteAllocation( + {"A": 0.5, "B": 0.5}, + pd.Series({"A": 10.0, "B": bad_price}), + total_portfolio_value=100, + ) + + +def test_discrete_allocation_ignores_prices_of_unweighted_assets(): + da = DiscreteAllocation( + {"A": 1.0}, pd.Series({"A": 10.0, "B": 0.0}), total_portfolio_value=100 + ) + assert da.greedy_portfolio()[0] == {"A": 10}