diff --git a/docs/BlackLitterman.rst b/docs/BlackLitterman.rst index 8a6b703d..e96bde9d 100644 --- a/docs/BlackLitterman.rst +++ b/docs/BlackLitterman.rst @@ -243,3 +243,8 @@ References .. [2] Black, F; Litterman, R. Combining investor views with market equilibrium. The Journal of Fixed Income, 1991. .. [3] Walters, Jay, The Factor Tau in the Black-Litterman Model (October 9, 2013). Available at SSRN: https://ssrn.com/abstract=1701467 or http://dx.doi.org/10.2139/ssrn.1701467 .. [4] Walters J. The Black-Litterman Model in Detail (2014). SSRN Electron J.;(February 2007):1–65. + +When priors and absolute views are total returns, pass ``risk_free_rate`` to +``BlackLittermanModel``. The market-prior constructor adds this rate; +``bl_weights()`` subtracts it before recovering implied weights. Leave it at +zero when the supplied estimates are already excess returns. diff --git a/pypfopt/black_litterman.py b/pypfopt/black_litterman.py index ec4bff42..1b208076 100644 --- a/pypfopt/black_litterman.py +++ b/pypfopt/black_litterman.py @@ -183,7 +183,9 @@ def __init__( market_caps : np.ndarray or pd.Series, optional (kwarg) market caps for the assets, required if pi="market" risk_free_rate : float, optional - (kwarg) risk_free_rate is needed in some methods. Defaults to 0.0. + (kwarg) risk-free rate added to a market prior and subtracted when + computing implied weights. Defaults to 0.0. If an explicit prior + contains total returns, pass its risk-free rate here as well. """ if sys.version_info[1] == 5: # pragma: no cover warnings.warn( @@ -206,6 +208,7 @@ def __init__( self.Q, self.P = self._parse_views(absolute_views) else: self._set_Q_P(Q, P) + self.risk_free_rate = kwargs.get("risk_free_rate", 0.0) self._set_risk_aversion(risk_aversion) self._set_pi(pi, **kwargs) self._set_tau(tau) @@ -480,7 +483,7 @@ def bl_weights(self, risk_aversion=None): .. math:: - w = (\delta \Sigma)^{-1} E(R) + w = (\delta \Sigma)^{-1} (E(R) - R_f) Parameters ---------- @@ -497,12 +500,12 @@ def bl_weights(self, risk_aversion=None): self.posterior_rets = self.bl_returns() A = risk_aversion * self.cov_matrix - b = self.posterior_rets + b = self.posterior_rets - self.risk_free_rate try: weight_solution = np.linalg.solve(A, b) except np.linalg.LinAlgError as e: if "Singular matrix" in str(e): - weight_solution = np.linalg.lstsq(self._A, b, rcond=None)[0] + weight_solution = np.linalg.lstsq(A, b, rcond=None)[0] else: raise e raw_weights = weight_solution diff --git a/tests/test_black_litterman_regressions.py b/tests/test_black_litterman_regressions.py new file mode 100644 index 00000000..8f11f20c --- /dev/null +++ b/tests/test_black_litterman_regressions.py @@ -0,0 +1,62 @@ +import numpy as np +import pandas as pd +import pytest + +from pypfopt.black_litterman import BlackLittermanModel, market_implied_prior_returns + + +@pytest.mark.parametrize("risk_free_rate", [0.0, 0.04]) +@pytest.mark.parametrize("market_prior", [True, False]) +def test_neutral_views_recover_market_weights(risk_free_rate, market_prior): + tickers = ["A", "B", "C"] + cov = pd.DataFrame(np.diag([0.04, 0.09, 0.16]), index=tickers, columns=tickers) + caps = pd.Series([500.0, 300.0, 200.0], index=tickers) + prior = market_implied_prior_returns(caps, 2.0, cov, risk_free_rate) + model = BlackLittermanModel( + cov, + pi="market" if market_prior else prior, + market_caps=caps, + absolute_views=prior, + risk_aversion=2.0, + risk_free_rate=risk_free_rate, + ) + pd.testing.assert_series_equal(model.bl_returns(), prior) + np.testing.assert_allclose(list(model.bl_weights().values()), [0.5, 0.3, 0.2]) + + +@pytest.mark.parametrize("views", [{"B": 0.06}, {"A": 0.04, "B": 0.06, "C": 0.04}]) +def test_singular_covariance_uses_asset_space_system(views): + # A and C are identical assets. Their total weight is 3/5; B has 2/5. + cov = pd.DataFrame( + [[0.04, 0.0, 0.04], [0.0, 0.09, 0.0], [0.04, 0.0, 0.04]], + index=["A", "B", "C"], + columns=["A", "B", "C"], + ) + model = BlackLittermanModel( + cov, pi=np.array([0.04, 0.06, 0.04]), absolute_views=views, risk_aversion=2 + ) + weights = model.bl_weights() + np.testing.assert_allclose(list(weights.values()), [0.3, 0.4, 0.3], atol=1e-12) + np.testing.assert_allclose(list(model.optimize().values()), list(weights.values())) + + +def test_duplicate_exact_views_match_single_view(): + # A singular view-space system is distinct from a singular asset covariance. + cov = np.diag([0.04, 0.09, 0.16]) + prior = np.array([0.05, 0.06, 0.07]) + single = BlackLittermanModel( + cov, + pi=prior, + P=np.array([[1.0, 0.0, 0.0]]), + Q=np.array([0.08]), + omega=np.zeros((1, 1)), + ) + duplicate = BlackLittermanModel( + cov, + pi=prior, + P=np.array([[1.0, 0.0, 0.0], [1.0, 0.0, 0.0]]), + Q=np.array([0.08, 0.08]), + omega=np.zeros((2, 2)), + ) + np.testing.assert_allclose(duplicate.bl_returns(), single.bl_returns()) + np.testing.assert_allclose(duplicate.bl_cov(), single.bl_cov())