From af9119af6b46d0e59e660b720186982648f465bd Mon Sep 17 00:00:00 2001 From: Krishna Anubhav Date: Mon, 21 Sep 2026 21:59:44 +0530 Subject: [PATCH] Add return_raw option to BlackLittermanModel.bl_weights Closes #763. bl_weights() normalizes the solved weights to sum to 1 before returning them, which discards the total leverage information in the raw (pre-normalization) solution - there's no way to recover how much borrowing/lending the posterior implied once it's normalized away. Adds a return_raw=False parameter; when True, returns the raw weights via the same _make_output_weights() path instead. self.weights is always set to the normalized version regardless of the flag, so portfolio_performance() and every other existing caller is unaffected. Verified: full test suite passes (280 passed, 32 skipped; the one pre-existing HRPOpt/scipy failure in test_hrp.py is unrelated and already tracked in #754/#755). ruff and black clean on both changed files. --- pypfopt/black_litterman.py | 12 +++++++++++- tests/test_black_litterman.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pypfopt/black_litterman.py b/pypfopt/black_litterman.py index ec4bff42..98500be3 100644 --- a/pypfopt/black_litterman.py +++ b/pypfopt/black_litterman.py @@ -471,7 +471,7 @@ def bl_cov(self): posterior_cov = self.cov_matrix + M return pd.DataFrame(posterior_cov, index=self.tickers, columns=self.tickers) - def bl_weights(self, risk_aversion=None): + def bl_weights(self, risk_aversion=None, return_raw=False): r""" Compute the weights implied by the posterior returns, given the market price of risk. Technically this can be applied to any @@ -486,6 +486,14 @@ def bl_weights(self, risk_aversion=None): ---------- risk_aversion : positive float, optional risk aversion parameter, defaults to 1 + return_raw : bool, optional + if True, return the unnormalized weights (which do not sum to 1) + instead of normalizing them to sum to 1. The raw weights carry + information about the total amount of borrowing/lending implied + by the posterior, which normalizing discards; ``self.weights`` + is always set to the normalized weights regardless of this flag, + so subsequent calls (e.g. ``portfolio_performance``) are + unaffected. Defaults to False. Returns ------- @@ -507,6 +515,8 @@ def bl_weights(self, risk_aversion=None): raise e raw_weights = weight_solution self.weights = raw_weights / raw_weights.sum() + if return_raw: + return self._make_output_weights(raw_weights) return self._make_output_weights() def optimize(self, risk_aversion=None): diff --git a/tests/test_black_litterman.py b/tests/test_black_litterman.py index 25bdff39..51eed9bd 100644 --- a/tests/test_black_litterman.py +++ b/tests/test_black_litterman.py @@ -309,6 +309,36 @@ def test_bl_weights(): assert w2 == w +def test_bl_weights_return_raw(): + df = get_data() + S = risk_models.sample_cov(df) + + viewdict = {"AAPL": 0.20, "BBY": -0.30, "BAC": 0, "SBUX": -0.2, "T": 0.131321} + bl = BlackLittermanModel(S, absolute_views=viewdict) + + prices = pd.read_csv( + resource("spy_prices.csv"), parse_dates=True, index_col=0 + ).squeeze("columns") + delta = market_implied_risk_aversion(prices) + + raw = bl.bl_weights(delta, return_raw=True) + # self.weights is always the normalized version, regardless of return_raw, + # so downstream calls like portfolio_performance() are unaffected. + normalized = bl._make_output_weights() + + # The raw weights are proportional to the normalized ones by exactly the + # sum that normalization divided out - not equal to them. + raw_sum = sum(raw.values()) + assert ( + abs(raw_sum - 1) > 1e-3 + ), "fixture should have real leverage to make this a meaningful check" + for ticker in raw: + assert raw[ticker] == pytest.approx(normalized[ticker] * raw_sum, rel=1e-8) + + # Calling with return_raw=False (the default) is unchanged from before. + assert bl.bl_weights(delta) == normalized + + def test_market_implied_prior(): df = get_data() S = risk_models.sample_cov(df)