Portfolio & Risk·Portfolio Construction·Intermediate

Risk Parity Portfolio

Build a risk parity portfolio construction methodology that equalizes the ex-ante risk contribution from each portfolio constituent rather than naively equal-weighting capital allocation, producing significantly more balanced and diversified portfolios less concentrated in the highest-volatility assets.

portfolio-theoryrisk-controlsrisk-management

Risk Parity Portfolio — Portfolio Construction

Category: Portfolio | Subcategory: Construction


What This Notebook Does

Risk Parity allocates capital so that each asset contributes equally to total portfolio risk — not equal dollar amounts.

  • Equal-weight portfolio: 50% to BTC, 50% to a low-vol bond → BTC dominates risk
  • Risk parity: reduces BTC weight, increases bond weight until both contribute 50% of risk
Risk Contribution of asset i = wᵢ × (Σw)ᵢ / σ_portfolio

Target: all risk contributions equal → RC_i = 1/N for all i

This notebook:

  1. Fetches data (Yahoo Finance or synthetic)
  2. Implements risk parity via convex optimisation
  3. Computes marginal and total risk contributions
  4. Compares risk parity vs equal-weight vs MVO
  5. Backtests and plots equity curves
  6. Exports weights
[1]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[2]
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.optimize import minimize
import warnings

warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
print('Imports ready.')
Imports ready.

Section 1 — Configuration

This section defines the configuration parameters for the notebook, including the data source, tickers, and date range. You can toggle between live data fetching from Yahoo Finance or using synthetic data for demonstration purposes.

[3]
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False  # Set True to fetch from Yahoo Finance (no API key needed)
TICKERS       = ['BTC-USD', 'ETH-USD', 'SOL-USD', 'BNB-USD', 'AVAX-USD']
START_DATE    = '2022-01-01'
END_DATE      = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS        = ['BTC', 'ETH', 'SOL', 'BNB', 'AVAX']
print('Config ready.')
Config ready.

Section 2 — Data

This section handles the data acquisition and preparation. Depending on the USE_LIVE_DATA flag, it either fetches historical stock prices from Yahoo Finance or generates synthetic return data. It then calculates the covariance matrix, which is crucial for risk parity optimization.

[4]
if USE_LIVE_DATA:
    import yfinance as yf
    prices  = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
    prices.columns = ASSETS
    prices.dropna(inplace=True)
    returns = prices.pct_change().dropna()
    print(f'Live data loaded: {len(returns)} trading days')
else:
    rng  = np.random.default_rng(42)
    n    = 730
    corr = np.array([[1,.85,.70,.75,.65],
                     [.85,1,.75,.70,.68],
                     [.70,.75,1,.65,.72],
                     [.75,.70,.65,1,.60],
                     [.65,.68,.72,.60,1]])
    vols = np.array([0.65,0.75,1.20,0.70,1.10]) / np.sqrt(252)
    cov  = np.outer(vols,vols)*corr
    L    = np.linalg.cholesky(cov)
    raw  = rng.standard_normal((n, len(ASSETS))) @ L.T
    mu   = np.array([0.50,0.45,0.80,0.35,0.70]) / 252
    data = raw + mu
    idx  = pd.date_range('2022-01-01', periods=n, freq='B')
    returns = pd.DataFrame(data, columns=ASSETS, index=idx)
    print(f'Synthetic data: {len(returns)} days')

cov_matrix = returns.cov().values
print('Annualised vol per asset:')
print((returns.std() * np.sqrt(252) * 100).round(1).to_string())
Synthetic data: 730 days
Annualised vol per asset:
BTC      65.3
ETH      77.5
SOL     116.6
BNB      72.9
AVAX    111.3

Section 3 — Risk Parity Optimisation

This section performs the core risk parity optimization. It uses scipy.optimize.minimize with the SLSQP method to find asset weights that equalize risk contributions, subject to the constraints that weights sum to one and are within specified bounds. The resulting risk parity weights and their contributions are then displayed.

[5]
def risk_contributions(weights: np.ndarray, cov: np.ndarray) -> np.ndarray:
    """
    Compute absolute risk contributions of each asset.

    RC_i = w_i * (Σw)_i / σ_portfolio
    """
    port_var = weights @ cov @ weights
    marginal = cov @ weights
    rc = weights * marginal / np.sqrt(port_var)
    return rc


def risk_parity_objective(weights: np.ndarray, cov: np.ndarray) -> float:
    """
    Minimise the sum of squared differences between risk contributions.
    At optimum all RC_i are equal.
    """
    rc = risk_contributions(weights, cov)
    target = rc.sum() / len(weights)
    return float(np.sum((rc - target) ** 2))


n_assets = len(ASSETS)
w0       = np.ones(n_assets) / n_assets
constraints = [{'type': 'eq', 'fun': lambda w: w.sum() - 1}]
bounds      = [(0.01, 0.9)] * n_assets

result = minimize(risk_parity_objective, w0, args=(cov_matrix,),
                  method='SLSQP', bounds=bounds, constraints=constraints,
                  options={'maxiter': 1000, 'ftol': 1e-10})
w_rp = result.x

rc = risk_contributions(w_rp, cov_matrix)
rc_pct = rc / rc.sum() * 100

print('Risk Parity Weights and Risk Contributions:')
df_rp = pd.DataFrame({'Asset': ASSETS,
                       'Weight %': (w_rp * 100).round(2),
                       'Risk Contrib %': rc_pct.round(2)})
print(df_rp.to_string(index=False))
Risk Parity Weights and Risk Contributions:
Asset  Weight %  Risk Contrib %
  BTC     25.07           19.94
  ETH     20.90           19.96
  SOL     14.52           20.00
  BNB     23.88           20.09
 AVAX     15.63           20.01

risk_contributions(weights: np.ndarray, cov: np.ndarray) -> np.ndarray

This function calculates the absolute risk contribution of each asset to the total portfolio risk. The formula used is RC_i = w_i * (Σw)_i / σ_portfolio, where w_i is the weight of asset i, (Σw)_i is the marginal risk contribution of asset i, and σ_portfolio is the portfolio standard deviation.

risk_parity_objective(weights: np.ndarray, cov: np.ndarray) -> float

This function defines the objective to be minimized for risk parity. It calculates the risk contributions for a given set of weights and then computes the sum of squared differences between each asset's risk contribution and the average risk contribution. Minimizing this function aims to make all risk contributions equal.

Section 4 — Comparison vs Equal Weight

This section compares the performance and risk characteristics of the risk parity portfolio against an equal-weight portfolio. It calculates risk contributions for both strategies and generates equity curves to visually compare their growth over time. The visualizations highlight how risk parity aims to balance risk across assets.

[6]
w_ew   = np.ones(n_assets) / n_assets
rc_ew  = risk_contributions(w_ew, cov_matrix)
rc_ew_pct = rc_ew / rc_ew.sum() * 100

port_rp  = (returns * w_rp).sum(axis=1)
port_ew  = (returns * w_ew).sum(axis=1)
equity_rp = (1 + port_rp).cumprod()
equity_ew = (1 + port_ew).cumprod()

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Risk Parity vs Equal Weight', fontsize=13, fontweight='bold')

ax1 = axes[0]
x = np.arange(n_assets)
ax1.bar(x - 0.2, rc_ew_pct, 0.4, label='Equal Weight', color='#9e9e9e', alpha=0.8)
ax1.bar(x + 0.2, rc_pct, 0.4, label='Risk Parity', color='#1976d2', alpha=0.8)
ax1.axhline(100/n_assets, color='red', ls='--', lw=1, label=f'Target {100/n_assets:.1f}%')
ax1.set_xticks(x); ax1.set_xticklabels(ASSETS)
ax1.set_ylabel('Risk Contribution (%)')
ax1.legend(fontsize=8); ax1.set_title('Risk Contributions')

ax2 = axes[1]
ax2.plot(equity_rp.index, equity_rp, label='Risk Parity', color='#1976d2', lw=1.5)
ax2.plot(equity_ew.index, equity_ew, label='Equal Weight', color='#e53935', lw=1.5, ls='--')
ax2.set_ylabel('Growth of $1'); ax2.legend(fontsize=8)
ax2.set_title('Equity Curve')

plt.tight_layout(); plt.show()
cell output

Section 5 — Export

This final section is responsible for exporting the calculated risk parity weights. The weights are saved to a CSV file named risk_parity_portfolio.csv, allowing for easy integration with other tools or future analysis.

[7]
df_rp.to_csv('risk_parity_portfolio.csv', index=False)
print('Saved: risk_parity_portfolio.csv')
Saved: risk_parity_portfolio.csv

Conclusion

This notebook successfully demonstrated the construction and comparison of a risk parity portfolio. By minimizing the differences in risk contributions, the risk parity strategy aims to create a more balanced portfolio from a risk perspective, potentially leading to more stable returns compared to an equal-weight approach, especially in portfolios with highly volatile assets.