Skip to content

markowitz.views.black_litterman

markowitz.views.black_litterman

Black-Litterman posterior using the Theil mixed-estimation form.

The implementation follows He & Litterman (1999) and only ever factorises the K x K matrix P tau Sigma P^T + Omega (not the N x N posterior precision), which is numerically advantageous when K << N and avoids explicit inversion of the prior covariance.

Notation
  • Sigma -- prior asset-return covariance (N x N).
  • w_mkt -- market-cap weights (N).
  • pi -- equilibrium-implied excess returns, delta * Sigma * w_mkt.
  • P -- view pick matrix (K x N).
  • Q -- view target vector (K).
  • Omega -- view uncertainty (K x K, typically diagonal).
  • tau -- prior scaling, 0.05 by default (He-Litterman convention).

BlackLitterman(cov: pd.DataFrame, market_weights: pd.Series, views: Views | None = None, *, tau: float = 0.05, delta: float = 2.5, omega: np.ndarray | None = None, omega_method: str = 'he_litterman', posterior_covariance_mode: str = 'full', risk_free_rate: float | None = None)

Construct a Black-Litterman posterior from a covariance prior and views.

Parameters:

Name Type Description Default
cov DataFrame

Prior covariance matrix as a labelled DataFrame.

required
market_weights Series

Market-cap weights series aligned to the covariance index.

required
views Views | None

:class:Views container with absolute and/or relative view specs. Pass None (or an empty container) for the no-views special case in which the posterior collapses to the prior.

None
tau float

Prior-scaling parameter. Must be strictly positive. Defaults to 0.05.

0.05
delta float

Representative-investor risk-aversion coefficient. Defaults to 2.5.

2.5
omega ndarray | None

Optional pre-computed view-uncertainty matrix; if supplied, omega_method is ignored.

None
omega_method str

Strategy used to derive Omega when not supplied: one of "he_litterman", "idzorek_exact", "idzorek_approx".

'he_litterman'
posterior_covariance_mode str

"full" returns Sigma_BL = Sigma + M (default), "prior_only" returns the prior covariance unchanged.

'full'
risk_free_rate float | None

Optional metadata used by :meth:summary to label the regime; not used in any numerical step (all quantities are excess returns).

None
Source code in src/markowitz/views/black_litterman.py
def __init__(
    self,
    cov: pd.DataFrame,
    market_weights: pd.Series,
    views: Views | None = None,
    *,
    tau: float = 0.05,
    delta: float = 2.5,
    omega: np.ndarray | None = None,
    omega_method: str = "he_litterman",
    posterior_covariance_mode: str = "full",
    risk_free_rate: float | None = None,
) -> None:
    self._validate_inputs(cov, market_weights, tau, delta)
    if omega_method not in _OMEGA_METHODS:
        raise OmegaSpecificationError(
            f"omega_method must be one of {sorted(_OMEGA_METHODS)}, got {omega_method!r}."
        )
    if posterior_covariance_mode not in _COV_MODES:
        raise BlackLittermanError(
            f"posterior_covariance_mode must be one of {sorted(_COV_MODES)}, "
            f"got {posterior_covariance_mode!r}."
        )

    self._cov: pd.DataFrame = cov
    self._assets: list[str] = list(cov.index)
    self._market_weights: pd.Series = market_weights.reindex(cov.index)
    if self._market_weights.isna().any():
        missing = self._market_weights.index[self._market_weights.isna()].tolist()
        raise BlackLittermanError(f"Market weights missing entries for: {missing}.")
    self._tau: float = float(tau)
    self._delta: float = float(delta)
    self._omega_method: str = omega_method
    self._posterior_cov_mode: str = posterior_covariance_mode
    self._risk_free_rate: float | None = risk_free_rate

    self._views: Views | None = views if views is not None and len(views) > 0 else None
    self._omega_user: np.ndarray | None = omega

    # Cached numerical pieces (built lazily).
    self._pi: pd.Series | None = None
    self._mu_bl: pd.Series | None = None
    self._sigma_bl: pd.DataFrame | None = None

implied_returns() -> pd.Series

Return the equilibrium-implied prior pi = delta * Sigma * w_mkt.

Source code in src/markowitz/views/black_litterman.py
def implied_returns(self) -> pd.Series:
    """Return the equilibrium-implied prior ``pi = delta * Sigma * w_mkt``."""
    if self._pi is None:
        self._pi = _implied_returns(self._cov, self._market_weights, delta=self._delta)
    return self._pi.copy()

posterior_covariance() -> pd.DataFrame

Return the posterior covariance matrix.

Source code in src/markowitz/views/black_litterman.py
def posterior_covariance(self) -> pd.DataFrame:
    """Return the posterior covariance matrix."""
    if self._sigma_bl is None:
        self._compute_posterior()
    assert self._sigma_bl is not None
    out: pd.DataFrame = self._sigma_bl.copy()
    return out

posterior_returns() -> pd.Series

Return the Black-Litterman posterior excess-return vector.

Source code in src/markowitz/views/black_litterman.py
def posterior_returns(self) -> pd.Series:
    """Return the Black-Litterman posterior excess-return vector."""
    if self._mu_bl is None:
        self._compute_posterior()
    assert self._mu_bl is not None
    return self._mu_bl.copy()

posterior_weights(*, constrained: bool = False) -> pd.Series

Return the unconstrained tangency weights implied by the posterior.

w_BL = (delta * Sigma_BL)^{-1} * mu_BL.

Parameters:

Name Type Description Default
constrained bool

If True, project to the long-only simplex by clipping at zero and renormalising to sum to 1. This is a convenience for quick inspection -- production code should use the full :mod:markowitz.optimizer pipeline.

False
Source code in src/markowitz/views/black_litterman.py
def posterior_weights(self, *, constrained: bool = False) -> pd.Series:
    """Return the unconstrained tangency weights implied by the posterior.

    ``w_BL = (delta * Sigma_BL)^{-1} * mu_BL``.

    Parameters
    ----------
    constrained
        If ``True``, project to the long-only simplex by clipping at zero
        and renormalising to sum to 1.  This is a convenience for quick
        inspection -- production code should use the full
        :mod:`markowitz.optimizer` pipeline.
    """
    mu = self.posterior_returns().to_numpy()
    sigma_bl = self.posterior_covariance().to_numpy()
    c_factor = cho_factor(self._delta * sigma_bl)
    w = cho_solve(c_factor, mu)
    if constrained:
        w = np.clip(w, 0.0, None)
        total = w.sum()
        if total > 0.0:
            w = w / total
    return pd.Series(w, index=self._assets, name="w_bl")

summary() -> pd.DataFrame

Return a per-asset table with prior, posterior and weight tilts.

Source code in src/markowitz/views/black_litterman.py
def summary(self) -> pd.DataFrame:
    """Return a per-asset table with prior, posterior and weight tilts."""
    pi = self.implied_returns()
    mu_bl = self.posterior_returns()
    w_mkt = self._market_weights.copy()
    w_bl = self.posterior_weights()
    df = pd.DataFrame(
        {
            "pi": pi,
            "mu_bl": mu_bl,
            "delta_mu": mu_bl - pi,
            "w_mkt": w_mkt,
            "w_bl": w_bl,
            "delta_w": w_bl - w_mkt,
        },
        index=self._assets,
    )
    return df