Black-Litterman allocation#
In this notebook, we solve a Black-Litterman portfolio optimization problem: combining market equilibrium returns with subjective investor views.
This approach addresses a key limitation of mean-variance optimization: sensitivity to expected return estimates. By starting from market-implied returns (the “prior”) and blending in investor views (the “likelihood”), we get more stable and intuitive allocations.
Features used:
Varwith vector-valued defaultEquality constraints (
constraint_eq_zero): budget constraintInequality constraints (
constraint_geq_zero): no short-sellingAugmented Lagrangian solver for constrained optimization
Comparison of prior vs posterior allocations
import jax
import jax.numpy as jnp
import jaxls
Historical stock data#
We use the same three stocks from the Mean-variance allocation example: IBM, Walmart (WMT), and Southern Electric (SEHI).
stock_names = ["IBM", "WMT", "SEHI"]
n_assets = len(stock_names)
# Monthly prices (13 months: Nov 2000 - Nov 2001)
prices = jnp.array(
[
[93.043, 51.826, 1.063],
[84.585, 52.823, 0.938],
[111.453, 56.477, 1.0],
[99.525, 49.805, 0.938],
[95.819, 50.287, 1.438],
[114.708, 51.521, 1.7],
[111.515, 51.531, 2.54],
[113.211, 48.664, 2.39],
[104.942, 55.744, 3.12],
[99.827, 47.916, 2.98],
[91.607, 49.438, 1.9],
[107.937, 51.336, 1.75],
[115.59, 55.081, 1.8],
]
)
# Compute returns and covariance.
returns = jnp.diff(prices, axis=0) / prices[:-1]
historical_returns = jnp.mean(returns, axis=0)
returns_centered = returns - historical_returns
covariance = (returns_centered.T @ returns_centered) / (returns.shape[0] - 1)
print("Historical monthly returns:")
for name, r in zip(stock_names, historical_returns):
print(f" {name}: {float(r) * 100:+.2f}%")
Historical monthly returns:
IBM: +2.60%
WMT: +0.81%
SEHI: +7.37%
Black-Litterman framework#
The model has three components:
Prior (market equilibrium): Expected returns implied by current market prices, assuming markets are efficient. Computed via “reverse optimization” from market-cap weights.
Views (investor beliefs): Specific predictions about asset returns, which can be:
Absolute: “SEHI will return 10%”
Relative: “SEHI will outperform IBM by 5%”
Posterior: Bayesian combination of prior and views, weighted by confidence.
The posterior expected returns are:
where:
\(\pi\) = prior expected returns (from market equilibrium)
\(P\) = picking matrix (maps views to assets)
\(Q\) = view returns vector
\(\Omega\) = view uncertainty matrix
\(\tau\) = scaling parameter (typically 0.025-0.05)
Step 1: computing the prior (equilibrium returns)#
The prior comes from “reverse optimization”: given market-cap weights, what expected returns would make those weights optimal?
For simplicity, we assume equal market-cap weights. The equilibrium returns are:
where \(\delta\) is the risk aversion coefficient (typically 2-3).
# Market-cap weights (assumed equal for simplicity)
market_weights = jnp.ones(n_assets) / n_assets
# Risk aversion coefficient.
delta = 2.5
# Prior expected returns (equilibrium implied returns)
prior_returns = delta * covariance @ market_weights
print("Prior (equilibrium) monthly returns:")
for name, r in zip(stock_names, prior_returns):
print(f" {name}: {float(r) * 100:+.2f}%")
Prior (equilibrium) monthly returns:
IBM: +1.96%
WMT: +1.24%
SEHI: +6.24%
Step 2: defining investor views#
We specify two views:
Relative view: “SEHI will outperform IBM by 5% (monthly)”
This is encoded as: return(SEHI) - return(IBM) = 0.05
Absolute view: “WMT will return 2% (monthly)”
This is encoded as: return(WMT) = 0.02
The picking matrix \(P\) maps views to assets, and \(Q\) contains the view values.
# View 1: SEHI outperforms IBM by 5%.
# P row: [-1 (IBM), 0 (WMT), +1 (SEHI)]
# View 2: WMT returns 2%.
# P row: [0 (IBM), 1 (WMT), 0 (SEHI)]
P = jnp.array(
[
[-1.0, 0.0, 1.0], # SEHI - IBM
[0.0, 1.0, 0.0], # WMT
]
)
Q = jnp.array([0.05, 0.02]) # View returns
# View uncertainty matrix (Omega must be symmetric positive definite)
# Using Idzorek's method: start with tau * P @ Sigma @ P.T, then scale diagonal by confidence.
tau = 0.05
confidence = jnp.array([0.6, 0.8]) # View 1: 60% confident, View 2: 80% confident
# Scale diagonal elements only to preserve symmetry.
# Lower confidence = higher uncertainty = larger diagonal.
omega_diag = tau * jnp.diag(P @ covariance @ P.T) / confidence
omega = jnp.diag(omega_diag)
print("Views:")
print(" 1. SEHI outperforms IBM by 5% (60% confidence)")
print(" 2. WMT returns 2% (80% confidence)")
Views:
1. SEHI outperforms IBM by 5% (60% confidence)
2. WMT returns 2% (80% confidence)
Step 3: computing posterior returns#
We combine the prior and views using the Black-Litterman formula.
# Black-Litterman posterior formula.
tau_sigma_inv = jnp.linalg.inv(tau * covariance)
omega_inv = jnp.linalg.inv(omega)
# Posterior precision (inverse covariance)
posterior_precision = tau_sigma_inv + P.T @ omega_inv @ P
# Posterior mean.
posterior_returns = jnp.linalg.solve(
posterior_precision, tau_sigma_inv @ prior_returns + P.T @ omega_inv @ Q
)
print("Posterior monthly returns (after incorporating views):")
for name, r in zip(stock_names, posterior_returns):
print(f" {name}: {float(r) * 100:+.2f}%")
print("\nChange from prior:")
for name, (prior, post) in zip(stock_names, zip(prior_returns, posterior_returns)):
change = (float(post) - float(prior)) * 100
print(f" {name}: {change:+.2f}%")
Posterior monthly returns (after incorporating views):
IBM: +2.10%
WMT: +1.58%
SEHI: +6.69%
Change from prior:
IBM: +0.14%
WMT: +0.34%
SEHI: +0.45%
Solving#
Now we optimize portfolios using both prior and posterior expected returns. We maximize expected return while minimizing variance (mean-variance optimization).
class WeightsVar(jaxls.Var[jax.Array], default_factory=lambda: jnp.ones(3) / 3):
"""Portfolio weights (3D vector)."""
weights_var = WeightsVar(id=0)
@jaxls.Cost.factory
def variance_cost(
vals: jaxls.VarValues, var: WeightsVar, cov_chol: jax.Array
) -> jax.Array:
"""Minimize portfolio variance: ||L.T @ w||^2 = w.T @ cov @ w."""
return cov_chol.T @ vals[var]
@jaxls.Cost.factory
def return_cost(
vals: jaxls.VarValues, var: WeightsVar, exp_ret: jax.Array, weight: float
) -> jax.Array:
"""Maximize expected return (negative because we minimize)."""
return -weight * jnp.dot(vals[var], exp_ret)
@jaxls.Cost.factory(kind="constraint_eq_zero")
def budget_constraint(vals: jaxls.VarValues, var: WeightsVar) -> jax.Array:
"""Weights must sum to 1 (fully invested)."""
return jnp.sum(vals[var]) - 1.0
@jaxls.Cost.factory(kind="constraint_geq_zero")
def no_short_constraint(vals: jaxls.VarValues, var: WeightsVar) -> jax.Array:
"""No short-selling: weights >= 0."""
return vals[var]
cov_chol = jnp.linalg.cholesky(covariance)
return_weight = 5.0 # Trade-off between return and variance
def optimize_portfolio(expected_returns: jax.Array) -> jax.Array:
"""Optimize portfolio for given expected returns."""
costs = [
variance_cost(weights_var, cov_chol),
return_cost(weights_var, expected_returns, return_weight),
budget_constraint(weights_var),
no_short_constraint(weights_var),
]
problem = jaxls.LeastSquaresProblem(costs, [weights_var]).analyze()
solution = problem.solve(
verbose=False,
linear_solver="dense_cholesky",
termination=jaxls.TerminationConfig(cost_tolerance=1e-8),
)
return solution[weights_var]
# Optimize with prior returns.
prior_weights = optimize_portfolio(prior_returns)
# Optimize with posterior returns (incorporating views)
posterior_weights = optimize_portfolio(posterior_returns)
print("Optimal weights with PRIOR returns (market equilibrium):")
for name, w in zip(stock_names, prior_weights):
print(f" {name}: {float(w) * 100:.1f}%")
print("\nOptimal weights with POSTERIOR returns (after views):")
for name, w in zip(stock_names, posterior_weights):
print(f" {name}: {float(w) * 100:.1f}%")
INFO | Building optimization problem with 4 terms and 1 variables: 2 costs, 1 eq_zero, 0 leq_zero, 1 geq_zero
INFO | Vectorizing group with 1 costs, 1 variables each: variance_cost
INFO | Vectorizing group with 1 costs, 1 variables each: return_cost
INFO | Vectorizing constraint group with 1 constraints (constraint_eq_zero), 1 variables each: augmented_budget_constraint
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 1 variables each: augmented_no_short_constraint
INFO | Building optimization problem with 4 terms and 1 variables: 2 costs, 1 eq_zero, 0 leq_zero, 1 geq_zero
INFO | Vectorizing constraint group with 1 constraints (constraint_eq_zero), 1 variables each: augmented_budget_constraint
INFO | Vectorizing group with 1 costs, 1 variables each: return_cost
INFO | Vectorizing group with 1 costs, 1 variables each: variance_cost
INFO | Vectorizing constraint group with 1 constraints (constraint_geq_zero), 1 variables each: augmented_no_short_constraint
Optimal weights with PRIOR returns (market equilibrium):
IBM: 3.2%
WMT: 96.8%
SEHI: -0.0%
Optimal weights with POSTERIOR returns (after views):
IBM: 4.3%
WMT: 95.7%
SEHI: -0.0%
Visualization#
We compare the prior vs posterior expected returns and portfolio allocations.
Interpreting the results#
The visualization shows how investor views shift the optimal allocation:
Expected Returns:
Our view that “SEHI outperforms IBM by 5%” increases SEHI’s expected return and decreases IBM’s
Our view that “WMT returns 2%” adjusts WMT’s return toward that target
Portfolio Allocation:
The posterior portfolio increases allocation to SEHI (which we believe will outperform)
Allocation to IBM decreases (the underperformer in our relative view)
WMT allocation adjusts based on its updated expected return
This demonstrates the power of Black-Litterman: instead of directly specifying portfolio weights, we express our beliefs as views, and the model translates them into a coherent allocation that respects both market equilibrium and our specific insights.
For more details on mean-variance optimization with jaxls, see Mean-variance allocation.
For the core API, see jaxls.Cost and jaxls.LeastSquaresProblem.