From 273bfb78e2cb7fd6d666d5ecf74397c3cfa000a9 Mon Sep 17 00:00:00 2001 From: Dirk <329616727+DirkEmmett@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:30:55 -0700 Subject: [PATCH] Support integer prices in greedy portfolio allocation Promote the private weight calculation buffer before normalization without changing caller prices. Cover long-only, nullable-integer, and short/reinvestment portfolios with exact allocation and budget checks. --- docs/Postprocessing.rst | 3 +++ pypfopt/discrete_allocation.py | 4 ++- tests/test_discrete_allocation.py | 42 +++++++++++++++++++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/docs/Postprocessing.rst b/docs/Postprocessing.rst index 7c78b159..80c937aa 100644 --- a/docs/Postprocessing.rst +++ b/docs/Postprocessing.rst @@ -25,6 +25,9 @@ algorithm, the other using integer programming. Greedy algorithm ================ +The greedy allocator accepts integer-valued quotes as well as floating-point prices +in ``latest_prices``, without changing the input Series or its dtype. + ``DiscreteAllocation.greedy_portfolio()`` proceeds in two 'rounds'. In the first round, we buy as many shares as we can for each asset without going over the desired weight. In the Apple example, :math:`1500/190 \approx 7.89`, so we buy 7 diff --git a/pypfopt/discrete_allocation.py b/pypfopt/discrete_allocation.py index f4033e0d..6c5ad2bf 100644 --- a/pypfopt/discrete_allocation.py +++ b/pypfopt/discrete_allocation.py @@ -240,7 +240,9 @@ def greedy_portfolio(self, reinvest=False, verbose=False): while available_funds > 0: # Calculate the equivalent continuous weights of the shares that # have already been bought - current_weights = np.array(buy_prices) * np.array(shares_bought) + current_weights = np.array(buy_prices, dtype=float) * np.array( + shares_bought + ) current_weights /= current_weights.sum() ideal_weights = np.array([i[1] for i in self.weights]) deficit = ideal_weights - current_weights diff --git a/tests/test_discrete_allocation.py b/tests/test_discrete_allocation.py index f0953186..6490c6c7 100644 --- a/tests/test_discrete_allocation.py +++ b/tests/test_discrete_allocation.py @@ -59,6 +59,48 @@ def test_greedy_portfolio_allocation(): assert leftover_verbose == leftover +@pytest.mark.parametrize("dtype", ["int64", "Int64"]) +def test_greedy_portfolio_integer_prices(dtype): + latest_prices = pd.Series({"A": 30, "B": 20}, dtype=dtype) + original_prices = latest_prices.copy() + da = DiscreteAllocation( + {"A": 0.5, "B": 0.5}, latest_prices, total_portfolio_value=100 + ) + + allocation, leftover = da.greedy_portfolio() + + assert allocation == {"A": 2, "B": 2} + assert leftover == 0 + total = sum(num * latest_prices[ticker] for ticker, num in allocation.items()) + assert total + leftover == 100 + pd.testing.assert_series_equal(latest_prices, original_prices) + + +@pytest.mark.parametrize( + "reinvest, expected_allocation, expected_leftover, gross_budget", + [ + (False, {"A": 3, "B": -1}, 15, 125), + (True, {"A": 4, "B": -1}, 10, 150), + ], +) +def test_greedy_portfolio_integer_prices_short( + reinvest, expected_allocation, expected_leftover, gross_budget +): + latest_prices = pd.Series({"A": 30, "B": 20}, dtype="int64") + original_prices = latest_prices.copy() + da = DiscreteAllocation( + {"A": 1.25, "B": -0.25}, latest_prices, total_portfolio_value=100 + ) + + allocation, leftover = da.greedy_portfolio(reinvest=reinvest) + + assert allocation == expected_allocation + assert leftover == expected_leftover + total = sum(abs(num) * latest_prices[ticker] for ticker, num in allocation.items()) + assert total + leftover == gross_budget + pd.testing.assert_series_equal(latest_prices, original_prices) + + def test_greedy_allocation_rmse_error(): ef = setup_efficient_frontier() w = ef.max_sharpe(risk_free_rate=0.02)