Skip to main content

Risk parity allocation

Problem: Equal-weight is naïve — high-vol assets dominate the realized portfolio risk. How do you actually equalize risk contribution?

Solution: Equal Risk Contribution (ERC) — the canonical risk-parity allocator.

The math

Given a covariance matrix Σ, find weights w such that each asset's risk contribution is equal:

RC_i = w_i × (Σw)_i = const ∀i

Subject to: sum(w) = 1, w > 0 (no shorts in standard ERC).

This is a nonlinear problem; standard solvers handle it in ~10ms for n < 100.

Using mts1b_quantkit.allocators.risk_parity

import pandas as pd
from mts1b_quantkit.allocators import risk_parity


# Historical returns: T rows × A columns
returns = pd.DataFrame({
"SPY": [...],
"TLT": [...],
"GLD": [...],
"VNQ": [...],
})

weights = risk_parity(returns)
# pd.Series indexed by symbol
print(weights)
# SPY 0.18
# TLT 0.34
# GLD 0.31
# VNQ 0.17

The high-vol asset (SPY) gets less weight; low-vol (TLT) gets more.

Verify risk contribution

import numpy as np

cov = returns.cov().values
w = weights.values

port_vol = np.sqrt(w @ cov @ w)
risk_contrib = w * (cov @ w) / port_vol

for sym, rc in zip(returns.columns, risk_contrib):
print(f" {sym}: RC = {rc:.4f}")

If risk_parity worked, all RCs are equal (within numerical tolerance).

Risk parity overlay on a factor

@register("f_momentum_risk_parity_overlay")
def f_momentum_risk_parity_overlay(panel, /, h_long=252, h_skip=21, n=10):
# Step 1: rank by momentum
raw = zscore_cross_sectional(panel.close[-h_skip-1] / panel.close[-h_long-h_skip-1] - 1)

# Step 2: pick top N
top_indices = np.argsort(raw)[-n:]

# Step 3: risk-parity weight the top N
returns_top = pd.DataFrame(
np.log(panel.close[1:, top_indices] / panel.close[:-1, top_indices]),
columns=[panel.symbols[i] for i in top_indices],
)
rp_weights = risk_parity(returns_top)

# Step 4: zero everything else; assign rp_weights to the top N
weights = np.zeros(len(panel.symbols))
for i, sym in zip(top_indices, returns_top.columns):
weights[i] = rp_weights[sym]

return weights[None, :] # (1, A) shape compatibility

Comparison: equal-weight vs risk-parity

# Equal-weight top 10
ew_weights = {sym: 0.10 for sym in top_10_symbols}

# Risk-parity top 10
rp_weights = risk_parity(returns[top_10_symbols])

# Realized vol comparison
ew_vol = np.sqrt(ew_w @ cov @ ew_w)
rp_vol = np.sqrt(rp_w @ cov @ rp_w)
print(f"Equal-weight vol: {ew_vol:.4f}")
print(f"Risk-parity vol: {rp_vol:.4f}")

ERC typically delivers ~20-30% lower realized vol than equal-weight, at similar return.

With vol-target overlay

Risk parity gives you the relative weights. Add a vol target to set the absolute exposure:

from mts1b_portfolio.sizers import vol_target_weights

rp_weights_array = rp_weights.values
final_weights = vol_target_weights(
base_weights=rp_weights_array,
asset_log_returns=hist_lr,
target_ann_vol=0.12,
window=63,
max_scale=3.0,
)

If portfolio realized vol is < 12% target, scale up to 12%. If > 12%, scale down.

Common mistakes

Using the full covariance matrix with too few observations

If T < 2A (observations < twice the universe size), the sample covariance is unstable. Use shrinkage:

from mts1b_quantkit.allocators import ledoit_wolf, risk_parity

cov_shrunk = ledoit_wolf(returns)
weights = risk_parity_from_cov(cov_shrunk, columns=returns.columns)

Asset universe that doesn't span risk dimensions

If your universe is 10 tech stocks (all correlated), risk parity gives you ~equal weights. The "diversification" is illusory because the risk factor (tech) is shared.

Better: choose a universe across distinct risk drivers (equities, bonds, gold, real estate, ...).

Forgetting the no-short constraint

Standard ERC assumes long-only. For long/short, use a generalization:

from mts1b_quantkit.allocators import risk_parity_long_short

weights = risk_parity_long_short(
returns,
long_universe=["AAPL", "MSFT", ...],
short_universe=["XYZ", "ABC", ...],
)

This solves a more complex optimization; convergence is slower and not guaranteed.

See also