Robust Portfolio Construction: Diversification and Risk Budgets
Covariance estimation addressed noisy risk inputs, and Black–Litterman combined reference-implied means with investor views. We now examine portfolio rules that rely less directly on expected-return forecasts: equal weighting, capitalization weighting, minimum variance, and risk budgeting.
Here robustness means how an allocation responds to uncertain inputs and implementation choices. We are not solving a formal worst-case optimization over an uncertainty set. A rule that avoids expected means can still be sensitive to estimated volatilities, correlations, the investment universe, and constraints.
We will answer three questions: how much capital is concentrated, which assets contribute to modeled risk, and how much trading is needed to maintain an allocation. The chapter progresses from small calculations to a monthly industry comparison with explicit estimation timing, drifting holdings, and transaction costs.
Run examples in order. The opening covariance inputs are hypothetical annual quantities; historical returns later in the chapter are monthly decimals. The interactive experiment can also run independently because its helper contains the two-asset inputs.
import json
import numpy as np
import pandas as pd
import PortfolioOptimizationKit as pok Capital Concentration Is Not Risk Diversification
For nonnegative portfolio weights summing to one, the weight Herfindahl index is
An equally weighted
capital_allocations = pd.DataFrame({"Equal weights": [0.25] * 4,
"Concentrated": [0.70, 0.10, 0.10, 0.10]},
index=["A", "B", "C", "D"])
print(capital_allocations)
print("Effective number of holdings:")
print(capital_allocations.apply(pok.enc).to_string(float_format=lambda x: f"{x:.3f}")) This measure does not use covariance. Four equally weighted investments can still share one dominant economic exposure. The toolkit's enc() requires nonnegative weights summing to one; applying the same count interpretation to signed or leveraged positions is inappropriate.
Attribute Portfolio Volatility with Euler's Theorem
For covariance
Assume positive portfolio variance. Differentiating with respect to one exposure gives its marginal contribution to volatility:
Volatility is homogeneous of degree one for positive exposure scaling. Euler's theorem therefore decomposes it into component contributions:
Dividing by total volatility gives the relative risk share:
Component volatility has the units of returns; relative risk shares are dimensionless. The corresponding component variance
Marginal volatility holds the other exposures fixed. A financed reallocation from asset
Worked Example: Equal Capital, Unequal Risk
Let Equity have volatility 20%, Bonds 10%, and correlation 0.25. Their covariance is 0.005. At 50/50 weights:
Portfolio variance is 0.015 and volatility is approximately 12.247%. The component variances are 0.01125 and 0.00375, so Equity supplies 75% of modeled risk despite receiving only half the capital.
asset_names = pd.Index(["Equity", "Bonds"])
annual_covariance = pd.DataFrame([[0.04, 0.005], [0.005, 0.01]], index=asset_names, columns=asset_names)
equal_weights = pd.Series([0.5, 0.5], index=asset_names)
equal_report = pok.risk_contribution_report(equal_weights, annual_covariance)
equal_volatility = pok.portfolio_volatility(equal_weights, annual_covariance)
print((equal_report * 100).rename(columns={
"Weight": "Capital (%)", "Marginal volatility": "Marginal vol (percentage points per unit weight)",
"Component volatility": "Component vol (percentage points)", "Relative risk": "Risk share (%)",
}).to_string(float_format=lambda x: f"{x:.3f}"))
print(f"Portfolio volatility: {equal_volatility:.3%}")
print(f"Sum of component volatility: {equal_report['Component volatility'].sum():.3%}") The component volatilities are approximately 9.186 and 3.062 percentage points, adding to 12.247%. portfolio_risk_contributions() returns the relative shares, while risk_contribution_report() exposes all four quantities with asset labels.
Negative Contributions Can Represent Hedging
Even a long-only position can contribute negatively to portfolio risk when its covariance with the portfolio is negative. That is an attribution result, not a negative standalone variance.
hedge_covariance = pd.DataFrame([[0.04, -0.018], [-0.018, 0.01]], index=asset_names, columns=asset_names)
hedge_weights = pd.Series([0.9, 0.1], index=asset_names)
hedge_report = pok.risk_contribution_report(hedge_weights, hedge_covariance)
print((hedge_report[["Weight", "Relative risk"]] * 100).rename(columns={
"Weight": "Capital (%)", "Relative risk": "Risk share (%)",
}).to_string(float_format=lambda x: f"{x:.3f}"))
print(f"Relative contributions sum to: {hedge_report['Relative risk'].sum():.6f}") Here Equity's risk share is about 105.195% and Bonds' share is −5.195%, adding to 100%. A small increase in the bond exposure, holding equity exposure fixed, reduces portfolio volatility. At exactly zero portfolio variance, marginal and relative volatility contributions are undefined; the helper reports that instead of producing misleading divisions by zero.
Risk-Share Concentration Is Not a Count of Independent Bets
When all relative contributions are nonnegative, their inverse Herfindahl is:
The toolkit retains the historical name encb() for this quantity. We interpret it as an effective number of risk contributors in the chosen asset representation, not as a count of statistically independent factors. It is unavailable as a count when contributions are signed.
Consider four identical exposures with 20% volatility and perfect correlation. Equal weights give four effective holdings and four equal risk shares, but the covariance matrix has rank one: all four exposures move with the same factor.
one_factor_covariance = pd.DataFrame(0.04, index=list("ABCD"), columns=list("ABCD"))
one_factor_weights = pd.Series(0.25, index=one_factor_covariance.index)
one_factor_shares = pok.portfolio_risk_contributions(one_factor_weights, one_factor_covariance)
print(f"Effective holdings: {pok.enc(one_factor_weights):.1f}")
print(f"Effective risk contributors: {pok.encb(one_factor_shares):.1f}")
print(f"Covariance rank: {np.linalg.matrix_rank(one_factor_covariance)}")
print(f"Portfolio volatility: {pok.portfolio_volatility(one_factor_weights, one_factor_covariance):.1%}") Splitting one exposure into multiple identical labels can improve an asset-level concentration statistic without adding economic diversification. Factor-based diversification requires a specified factor representation, as discussed in the previous chapters.
Equal Risk Contribution and General Risk Budgets
Choose positive desired risk shares
Equal risk contribution (ERC), commonly called asset-level risk parity, uses
When Inverse Volatility Works
For diagonal covariance,
For equal budgets, this reduces to inverse-volatility weighting. In a two-asset portfolio, equalizing the component variances cancels the common covariance term:
Thus the two-asset ERC weights are inverse volatility for any nondegenerate correlation. At volatilities 20% and 10%, they are one-third Equity and two-thirds Bonds.
inverse_volatility_weights = 1 / np.sqrt(np.diag(annual_covariance))
inverse_volatility_weights /= inverse_volatility_weights.sum()
erc_weights = pok.risk_parity_weigths(annual_covariance)
erc_shares = pok.portfolio_risk_contributions(erc_weights, annual_covariance)
print(pd.DataFrame({"Inverse vol weight (%)": inverse_volatility_weights * 100,
"ERC weight (%)": erc_weights * 100, "ERC risk share (%)": erc_shares * 100}).to_string()) For three or more assets with unequal correlation patterns, inverse volatility generally does not equalize risk. Use volatilities 10%, 20%, and 30% with correlations 0.6, 0.1, and 0.2. Inverse-volatility weights equalize
three_assets = pd.Index(["A", "B", "C"])
three_volatilities = np.array([0.10, 0.20, 0.30])
three_correlations = np.array([[1.0, 0.6, 0.1], [0.6, 1.0, 0.2], [0.1, 0.2, 1.0]])
three_covariance = pd.DataFrame(three_correlations * np.outer(three_volatilities, three_volatilities),
index=three_assets, columns=three_assets)
three_inverse = pd.Series(1 / three_volatilities, index=three_assets)
three_inverse /= three_inverse.sum()
three_erc = pok.risk_parity_weigths(three_covariance)
print(pd.DataFrame({"Inverse vol weights (%)": three_inverse * 100,
"Inverse vol risk shares (%)": pok.portfolio_risk_contributions(three_inverse, three_covariance) * 100,
"ERC weights (%)": three_erc * 100,
"ERC risk shares (%)": pok.portfolio_risk_contributions(three_erc, three_covariance) * 100})
.to_string(float_format=lambda x: f"{x:.3f}")) The inverse-volatility risk shares are approximately 35.417%, 37.500%, and 27.083%; the ERC solution targets one-third each. Equal correlation is another special case in which inverse volatility yields equal risk contributions, but it is a modeling restriction, not a general property of markets.
A Convex Risk-Budget Construction
For positive-definite covariance and strictly positive budgets, solve for positive unnormalized exposures
The first-order conditions give
The quadratic term penalizes total risk; the negative-log terms keep every positive-budget asset invested. The intermediate
The helper checks the achieved budgets after solving. This implementation requires numerically positive-definite covariance; regularize a singular estimate or remove redundant assets before fitting. Zero-budget assets should be excluded from the optimization universe. Negative hedge contributions can be analyzed, but are not targets of this positive risk-budget construction.
desired_budgets = pd.Series([0.20, 0.30, 0.50], index=three_assets)
budget_weights = pd.Series(pok.portfolio_risk_contrib_optimizer(desired_budgets, three_covariance), index=three_assets)
achieved_budgets = pok.portfolio_risk_contributions(budget_weights, three_covariance)
print(pd.DataFrame({"Requested risk (%)": desired_budgets * 100,
"Capital weight (%)": budget_weights * 100,
"Achieved risk (%)": achieved_budgets * 100}).to_string(float_format=lambda x: f"{x:.4f}")) Try it: change the desired budgets to equal thirds. Then increase Asset C's budget while keeping all budgets positive and their sum equal to one. More capital is not necessarily required in proportion to the risk budget: volatility and correlation also enter the solution.
Interactive Capital and Risk Allocation
Compare equal weighting, fully invested long-only GMV, and a requested risk-budget allocation. All use the same hypothetical annual covariance. For the 50%/50% budget, the final portfolio is ERC; changing the budget makes it an unequal risk-budget portfolio.
import ipywidgets as widgets
import PortfolioOptimizationKit as pok
widget_instance = widgets.interactive(
pok.show_risk_parity_echart,
vol_equity=widgets.FloatSlider(value=0.20, min=0.05, max=0.50, step=0.01, description="Equity vol"),
vol_bond=widgets.FloatSlider(value=0.10, min=0.05, max=0.30, step=0.01, description="Bond vol"),
correlation=widgets.FloatSlider(value=0.25, min=-0.90, max=0.90, step=0.05, description="Correlation"),
equity_budget=widgets.FloatSlider(value=0.50, min=0.10, max=0.90, step=0.05, description="Equity risk share"),
)
widget_instance At the defaults, EW allocates risk 75%/25%; GMV holds 12.5% Equity and 87.5% Bonds; ERC holds one-third/two-thirds and allocates risk equally. Move correlation below zero: EW may show a negative bond contribution. That is a valid hedge contribution, while the solved positive risk-budget portfolio still meets its specified positive shares.
Try it: leave both volatilities unchanged and move correlation to −0.90. In the risk chart, EW's bond bar falls below zero and its equity bar rises above 100%; the two still sum to 100%. Then change the Equity risk share to 0.10. The risk-budget bars should read 10%/90%, while the capital bars generally show different percentages. Slider values are decimals: 0.20 volatility means 20%, and 0.10 risk share means 10%.
For two-asset ERC, changing correlation alone leaves weights unchanged but changes volatility. For unequal budgets, correlation generally affects weights as well. The experiment isolates these relationships without estimating a historical covariance matrix.
Historical Comparison on One Investment Universe
Data, Estimation Windows, and Baselines
Use the 30 value-weighted industry portfolios from January 1990 through December 2018. Estimate covariance from the previous 60 months, then allocate for the next month. All strategies are long-only, fully invested, and evaluated over the same 288 months: January 1995–December 2018.
The strategies are EW, lagged capitalization weighting (CW), sample-covariance GMV, sample-covariance ERC, and ERC with 50% constant-correlation shrinkage. This is allocation across industries, not equal weighting of the individual stocks within each industry.
The shrinkage estimate puts equal weight on the sample covariance and the constant-correlation target from Chapter 2.2. The target retains the sample variances and replaces pairwise correlations with their average.
industry_returns = pok.get_ind_file(filetype="rets", nind=30, ew=False).loc["1990":"2018"]
industry_cap_weights = pok.get_ind_market_caps(nind=30, weights=True)
cash_returns = pok.get_fff_returns()["RF"]
window_months = 60
evaluation_dates = industry_returns.index[window_months:]
print(f"Training starts: {industry_returns.index[0]}; first training end: {industry_returns.index[window_months-1]}")
print(f"Evaluation: {evaluation_dates[0]} to {evaluation_dates[-1]}, {len(evaluation_dates)} months") Capitalization fractions come from firm counts times average firm sizes. CW uses the final training month's weights, not the evaluated month's weights. The historical files do not establish when all metadata were published, so this is a retrospective comparison rather than a fully point-in-time investability claim.
Retain the Allocation History
Keep target weights as well as returns so that we can examine concentration, drift, and turnover. Each training slice ends just before the month in which its allocation is used. No expected-return estimate enters GMV or ERC.
weight_rules = {
"EW": pok.weight_ew,
"CW": lambda history: pok.weight_cw(history, cap_ws=industry_cap_weights),
"GMV": pok.weight_minvar,
"ERC": pok.weight_rp,
"ERC shrinkage": lambda history: pok.weight_rp(history, cov_estimator=pok.shrinkage_cov, delta=0.5),
}
weight_history = {}
for name, rule in weight_rules.items():
allocations = [rule(industry_returns.iloc[t-window_months:t]) for t in range(window_months, len(industry_returns))]
weight_history[name] = pd.DataFrame(allocations, index=evaluation_dates, columns=industry_returns.columns)
evaluation_assets = industry_returns.loc[evaluation_dates]
gross_returns = pd.DataFrame({name: (weights * evaluation_assets).sum(axis=1) for name, weights in weight_history.items()})
print(gross_returns.head().to_string(float_format=lambda x: f"{x:.4%}")) These are gross returns before trading costs. Re-estimating covariance each month changes target weights, while asset returns also make previously held weights drift. Both effects matter when a strategy is implemented.
The next table reports compound annual growth (CAGR), monthly standard deviation scaled by
gross_excess = gross_returns.sub(cash_returns.loc[evaluation_dates], axis=0)
gross_statistics = pd.DataFrame({
"CAGR (%)": pok.annualize_rets(gross_returns, 12) * 100,
"Annualized volatility (%)": pok.annualize_vol(gross_returns, 12) * 100,
"Sharpe": np.sqrt(12) * gross_excess.mean() / gross_excess.std(ddof=1),
"Maximum drawdown (%)": gross_returns.apply(lambda r: -pok.drawdown(r)["Drawdown"].min() * 100),
})
print(gross_statistics.to_string(float_format=lambda x: f"{x:.3f}"))
gross_wealth = 100 * (1 + gross_returns).cumprod()
gross_wealth.loc[evaluation_dates[0] - 1] = 100.0
gross_wealth = gross_wealth.sort_index()
print("\n<ECHARTS_DATA>" + json.dumps({
"dates": gross_wealth.index.astype(str).tolist(),
"robustGrossWealth": {
"title": "Common Evaluation Sample: Gross Portfolio Wealth",
"yAxis": {"type": "log", "name": "Wealth per 100 initial units"},
"series": {name: gross_wealth[name].tolist() for name in gross_wealth},
},
}, allow_nan=False)) Compare these outcomes against each strategy's objective. GMV minimizes estimated variance; ERC targets estimated risk shares; neither maximizes realized CAGR. A higher historical Sharpe does not establish stability across other samples or after implementation costs.
For this sample, annualized realized volatility is approximately 15.47% for EW, 14.92% for CW, 11.63% for GMV, 14.07% for ERC, and 14.25% for ERC shrinkage. GMV has the lowest realized volatility, while ERC's gross CAGR is about 10.12%. Those are different outcomes, not interchangeable definitions of “best.”
Inspect One Decision's Capital and Risk Shares
For January 2009, the 60-month training window ends in December 2008. Compare all allocations using the same sample covariance when reporting their risk shares. ERC shrinkage meets equal budgets under its own shrunk estimate; it need not be equal-risk when assessed under the sample matrix.
decision_month = pd.Period("2009-01", freq="M")
decision_training = industry_returns.loc[:decision_month-1].iloc[-window_months:]
decision_covariance = pok.sample_cov(decision_training)
decision_weights = pd.DataFrame({name: weights.loc[decision_month] for name, weights in weight_history.items()})
decision_shares = decision_weights.apply(lambda weights: pok.portfolio_risk_contributions(weights, decision_covariance))
print("Capital allocation (%):")
print((decision_weights * 100).to_string(float_format=lambda x: f"{x:.2f}"))
print("Risk shares under the common sample covariance (%):")
print((decision_shares * 100).to_string(float_format=lambda x: f"{x:.2f}"))
selected_industries = decision_weights.max(axis=1).nlargest(8).index
print("\n<ECHARTS_DATA>" + json.dumps({
"snapshotWeights": {
"type": "bar", "title": "January 2009: Eight Industries Ranked by Largest Strategy Weight",
"xAxis": {"type": "category", "data": selected_industries.tolist()}, "yAxisName": "Portfolio weight (%)",
"series": {name: (decision_weights.loc[selected_industries, name] * 100).tolist() for name in decision_weights},
},
"snapshotRisk": {
"type": "bar", "title": "Same Industries: Risk Shares under Sample Covariance",
"xAxis": {"type": "category", "data": selected_industries.tolist()}, "yAxisName": "Relative volatility contribution (%)",
"series": {name: (decision_shares.loc[selected_industries, name] * 100).tolist() for name in decision_shares},
},
}, allow_nan=False)) The tables contain all 30 industries. To select eight for the charts, take each industry's largest capital weight across the five strategies, then rank those maxima. Both charts use that same selection and order. The bars retain their shares of the whole portfolio; they are not renormalized to the selected subset, so the eight displayed shares need not sum to 100%.
Equal estimated risk shares are a statement about the allocation date's model, not a guarantee of equal realized contributions over the following year.
How Sensitive Are the Allocations to the Sample?
Resample complete monthly return rows from the first training window, preserving contemporaneous cross-asset relationships. Re-estimate the same 50% shrinkage covariance for both GMV and ERC. This isolates how the two allocation rules respond to one form of estimation uncertainty.
The illustrative bootstrap treats months as independent and identically distributed (iid). It does not preserve serial dependence or establish confidence intervals for a changing future regime. Each repetition gives GMV and ERC the same resampled rows; the fixed seed makes the experiment reproducible.
first_training = industry_returns.iloc[:window_months]
bootstrap_rng = np.random.default_rng(42)
bootstrap_allocations = {"GMV": [], "ERC": []}
for repetition in range(40):
sampled_rows = bootstrap_rng.integers(0, window_months, size=window_months)
resampled_returns = first_training.iloc[sampled_rows].reset_index(drop=True)
bootstrap_allocations["GMV"].append(pok.weight_minvar(resampled_returns, cov_estimator=pok.shrinkage_cov, delta=0.5))
bootstrap_allocations["ERC"].append(pok.weight_rp(resampled_returns, cov_estimator=pok.shrinkage_cov, delta=0.5))
allocation_sensitivity = pd.DataFrame({name: pd.DataFrame(rows).std(ddof=1) * 100 for name, rows in bootstrap_allocations.items()})
print("Weight standard deviation across resamples (percentage points):")
print(allocation_sensitivity.to_string(float_format=lambda x: f"{x:.3f}"))
sensitive_industries = allocation_sensitivity.max(axis=1).nlargest(8).index
print("\n<ECHARTS_DATA>" + json.dumps({
"allocationSensitivity": {
"type": "bar", "title": "Sampling Sensitivity: Same Resamples and Covariance Estimator",
"xAxis": {"type": "category", "data": sensitive_industries.tolist()},
"yAxisName": "Weight standard deviation (percentage points)",
"series": {name: allocation_sensitivity.loc[sensitive_industries, name].tolist() for name in allocation_sensitivity},
},
}, allow_nan=False)) The chart selects the eight industries with the largest weight standard deviation under either method. A value of 2 means 2 percentage points of weight variability across the 40 resamples, not 2% of the average weight. These are in-sample allocation sensitivities, not realized return volatilities.
Weight stability is useful but not sufficient: a stable rule can consistently target the wrong risk structure. Interpret this experiment alongside its covariance assumptions, the historical evaluation, and economic exposure analysis rather than declaring one rule universally robust.
Turnover and Transaction Costs
Drift Before the Next Rebalance
Let
Compare the new target with these drifted weights, not just with the preceding target. One-way turnover, before accounting for fee financing, is:
Even unchanged equal-weight targets require trading after unequal asset returns. Suppose a 50/50 portfolio earns 10% on A and −10% on B. Portfolio wealth is unchanged, but weights drift to 55%/45%; restoring 50/50 requires 5% one-way turnover.
previous_target = np.array([0.5, 0.5])
realized_asset_returns = np.array([0.10, -0.10])
drifted_weights = previous_target * (1 + realized_asset_returns) / (1 + previous_target @ realized_asset_returns)
next_target = np.array([0.5, 0.5])
worked_turnover = np.abs(next_target - drifted_weights).sum() / 2
print("Drifted weights:", drifted_weights)
print(f"One-way turnover despite unchanged target: {worked_turnover:.1%}") For the historical comparison, assume each account is already invested in its first target at the start of evaluation. We exclude initial funding costs equally across strategies, and charge subsequent rebalancing costs.
pretrade_weights = {}
turnover = pd.DataFrame(index=evaluation_dates)
for name, targets in weight_history.items():
drifted = targets.shift(1) * (1 + evaluation_assets.shift(1))
drifted = drifted.div(1 + gross_returns[name].shift(1), axis=0)
drifted.iloc[0] = targets.iloc[0]
pretrade_weights[name] = drifted
turnover[name] = (targets - drifted).abs().sum(axis=1) / 2
print("Mean monthly one-way turnover (%):")
print((turnover.mean() * 100).to_string(float_format=lambda x: f"{x:.3f}"))
print("\n<ECHARTS_DATA>" + json.dumps({
"turnoverComparison": {
"type": "bar", "title": "Trading Required after Asset-Weight Drift",
"xAxis": {"type": "category", "data": turnover.columns.tolist(), "axisLabel": {"width": 65, "overflow": "break", "lineHeight": 14}},
"yAxisName": "Mean monthly one-way turnover (%)",
"series": {"Turnover": (turnover.mean() * 100).tolist()},
},
}, allow_nan=False)) The default mean monthly one-way turnover is about 1.58% for EW, 2.10% for CW, 7.32% for GMV, 1.80% for ERC, and 1.63% for ERC shrinkage. These implied industry-capitalization targets can change with constituents and metadata; their turnover is not an estimate of the turnover of a particular capitalization-weighted ETF.
Pay Fees from the Portfolio
Suppose the cost rate is
This accounts for the sale of assets needed to pay fees. For long-only fully invested weights and
The usual approximation
Start with
cost_bps = 10.0
fee_rate = cost_bps / 10000
if not np.isfinite(fee_rate) or not 0 <= fee_rate < 1:
raise ValueError("Use a finite cost rate between 0 and 10,000 basis points, excluding 10,000")
after_cost_fractions = pd.DataFrame(index=evaluation_dates)
net_returns = pd.DataFrame(index=evaluation_dates)
for name, targets in weight_history.items():
remaining_fraction = pd.Series(1.0, index=evaluation_dates)
for iteration in range(100):
traded_fraction = targets.mul(remaining_fraction, axis=0).sub(pretrade_weights[name]).abs().sum(axis=1)
updated_fraction = 1 - fee_rate * traded_fraction
difference = (updated_fraction - remaining_fraction).abs().max()
remaining_fraction = updated_fraction
if difference <= 1e-12:
break
else:
raise RuntimeError("Fee calculation did not converge; increase the iteration limit")
after_cost_fractions[name] = remaining_fraction
net_returns[name] = remaining_fraction * (1 + gross_returns[name]) - 1
cost_comparison = pd.DataFrame({
"Gross CAGR (%)": pok.annualize_rets(gross_returns, 12) * 100,
"Net CAGR (%)": pok.annualize_rets(net_returns, 12) * 100,
"Mean monthly turnover (%)": turnover.mean() * 100,
})
print(f"Illustrative cost: {cost_bps:g} bp per dollar bought or sold; initial funding cost excluded")
print(cost_comparison.to_string(float_format=lambda x: f"{x:.3f}"))
net_wealth = 100 * (1 + net_returns).cumprod()
net_wealth.loc[evaluation_dates[0] - 1] = 100.0
net_wealth = net_wealth.sort_index()
print("\n<ECHARTS_DATA>" + json.dumps({
"dates": net_wealth.index.astype(str).tolist(),
"robustNetWealth": {
"title": "Portfolio Wealth after Self-Financed Rebalancing Costs",
"yAxis": {"type": "log", "name": "Wealth per 100 initial units"},
"series": {name: net_wealth[name].tolist() for name in net_wealth},
},
}, allow_nan=False)) The fixed 10 bp rate is an assumption, not an estimate of trading these industry portfolios. Real costs vary with instruments, spread, market impact, size, and liquidity. No external capital pays the modeled fees.
Try it: use 0, 10, and 50 basis points and rerun this cell. Zero costs should recover gross returns to numerical precision; higher nonnegative costs should reduce terminal wealth for each fixed strategy. Keep the targets and asset returns fixed so the experiment isolates implementation costs.
Scaling Risk Parity Is a Separate Financing Decision
An unlevered risk-budget portfolio can have low volatility because it puts more capital in low-volatility assets. To reach a chosen modeled risk level, investors sometimes scale the risky portfolio and borrow or lend cash. With risky fraction
Positive scaling preserves relative risky-asset contributions, but
requested_volatility = 0.20
erc_volatility = pok.portfolio_volatility(erc_weights, annual_covariance)
risky_fraction = requested_volatility / erc_volatility
scaled_exposures = risky_fraction * erc_weights
cash_weight = 1 - risky_fraction
print(f"Unlevered ERC volatility: {erc_volatility:.3%}")
print(f"Risky exposure relative to account net asset value: {risky_fraction:.3f}")
print("Scaled risky exposures (% of account net asset value):")
print((scaled_exposures * 100).to_string(float_format=lambda x: f"{x:.3f}"))
print(f"Cash allocation: {cash_weight:.3%}")
print("Relative risky-asset contributions:")
print(pok.portfolio_risk_contributions(scaled_exposures, annual_covariance).to_string()) For the hypothetical inputs, ERC volatility is about 10.541%, so a 20% modeled target requires roughly 1.897 times risky exposure, financed with about 89.7% cash borrowing relative to account net asset value. For every 100 units of the investor's capital, this means approximately 63.25 in Equity, 126.49 in Bonds, and −89.74 in cash. The historical strategy comparison above uses no such leverage. Mixing levered and unlevered strategies without accounting for financing would answer a different question.
Chapter Summary
- Capital concentration, risk-share concentration, and economic diversification are distinct. Many names do not necessarily mean many independent sources of risk.
- Euler attribution separates marginal volatility, component volatility, and relative risk shares. Hedge contributions can be negative; zero-risk portfolios require separate treatment.
- Inverse volatility is an ERC solution in special cases, not a general substitute for the covariance-aware calculation.
- Positive risk budgets can be solved through a convex formulation and must be checked after numerical optimization. They describe the supplied risk model, not guaranteed realized contributions.
- Compare allocation methods on common assets, constraints, information dates, and evaluation periods. Examine sampling sensitivity as well as realized outcomes.
- Drift, turnover, self-financed costs, and any leverage are part of the investment problem. A strategy that looks attractive before those effects may be less attractive to implement.
Apply the Ideas
Continue to Comparing Portfolio Decisions: A Capstone to combine covariance estimation, Black–Litterman, risk budgets, and self-financed costs on one three-industry investment universe.
