Skip to content

Black–Litterman: Reference Portfolios, Views, and Uncertainty ​

The previous chapter improved the risk inputs used by portfolio optimization. Expected returns present another difficulty: small changes in estimated means can produce large changes in optimal allocations. Black–Litterman starts from a reference portfolio and combines its implied expected returns with explicit investor views and their uncertainty.

We first examine sample-mean uncertainty, equal-return and equal-Sharpe targets, and the factor-based alternative. These lead to three separate decisions: constructing a prior, updating beliefs, and choosing an allocation under constraints. Black–Litterman makes the assumptions behind a tilt visible; it does not make the reference portfolio correct, turn opinions into facts, or guarantee better performance.

The first examples use hypothetical one-year inputs. Returns and views are decimals, covariance is in squared return units, and expected returns are excess of a common known cash return. Run the examples in order; the interactive experiment also runs independently because its helper contains the stated two-asset inputs.

python
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok

Why Historical Means Need Structure ​

Question: if an asset has delivered a high average return, how precisely have we estimated its future expected return?

For T independent observations with a constant mean and variance σ2, the sample mean r¯ has standard error:

SE(r¯)=σT.

For independent one-year excess returns with 20% volatility, ten years of observations give a standard error of 0.20/10≈0.06325, or 6.32 percentage points. That is uncertainty about the estimated mean, not the volatility of next year's return. Four times as many observations halve this standard error under the stated model.

python
mean_observations = np.array([5, 10, 20, 40])
mean_standard_errors = 0.20 / np.sqrt(mean_observations)
print(pd.DataFrame({
    "Independent annual observations": mean_observations,
    "Mean standard error (percentage points)": 100 * mean_standard_errors,
}).to_string(index=False, float_format=lambda x: f"{x:.3f}"))

For an asset vector under the same independent, identically distributed model, the covariance of the sample mean is Σ/T. Serial dependence and changing expected returns require a different uncertainty calculation. A longer historical window also spans more potentially different economic conditions.

With monthly observations, 12r¯m estimates an annualized arithmetic mean. Its standard error under independence is 12sm/T, where T counts months; annualized return volatility instead uses 12sm. Neither expression estimates CAGR. Keep these quantities distinct when deciding how much confidence to place in a return estimate.

Shrink Expected Returns Toward an Explicit Target ​

A simple way to reduce reliance on noisy sample means is to blend them with a structured target m:

μ^λ=(1−λ)r¯+λm,0≤λ≤1.

Here λ=0 retains the sample mean and λ=1 uses only the target. This is mean shrinkage; Chapter 2.2 instead shrank the covariance matrix. The target can reduce estimation variance at the cost of bias if its assumptions are wrong. A chosen blend is not automatically a Bayesian posterior or an optimal shrinkage estimator.

Equal Returns and Equal Sharpe Ratios ​

Two simple targets avoid using each asset's historical mean to rank its expected performance:

  • Equal expected excess returns: mi=c. All assets receive the same expected premium, even if their risks differ. For a fully invested portfolio, wTm=c, so maximizing mean–variance utility with this target reduces to minimizing variance. Equal expected returns do not imply equal weights.
  • Equal Sharpe ratios: mi=sσi. Each asset receives the same assumed excess-return-to-volatility ratio s. More volatile assets receive higher expected premiums. This assumption does not imply equal portfolio risk contributions, and individual Sharpe ratios do not capture correlations.

These are sometimes called agnostic priors: they avoid asset-specific historical-mean rankings but still impose economic assumptions. Their common levels c and s must be specified or estimated using information available at the decision date.

A Worked Allocation Comparison ​

Take hypothetical sample excess means of 12% for Equity and 2% for Bonds, volatilities of 20% and 10%, and correlation 0.30. Use a 4% equal-return target and a 0.30 equal-Sharpe target, giving target premiums of 6% and 3%. At 50% shrinkage:

μ^equal return=(8%,3%),μ^equal Sharpe=(9%,2.5%).

Hold covariance and risk aversion fixed at δ=2.5 and maximize wTμ^−δwTΣw/2 over fully invested, long-only weights. The toolkit's bl_portfolio_weights() solves this utility problem for any supplied excess means, including these non-Black–Litterman estimates.

python
shrink_assets = pd.Index(["Equity", "Bonds"])
shrink_sample = pd.Series([0.12, 0.02], index=shrink_assets)
shrink_cov = pd.DataFrame([[0.04, 0.006], [0.006, 0.01]],
                          index=shrink_assets, columns=shrink_assets)
shrink_targets = {
    "Equal return": pd.Series(0.04, index=shrink_assets),
    "Equal Sharpe": 0.30 * pd.Series(np.sqrt(np.diag(shrink_cov)), index=shrink_assets),
}
shrink_intensity = 0.50  # Edit within [0, 1] and rerun this cell.
if not 0 <= shrink_intensity <= 1:
    raise ValueError("Shrinkage intensity must lie in [0, 1].")
shrink_means = pd.DataFrame({"Sample": shrink_sample, **{
    name: (1 - shrink_intensity) * shrink_sample + shrink_intensity * target
    for name, target in shrink_targets.items()
}})
shrink_weights = shrink_means.apply(lambda means: pok.bl_portfolio_weights(means, shrink_cov, delta=2.5))
print(f"Selected target weight: {shrink_intensity:.0%}")
print("Expected excess returns (%):")
print((100 * shrink_means).to_string(float_format=lambda x: f"{x:.3f}"))
print("Long-only portfolio weights (%):")
print((100 * shrink_weights).to_string(float_format=lambda x: f"{x:.3f}"))

shrink_grid = np.linspace(0, 1, 21)
shrink_mean_paths, shrink_weight_paths = {}, {}
for name, target in shrink_targets.items():
    path = pd.DataFrame([(1 - strength) * shrink_sample + strength * target
                         for strength in shrink_grid], index=shrink_grid)
    for asset in shrink_assets:
        shrink_mean_paths[f"{name}: {asset}"] = (100 * path[asset]).tolist()
    shrink_weight_paths[name] = [
        100 * pok.bl_portfolio_weights(means, shrink_cov, delta=2.5)["Equity"]
        for _, means in path.iterrows()
    ]
print("\n<ECHARTS_DATA>" + json.dumps({
    "meanShrinkage": {
        "type": "line", "title": "Expected Returns Along the Shrinkage Path",
        "xAxis": {"type": "category", "name": "Weight on target",
                  "data": [f"{strength:.0%}" for strength in shrink_grid]},
        "yAxisName": "Expected excess return (%)",
        "series": shrink_mean_paths,
    },
    "allocationShrinkage": {
        "type": "line", "title": "Equity Allocation Along the Shrinkage Path",
        "xAxis": {"type": "category", "name": "Weight on target",
                  "data": [f"{strength:.0%}" for strength in shrink_grid]},
        "yAxisName": "Equity weight (%)",
        "series": shrink_weight_paths,
    },
}, allow_nan=False))

At 50% shrinkage, the Equity weights are approximately 63.16% with the equal-return target and 78.95% with the equal-Sharpe target, compared with 100% using the sample means. The allocation chart shows Equity only; Bonds receives the remainder. Its flat segments arise when the long-only constraint binds, even though estimated means continue changing.

Try it: set shrink_intensity to 0, 0.5, and 1. The tables show your selected blend; the charts always show the full path. Predict the endpoint before rerunning: full equal-return shrinkage gives the minimum-variance allocation, about 10.53% Equity, while full equal-Sharpe shrinkage gives about 42.11% Equity. Compare objectives and assumptions rather than interpreting smoother allocations as evidence of better future returns.

Connect Factor Exposures to Expected Returns ​

The factor chapter modeled excess returns as rie=αi+βiTf+εi. Taking expectations with zero-mean residuals gives:

μie=αi+βiTE[f].

For example, exposures of 0.8 and 0.4 to factors with expected premiums of 5% and 2%, assuming zero alpha, imply 0.8(0.05)+0.4(0.02)=4.8% expected excess return on the same horizon. The premiums are expectations, not necessarily their historical averages; zero alpha is an assumption rather than a regression finding. Estimated exposures and factor premiums both introduce uncertainty.

A factor model can supply another structured target. Black–Litterman takes a different starting point: an allocation and a risk model imply a coherent set of reference expected returns, which explicit views can then update. We now derive that reference.

Start from an Allocation, Not a Sample Mean ​

Let w0 be reference risky-asset weights, Σ their return covariance, and δ>0 a risk-aversion coefficient. For expected excess returns μ, consider mean–variance utility:

U(w)=wTμ−δ2wTΣw.

First allow unrestricted risky positions with cash financing the remaining budget. The first-order condition is:

μ−δΣw=0.

Reverse the problem: which expected excess returns make the reference allocation optimal? The answer is:

Π=δΣw0.

These implied returns depend on covariance, risk aversion, and the reference weights. They are not historical average returns. A market-capitalization reference is motivated by an equilibrium interpretation; a policy benchmark instead produces a policy-implied prior. Calling an arbitrary selected portfolio “the market” does not establish equilibrium.

If optimization were restricted from the outset to fully invested risky portfolios, the first-order condition would also contain a budget multiplier: μ=δΣw0+λ1. Weights alone would not identify that common return level. The cash-financed formulation above motivates the conventional choice λ=0.

A Two-Asset Hand Calculation ​

Use an Equity/Bonds reference with weights 60%/40%. Equity volatility is 20%, bond volatility 10%, and correlation 0.30, so covariance is 0.30(0.20)(0.10)=0.006. With δ=2.5:

Σ=(0.040.0060.0060.01),Π=2.5(0.04(0.6)+0.006(0.4)0.006(0.6)+0.01(0.4))=(0.0660.019).

The implied expected excess returns are 6.6% for Equity and 1.9% for Bonds. If the matching cash return were 2%, total expected returns would be 8.6% and 3.9%; do not add cash to the model's excess-return inputs themselves.

python
assets = pd.Index(["Equity", "Bonds"])
sigma = pd.DataFrame([[0.04, 0.006], [0.006, 0.01]], index=assets, columns=assets)
reference_weights = pd.Series([0.60, 0.40], index=assets)
risk_aversion = 2.5
pi = pok.implied_returns(sigma, reference_weights, delta=risk_aversion)
print(pd.DataFrame({"Reference weight (%)": reference_weights * 100,
                    "Implied excess return (%)": pi * 100}).to_string(float_format=lambda x: f"{x:.3f}"))

For an invertible Σ, substituting Π back into w∗=Σ−1Π/δ recovers the reference. Use a linear solve rather than explicitly forming an inverse:

python
recovered_reference = pd.Series(np.linalg.solve(sigma, pi) / risk_aversion, index=assets)
reference_excess_mean = reference_weights @ pi
reference_variance = reference_weights @ sigma @ reference_weights
print("Recovered risky weights (%):")
print((recovered_reference * 100).to_string(float_format=lambda x: f"{x:.3f}"))
print(f"Reference expected excess return: {reference_excess_mean:.3%}")
print(f"Reference variance: {reference_variance:.6f}")
print(f"Implied risk aversion: {reference_excess_mean / reference_variance:.3f}")

The reference's expected excess return is 4.72% and variance 0.01888, so their ratio is 2.5. This identity motivates estimating market risk aversion from an expected market premium divided by variance. A noisy historical average, especially a negative one, is not automatically a defensible positive risk-aversion estimate.

Try it: change risk_aversion to 5 and rerun both cells. Implied returns double, while reverse optimization with the same coefficient recovers the same weights. Risk aversion controls the mean–variance trade-off; it is not a view-confidence parameter.

Encode Views and Their Uncertainty ​

Let N be the number of assets and K the number of views. The pick matrix P has shape K×N, and Q is a K-element vector specifying views about expected excess returns:

Q=Pμ+η,η∼N(0,Ω).

Rows of P identify the exposures in each view. Q contains the stated expected returns or return spreads. Ω is the covariance of view errors about the means, not the covariance of asset returns.

  • Absolute view: Equity's expected excess return is 8%. Use P=[1,0] and Q=0.08.
  • Relative view: Equity's expected return exceeds Bonds' by 8 percentage points. Use P=[1,−1] and Q=0.08.

The same number has different meanings in these two statements. A relative view is a difference of expected returns, not “Equity grows 8% faster” as a ratio of wealth levels.

For a view originally stated in total-return units, subtract the cash contribution:

Qexcess=Qtotal−rfP1.

For a zero-net-exposure relative view, P1=0, so cash cancels. For an absolute asset view, it does not.

python
view_examples = pd.DataFrame([[1.0, 0.0], [1.0, -1.0]], columns=assets,
                             index=["Equity absolute", "Equity minus bonds"])
total_view_returns = pd.Series([0.08, 0.08], index=view_examples.index)
cash_assumption = 0.02
excess_view_returns = total_view_returns - cash_assumption * view_examples.sum(axis=1)
print(view_examples)
print("Views converted from total to excess units (%):")
print((excess_view_returns * 100).to_string())

# The main experiment uses the relative view only.
P = view_examples.loc[["Equity minus bonds"]]
Q = pd.Series([0.08], index=P.index)
tau = 0.05
C = tau * sigma
Omega = pok.omega_uncertain_prior(sigma, tau, P)
print("Default view-error covariance:")
print(Omega.to_string(float_format=lambda x: f"{x:.6f}"))

The converted absolute view is a 6% excess return; the relative view remains 8%. The named rows and columns are part of the calculation: assets, views, and covariance matrices must match by meaning, not merely by array position.

Prior Uncertainty Versus View Uncertainty ​

The Bayesian formulation treats the unknown expected excess-return vector as:

μ∼N(Π,C),C=τΣ,τ>0.

Σ describes return variability conditional on the mean. C describes uncertainty about the mean. The scalar τ specifies the prior uncertainty scale; it is not automatically 1/T unless a separate estimation argument justifies that choice.

Smaller entries in Ω indicate more precise views. Off-diagonal entries represent correlated view errors. A diagonal Ω assumes those errors are uncorrelated; it does not follow merely because the views have different names.

The toolkit's default is the common heuristic:

Ω=diag(PCPT).

For our single relative view, PCPT=0.05(0.04+0.01−2×0.006)=0.0019. The default assigns the view error the same variance as the prior uncertainty in that expected spread. Its standard deviation is 0.0019≈4.359 percentage points—not a claim that the view has a particular probability of being correct.

Derive the Posterior Update ​

Assume view errors are independent of the prior mean uncertainty. When C and Ω are positive definite, combining the Gaussian prior and likelihood is equivalent to minimizing:

12(μ−Π)TC−1(μ−Π)+12(Q−Pμ)TΩ−1(Q−Pμ).

The first term penalizes deviations from the prior; the second penalizes deviations from the views. More precise information receives more weight. Differentiating gives:

(C−1+PTΩ−1P)μ=C−1Π+PTΩ−1Q.

An equivalent update, which avoids explicitly inverting C or Ω, is:

μBL=Π+CPT(PCPT+Ω)−1(Q−PΠ).

Q−PΠ is the innovation: the difference between the stated views and what the prior already implies. CPT propagates each view through the covariance structure, so an asset can move even when its coefficient in that view is zero.

The posterior covariance of the uncertain mean is:

M=C−CPT(PCPT+Ω)−1PC.

The implementation solves the conditioning system rather than forming its inverse. Independent exact views can also be handled through this update with Ω=0, provided PCPT+Ω is nonsingular. Redundant exact views require reformulation; silently adding numerical noise would change the model.

Complete the Two-Asset Calculation ​

The prior relative mean is PΠ=0.066−0.019=0.047. The stated view is 0.08, so the innovation is 0.033. Also:

CPT=(0.0017−0.0002),PCPT+Ω=0.0038.

Thus the update adds approximately 1.4763 percentage points to Equity and subtracts 0.1737 percentage points from Bonds. The posterior means are 8.0763% and 1.7263%, giving a spread of 6.35%, halfway between the prior 4.7% spread and the 8% view.

python
prior_spread = float((P @ pi).iloc[0])
innovation = Q - P @ pi
cross_covariance = C @ P.T
conditioning_variance = float((P @ C @ P.T + Omega).iloc[0, 0])
hand_mean = pi + cross_covariance.iloc[:, 0] * innovation.iloc[0] / conditioning_variance
posterior_mean, predictive_covariance = pok.black_litterman(
    reference_weights, sigma, P, Q, Omega=Omega, delta=risk_aversion, tau=tau,
)
mean_uncertainty = predictive_covariance - sigma
print(pd.DataFrame({"Prior (%)": pi * 100, "Hand update (%)": hand_mean * 100,
                    "Toolkit posterior (%)": posterior_mean * 100}).to_string(float_format=lambda x: f"{x:.4f}"))
print(f"Prior spread: {prior_spread:.3%}; posterior spread: {float((P @ posterior_mean).iloc[0]):.3%}")

Try it: set the view to the prior spread, Q.iloc[0] = prior_spread, and rerun. The mean does not move because the innovation is zero, but posterior uncertainty still falls: a consistent informative signal adds precision. Restore the 8% view before continuing with the default worked results.

Three Different Covariance Matrices ​

Suppose future excess returns satisfy Re∣μ∼N(μ,Σ), independently of the view errors conditional on the mean. The law of total covariance gives:

Cov(Re∣Q)=Σ+M.

Keep the roles separate:

MatrixMeaning
ΣVariability of future returns conditional on their mean
MPosterior uncertainty about the expected-return vector
Σ+MPosterior predictive covariance of future returns

The second result returned by pok.black_litterman() is Σ+M. Subtract Σ to obtain M. A smaller uncertainty about a mean does not eliminate the underlying randomness of returns.

python
view_vector = P.iloc[0].to_numpy()
uncertainty_comparison = pd.Series({
    "Prior mean-spread uncertainty": np.sqrt(view_vector @ C.to_numpy() @ view_vector),
    "Posterior mean-spread uncertainty": np.sqrt(view_vector @ mean_uncertainty.to_numpy() @ view_vector),
    "Conditional return-spread volatility": np.sqrt(view_vector @ sigma.to_numpy() @ view_vector),
    "Predictive return-spread volatility": np.sqrt(view_vector @ predictive_covariance.to_numpy() @ view_vector),
})
print((uncertainty_comparison * 100).rename("Standard deviation (percentage points)").to_string(float_format=lambda x: f"{x:.3f}"))

For the default inputs, posterior uncertainty in the mean spread is about 3.082 percentage points, while predictive spread volatility is about 19.736%. They answer different questions and should not share an unlabeled “risk” column.

View Strength and the Role of Tau ​

A Single-View Confidence Scale ​

For one view with prior variance v=PCPT>0, define a scale 0<c≤1 by:

Ω=1−ccv.

Then v/(v+Ω)=c, so the posterior viewed mean is:

PμBL=(1−c)PΠ+cQ.

Here “confidence” is an update-weight parameter for this construction, not a probability that the view is true. At c=0 we explicitly omit the view; at c=1 it becomes exact. With multiple correlated or overlapping views, a separate scalar confidence does not generally describe each final update so simply.

python
confidence_rows = []
for confidence in [0.0, 0.25, 0.5, 0.75, 1.0]:
    if confidence == 0:
        mean_c, predictive_c = pok.black_litterman(reference_weights, sigma, P.iloc[:0], Q.iloc[:0],
                                                  delta=risk_aversion, tau=tau)
    else:
        omega_c = Omega * (1 - confidence) / confidence
        mean_c, predictive_c = pok.black_litterman(reference_weights, sigma, P, Q, Omega=omega_c,
                                                  delta=risk_aversion, tau=tau)
    weights_c = pok.bl_portfolio_weights(mean_c, sigma, delta=risk_aversion)
    confidence_rows.append([confidence, float((P @ mean_c).iloc[0]), weights_c["Equity"]])
confidence_table = pd.DataFrame(confidence_rows, columns=["Confidence", "Posterior spread", "Equity weight"])
print(confidence_table.to_string(float_format=lambda x: f"{x:.4f}"))
print("\n<ECHARTS_DATA>" + json.dumps({
    "confidenceMean": {
        "title": "One View: Strength of the Mean Update",
        "xAxis": {"type": "value", "name": "View update weight", "min": 0, "max": 1},
        "yAxisName": "Expected annual return spread (%)",
        "series": {"Posterior spread": (confidence_table[["Confidence", "Posterior spread"]] * [1, 100]).values.tolist()},
    },
    "confidenceWeights": {
        "title": "One View: Constrained Equity Allocation",
        "xAxis": {"type": "value", "name": "View update weight", "min": 0, "max": 1},
        "yAxisName": "Equity portfolio weight (%)",
        "series": {"Equity": (confidence_table[["Confidence", "Equity weight"]] * [1, 100]).values.tolist()},
    },
}, allow_nan=False))

The mean spread moves linearly under this single-view construction, while long-only portfolio weights can hit bounds. A strong enough view can produce a concentrated allocation even when the posterior update itself is smooth.

Why Tau Sometimes Cancels ​

With C=τΣ and the default Ω=τdiag(PΣPT), the common τ factor cancels from the gain in the posterior mean. It remains in M, so predictive covariance can change. If instead Ω is held fixed as τ changes, the relative weight of the prior and views changes and the mean generally moves.

python
tau_rows = []
for tau_value in [0.01, 0.05, 0.20]:
    scaled_mean, scaled_predictive = pok.black_litterman(reference_weights, sigma, P, Q,
                                                        delta=risk_aversion, tau=tau_value)
    fixed_mean, _ = pok.black_litterman(reference_weights, sigma, P, Q, Omega=Omega,
                                       delta=risk_aversion, tau=tau_value)
    tau_rows.append([tau_value, float((P @ scaled_mean).iloc[0]), float((P @ fixed_mean).iloc[0]),
                     np.sqrt(view_vector @ (scaled_predictive - sigma).to_numpy() @ view_vector)])
tau_table = pd.DataFrame(tau_rows, columns=["Tau", "Spread: scaled Omega", "Spread: fixed Omega", "Posterior mean-spread SD"])
print(tau_table.to_string(float_format=lambda x: f"{x:.5f}"))

This is why tuning tau without stating the Omega convention is ambiguous. Keep both the prior-uncertainty and view-uncertainty assumptions visible.

From Posterior Means to Portfolio Weights ​

Updating beliefs does not specify trading constraints. We now impose fully invested, long-only risky weights:

maxw(wTμBL−δ2wTVw),1Tw=1,wi≥0.

Choose V explicitly. Using V=Σ retains the original conditional-risk model; using V=Σ+M accounts for mean uncertainty in predictive return risk. These are different allocation specifications. Neither should be selected silently by a helper or confused with normalizing unconstrained risky positions.

python
weights_prior_risk = pok.bl_portfolio_weights(posterior_mean, sigma, delta=risk_aversion)
weights_predictive_risk = pok.bl_portfolio_weights(posterior_mean, predictive_covariance, delta=risk_aversion)
allocation_comparison = pd.DataFrame({"Reference": reference_weights, "BL: conditional risk": weights_prior_risk,
                                     "BL: predictive risk": weights_predictive_risk})
print((allocation_comparison * 100).to_string(float_format=lambda x: f"{x:.3f}"))
print("\n<ECHARTS_DATA>" + json.dumps({
    "allocationChoice": {
        "type": "bar", "title": "Same Posterior Mean, Explicit Risk-Matrix Choice",
        "xAxis": {"type": "category", "data": assets.tolist()}, "yAxisName": "Portfolio weight (%)",
        "series": {name: (allocation_comparison[name] * 100).tolist() for name in allocation_comparison},
    },
}, allow_nan=False))

With conditional covariance, the default posterior implies approximately 77.37% Equity and 22.63% Bonds. For two assets, this result can be independently checked by substituting wB=1−wE into the utility objective and solving:

wE∗=μE−μB+δ(ΣBB−ΣEB)δ(ΣEE+ΣBB−2ΣEB),

then restricting the result to [0,1]. The predictive-risk allocation generally differs. With no views, the original reference is recovered using Π and Σ when it is feasible. Using the enlarged predictive covariance (1+τ)Σ instead need not reproduce that reference under the same risk aversion and budget constraint.

Exact Views Still Leave Return Risk ​

python
exact_omega = pd.DataFrame(0.0, index=P.index, columns=P.index)
exact_mean, exact_predictive = pok.black_litterman(reference_weights, sigma, P, Q, Omega=exact_omega,
                                                 delta=risk_aversion, tau=tau)
exact_mean_spread_variance = float(view_vector @ (exact_predictive - sigma).to_numpy() @ view_vector)
print(f"Exact posterior expected spread: {float((P @ exact_mean).iloc[0]):.3%}")
print(f"Mean-spread uncertainty variance: {exact_mean_spread_variance:.3e}")
print(f"Predictive spread volatility: {np.sqrt(view_vector @ exact_predictive.to_numpy() @ view_vector):.3%}")

The expected spread is exactly 8%, and its mean uncertainty is zero up to roundoff. Future return-spread volatility is still about 19.494%. Certainty about an expected value is not certainty about the realized outcome.

Interactive View and Allocation Experiment ​

The controls below recreate the two-asset case. “View spread” is in decimal return units: 0.08 means eight percentage points. The confidence slider implements the single-view scale above, including explicit no-view and exact-view endpoints. “Risk matrix” chooses the allocation covariance; it does not change the posterior mean.

python
import ipywidgets as widgets
import PortfolioOptimizationKit as pok

widget_instance = widgets.interactive(
    pok.show_black_litterman_echart,
    view_return=widgets.FloatSlider(value=0.08, min=-0.05, max=0.15, step=0.005, description="View spread"),
    confidence=widgets.FloatSlider(value=0.5, min=0.0, max=1.0, step=0.05, description="Confidence"),
    tau=widgets.FloatSlider(value=0.05, min=0.01, max=0.20, step=0.01, description="Prior tau"),
    delta=widgets.FloatSlider(value=2.5, min=0.5, max=10.0, step=0.5, description="Risk aversion"),
    risk_model=widgets.Dropdown(options=[("Conditional covariance", "prior"), ("Predictive covariance", "predictive")],
                                value="prior", description="Risk matrix"),
)
widget_instance

Try these experiments:

  1. Set confidence to zero with conditional covariance: the prior means and 60/40 reference allocation return.
  2. Increase confidence to one: the expected spread reaches the view, while realized return risk remains positive.
  3. Change tau: under this proportional Omega rule, the mean stays fixed but its uncertainty changes. Select predictive covariance to see the allocation effect.
  4. Change risk aversion: both the reverse-optimized prior and the investor's utility change. This is different from changing risk aversion only at the final allocation step.

A Historical Three-Industry Illustration ​

Estimate Risk Before Setting the Decision ​

Use the bundled value-weighted industry returns for Food, Steel, and Finance. The training period is January 2000–December 2009; the evaluation period will be January 2010–December 2018. The selection is illustrative, not a claim that these industries represent the whole market.

Estimate monthly covariance using the previous chapter's 50% constant-correlation shrinkage, then report it on the annualized basis by multiplying by 12. Views below refer to annualized arithmetic expected excess returns, not forecasts of CAGR. Tau is specified on this same reporting basis rather than inferred automatically from the number of months.

python
industry_names = ["Food", "Steel", "Fin"]
industry_returns = pok.get_ind_file(filetype="rets", nind=30, ew=False)[industry_names]
training_returns = industry_returns.loc["2000":"2009"]
held_out_returns = industry_returns.loc["2010":"2018"]
factor_returns = pok.get_fff_returns()
annual_covariance = 12 * pok.shrinkage_cov(training_returns, delta=0.5)
print(f"Training: {training_returns.index[0]} to {training_returns.index[-1]}, {len(training_returns)} months")
print(f"Held out: {held_out_returns.index[0]} to {held_out_returns.index[-1]}, {len(held_out_returns)} months")
print("Annualized covariance:")
print(annual_covariance.to_string(float_format=lambda x: f"{x:.5f}"))

A Reference Within the Selected Universe ​

Use the final training month's firm counts times average firm sizes to form implied capitalization weights within these three industries. Those weights are normalized over the selected universe. The metadata do not establish point-in-time publication availability; this remains a retrospective illustration.

python
reference_month = training_returns.index[-1]
counts = pok.get_ind_file(filetype="nfirms", nind=30).loc[reference_month, industry_names]
sizes = pok.get_ind_file(filetype="size", nind=30).loc[reference_month, industry_names]
selected_capitalization = counts * sizes
historical_reference = selected_capitalization / selected_capitalization.sum()
historical_delta, historical_tau = 2.5, 0.05
historical_pi = pok.implied_returns(annual_covariance, historical_reference, delta=historical_delta)
sample_excess_means = 12 * training_returns.sub(factor_returns.loc[training_returns.index, "RF"], axis=0).mean()
print(pd.DataFrame({"Reference weight (%)": historical_reference * 100,
                    "Implied excess mean (%)": historical_pi * 100,
                    "Historical arithmetic excess mean (%)": sample_excess_means * 100})
      .to_string(float_format=lambda x: f"{x:.3f}"))

The last two columns answer different questions. Reverse optimization asks which means support the reference under the risk model; the sample mean summarizes historical excess returns. Neither is observed future performance.

Two Explicit Hypothetical Views ​

Assume Food will outperform Steel by two percentage points on the stated annualized basis, and Finance will have a 6% expected excess return. These are hypothetical opinions for the exercise, not documented forecasts made in 2009.

python
historical_P = pd.DataFrame([[1.0, -1.0, 0.0], [0.0, 0.0, 1.0]], columns=industry_names,
                            index=["Food minus Steel", "Finance absolute excess"])
historical_Q = pd.Series([0.02, 0.06], index=historical_P.index)
historical_omega = pok.omega_uncertain_prior(annual_covariance, historical_tau, historical_P)
print(historical_P)
print(pd.DataFrame({"Prior implied view (%)": historical_P @ historical_pi * 100,
                    "Stated view (%)": historical_Q * 100,
                    "View-error SD (percentage points)": np.sqrt(np.diag(historical_omega)) * 100})
      .to_string(float_format=lambda x: f"{x:.3f}"))

A positive relative view can still be below what the prior already implies. Always inspect Q−PΠ before describing a view as bullish or bearish relative to the reference.

python
historical_mean, historical_predictive = pok.black_litterman(
    historical_reference, annual_covariance, historical_P, historical_Q, Omega=historical_omega,
    delta=historical_delta, tau=historical_tau,
)
print(pd.DataFrame({"Prior excess mean (%)": historical_pi * 100,
                    "Posterior excess mean (%)": historical_mean * 100}).to_string(float_format=lambda x: f"{x:.3f}"))
print("\n<ECHARTS_DATA>" + json.dumps({
    "industryBLMeans": {
        "type": "bar", "title": "Three Industries: Reference-Implied and Updated Means",
        "xAxis": {"type": "category", "data": industry_names}, "yAxisName": "Annualized expected excess return (%)",
        "series": {"Prior": (historical_pi * 100).tolist(), "Posterior": (historical_mean * 100).tolist()},
    },
}, allow_nan=False))

For the default inputs, Food's posterior excess mean is approximately 3.461%, Steel's 5.125%, and Finance's 7.687%. The relative Food–Steel expectation rises from about −6.305% to −1.664%, toward the stated +2% view. Food's absolute mean nevertheless falls: the second view and the covariance structure also affect the joint update. Reading only one view at a time would miss that interaction.

Correlated Views Are a Different Information Model ​

If the two view errors share information, a diagonal Omega may overstate independent evidence. Keep their marginal error variances fixed and introduce a 0.5 error correlation. This changes the joint update; it does not change the supplied asset covariance or the view statements themselves.

python
view_error_sd = np.sqrt(np.diag(historical_omega))
correlated_omega = historical_omega.copy()
correlated_omega.iloc[0, 1] = correlated_omega.iloc[1, 0] = 0.5 * view_error_sd[0] * view_error_sd[1]
correlated_mean, _ = pok.black_litterman(historical_reference, annual_covariance, historical_P, historical_Q,
                                       Omega=correlated_omega, delta=historical_delta, tau=historical_tau)
print(pd.DataFrame({"Independent view errors (%)": historical_mean * 100,
                    "Correlated view errors (%)": correlated_mean * 100}).to_string(float_format=lambda x: f"{x:.3f}"))

Do not duplicate the same opinion as two supposedly independent views to manufacture confidence. Perfectly redundant exact information can make the conditioning system singular, which the toolkit reports explicitly.

Choose Allocations, Then Freeze Them for Evaluation ​

Use the original annualized covariance for every allocation in this comparison, with identical risk aversion and long-only, fully invested constraints. This isolates the expected-return inputs. The sample-mean strategy is included as a comparator, not as a claim that unconstrained historical extrapolation is advisable.

python
historical_weights = pd.DataFrame({
    "Reference": historical_reference,
    "Black–Litterman": pok.bl_portfolio_weights(historical_mean, annual_covariance, delta=historical_delta),
    "Sample mean": pok.bl_portfolio_weights(sample_excess_means, annual_covariance, delta=historical_delta),
})
print((historical_weights * 100).to_string(float_format=lambda x: f"{x:.3f}"))
print("\n<ECHARTS_DATA>" + json.dumps({
    "industryBLWeights": {
        "type": "bar", "title": "End-2009 Allocations: Same Risk Model and Constraints",
        "xAxis": {"type": "category", "data": industry_names}, "yAxisName": "Portfolio weight (%)",
        "series": {name: (historical_weights[name] * 100).tolist() for name in historical_weights},
    },
}, allow_nan=False))

The default Black–Litterman allocation is approximately 39.21% Food, 0% Steel, and 60.79% Finance, compared with reference weights of 14.43%, 2.87%, and 82.70%. The sample-mean comparator reaches the 100% Food boundary. These differences reflect the specified inputs and constraints, not a claim about the optimal holdings for every investor.

Hold these target weights fixed during 2010–2018, rebalancing monthly. This is not buy-and-hold quantities. No evaluation-period returns enter the covariance, reference weights, or sample-mean estimates.

python
evaluation_returns = held_out_returns @ historical_weights
evaluation_excess = evaluation_returns.sub(factor_returns.loc[evaluation_returns.index, "RF"], axis=0)
evaluation_statistics = pd.DataFrame({
    "CAGR (%)": pok.annualize_rets(evaluation_returns, 12) * 100,
    "Annualized volatility (%)": pok.annualize_vol(evaluation_returns, 12) * 100,
    "Sharpe": np.sqrt(12) * evaluation_excess.mean() / evaluation_excess.std(ddof=1),
    "Maximum drawdown (%)": evaluation_returns.apply(lambda r: -pok.drawdown(r)["Drawdown"].min() * 100),
})
print(evaluation_statistics.to_string(float_format=lambda x: f"{x:.3f}"))
evaluation_wealth = 100 * (1 + evaluation_returns).cumprod()
evaluation_wealth.loc[evaluation_returns.index[0] - 1] = 100.0
evaluation_wealth = evaluation_wealth.sort_index()
print("\n<ECHARTS_DATA>" + json.dumps({
    "dates": evaluation_wealth.index.astype(str).tolist(),
    "industryBLHoldout": {
        "title": "2010–2018: Frozen Target Weights, Monthly Rebalancing",
        "yAxisName": "Wealth per 100 initial units",
        "series": {name: evaluation_wealth[name].tolist() for name in evaluation_wealth},
    },
}, allow_nan=False))

This single holdout does not establish the superiority of Black–Litterman. The universe and views were chosen retrospectively, metadata timing is incomplete, and costs are omitted. Changing views after seeing this chart would be tuning to the evaluation sample. A serious comparison needs a defensible process for forming views, genuinely available inputs, and repeated out-of-sample evaluation.

Chapter Summary ​

  • Sample means can be imprecise even with years of observations. Mean shrinkage trades reliance on those estimates for explicit target assumptions; equal-return, equal-Sharpe, and factor-based targets encode different beliefs.
  • Reverse optimization produces implied excess means from a reference allocation, covariance, and risk aversion; it does not estimate future returns directly.
  • The pick matrix, view vector, and view-error covariance describe what is believed and how uncertain that information is. Horizons, units, and labels must agree.
  • The posterior update responds to the innovation Q−PΠ, with effects transmitted through covariance.
  • Uncertainty about expected returns M differs from conditional return covariance Σ and predictive covariance Σ+M.
  • Confidence conventions determine how tau matters. Under proportional Omega, tau cancels from the posterior mean but not its uncertainty.
  • Portfolio constraints and the risk matrix remain explicit choices after updating beliefs. Exact expected-return views still leave realized return risk.

For the original portfolio framework, see Fischer Black and Robert Litterman, “Global Portfolio Optimization,” Financial Analysts Journal, 48(5), 1992, pp. 28–43, doi:10.2469/faj.v48.n5.28.

Next, Robust Portfolio Construction compares capital allocation with risk allocation and examines how turnover, costs, and estimation uncertainty affect implementation.

Independent study notes and Python examples by Amine El Qazoui.