Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/BlackLitterman.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
11 changes: 7 additions & 4 deletions pypfopt/black_litterman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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
----------
Expand All @@ -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
Expand Down
62 changes: 62 additions & 0 deletions tests/test_black_litterman_regressions.py
Original file line number Diff line number Diff line change
@@ -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())