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.
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
For independent one-year excess returns with 20% volatility, ten years of observations give a standard error of
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
With monthly observations,
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
Here
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:
. All assets receive the same expected premium, even if their risks differ. For a fully invested portfolio, , so maximizing mean–variance utility with this target reduces to minimizing variance. Equal expected returns do not imply equal weights. - Equal Sharpe ratios:
. Each asset receives the same assumed excess-return-to-volatility ratio . 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
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:
Hold covariance and risk aversion fixed at bl_portfolio_weights() solves this utility problem for any supplied excess means, including these non-Black–Litterman estimates.
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
For example, exposures of 0.8 and 0.4 to factors with expected premiums of 5% and 2%, assuming zero alpha, imply
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
First allow unrestricted risky positions with cash financing the remaining budget. The first-order condition is:
Reverse the problem: which expected excess returns make the reference allocation optimal? The answer is:
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:
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
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.
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
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
Rows of
- Absolute view: Equity's expected excess return is 8%. Use
and . - Relative view: Equity's expected return exceeds Bonds' by 8 percentage points. Use
and .
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:
For a zero-net-exposure relative view,
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:
Smaller entries in
The toolkit's default is the common heuristic:
For our single relative view,
Derive the Posterior Update
Assume view errors are independent of the prior mean uncertainty. When
The first term penalizes deviations from the prior; the second penalizes deviations from the views. More precise information receives more weight. Differentiating gives:
An equivalent update, which avoids explicitly inverting
The posterior covariance of the uncertain mean is:
The implementation solves the conditioning system rather than forming its inverse. Independent exact views can also be handled through this update with
Complete the Two-Asset Calculation
The prior relative mean is
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.
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
Keep the roles separate:
| Matrix | Meaning |
|---|---|
| Variability of future returns conditional on their mean | |
| Posterior uncertainty about the expected-return vector | |
| Posterior predictive covariance of future returns |
The second result returned by pok.black_litterman() is
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
Then
Here “confidence” is an update-weight parameter for this construction, not a probability that the view is true. At
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
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:
Choose
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
then restricting the result to
Exact Views Still Leave Return Risk
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.
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:
- Set confidence to zero with conditional covariance: the prior means and 60/40 reference allocation return.
- Increase confidence to one: the expected spread reaches the view, while realized return risk remains positive.
- Change tau: under this proportional Omega rule, the mean stays fixed but its uncertainty changes. Select predictive covariance to see the allocation effect.
- 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.
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.
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.
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
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.
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.
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.
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
, with effects transmitted through covariance. - Uncertainty about expected returns
differs from conditional return covariance and predictive covariance . - 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.
