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:

  • Var with vector-valued default

  • Equality constraints (constraint_eq_zero): budget constraint

  • Inequality constraints (constraint_geq_zero): no short-selling

  • Augmented Lagrangian solver for constrained optimization

  • Comparison of prior vs posterior allocations

Hide code cell source

import sys
from loguru import logger

logger.remove()
logger.add(sys.stdout, format="<level>{level: <8}</level> | {message}");
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:

  1. Prior (market equilibrium): Expected returns implied by current market prices, assuming markets are efficient. Computed via “reverse optimization” from market-cap weights.

  2. Views (investor beliefs): Specific predictions about asset returns, which can be:

    • Absolute: “SEHI will return 10%”

    • Relative: “SEHI will outperform IBM by 5%”

  3. Posterior: Bayesian combination of prior and views, weighted by confidence.

The posterior expected returns are:

\[\mu_{\text{posterior}} = \left[(\tau\Sigma)^{-1} + P^T\Omega^{-1}P\right]^{-1} \left[(\tau\Sigma)^{-1}\pi + P^T\Omega^{-1}Q\right]\]

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:

\[\pi = \delta \cdot \Sigma \cdot w_{\text{market}}\]

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:

  1. Relative view: “SEHI will outperform IBM by 5% (monthly)”

    • This is encoded as: return(SEHI) - return(IBM) = 0.05

  2. 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.

Hide code cell source

import plotly.graph_objects as go
from plotly.subplots import make_subplots
from IPython.display import HTML

colors = {"prior": "#2196F3", "posterior": "#FF9800"}

fig = make_subplots(
    rows=1,
    cols=2,
    subplot_titles=("Expected Returns (Monthly)", "Portfolio Allocation"),
)

# Left plot: Expected returns comparison.
x_pos = list(range(n_assets))
bar_width = 0.35

fig.add_trace(
    go.Bar(
        x=[stock_names[i] for i in x_pos],
        y=[float(r) * 100 for r in prior_returns],
        name="Prior (Equilibrium)",
        marker_color=colors["prior"],
        width=bar_width,
        offset=-bar_width / 2,
    ),
    row=1,
    col=1,
)

fig.add_trace(
    go.Bar(
        x=[stock_names[i] for i in x_pos],
        y=[float(r) * 100 for r in posterior_returns],
        name="Posterior (With Views)",
        marker_color=colors["posterior"],
        width=bar_width,
        offset=bar_width / 2,
    ),
    row=1,
    col=1,
)

# Right plot: Portfolio allocation comparison.
fig.add_trace(
    go.Bar(
        x=[stock_names[i] for i in x_pos],
        y=[float(w) * 100 for w in prior_weights],
        name="Prior (Equilibrium)",
        marker_color=colors["prior"],
        width=bar_width,
        offset=-bar_width / 2,
        showlegend=False,
    ),
    row=1,
    col=2,
)

fig.add_trace(
    go.Bar(
        x=[stock_names[i] for i in x_pos],
        y=[float(w) * 100 for w in posterior_weights],
        name="Posterior (With Views)",
        marker_color=colors["posterior"],
        width=bar_width,
        offset=bar_width / 2,
        showlegend=False,
    ),
    row=1,
    col=2,
)

fig.update_yaxes(title_text="Return (%)", row=1, col=1)
fig.update_yaxes(title_text="Weight (%)", row=1, col=2)

fig.update_layout(
    height=400,
    margin=dict(t=60, b=40, l=60, r=40),
    legend=dict(
        orientation="h",
        yanchor="bottom",
        y=1.08,
        xanchor="center",
        x=0.5,
    ),
    barmode="group",
)

HTML(fig.to_html(full_html=False, include_plotlyjs="cdn"))

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.