Welcome

This website presents an empirical study of the Risk Parity Portfolio and compares it with traditional portfolio construction methods.

The central research question is:

Does a Risk Parity Portfolio improve portfolio risk allocation and out-of-sample performance compared with traditional portfolio construction methods, and which computational approach is most suitable for its implementation?

The analysis proceeds in four stages:

  • portfolio construction and risk allocation,
  • comparison of Naive Risk Parity and Risk Parity,
  • computational implementation of the convex Risk Parity formulation,
  • out-of-sample walk-forward backtesting.

Chapter 1 - Portfolio Construction and Risk Allocation

This chapter introduces three portfolio-construction approaches: the Equal Weight Portfolio, the Global Minimum Variance Portfolio, and the Risk Parity Portfolio. Although all three methods allocate capital across the same assets, they pursue different objectives.

The Equal Weight Portfolio distributes capital equally, the Global Minimum Variance Portfolio minimizes total portfolio volatility, and the Risk Parity Portfolio distributes portfolio risk equally across assets. The comparison therefore highlights the difference between capital diversification, variance minimization, and risk diversification.

Objective of Chapter 1

To examine how the Equal Weight, Global Minimum Variance, and Risk Parity portfolios differ in terms of portfolio weights, relative risk contributions, and total portfolio volatility.

Software Environment

The empirical analysis is implemented in R. The following packages are used throughout the project.

Package Purpose in the analysis
dplyr Data manipulation and creation of summary tables
tidyr Restructuring data into long format
ggplot2 Visualization of portfolio results
tidyverse Collection of packages for data handling and visualization
reshape2 Transformation of data for grouped bar charts
patchwork Combination of multiple plots
riskParityPortfolio Specialized Risk Parity optimization in Chapters 3 and 4
microbenchmark Runtime comparisons in Chapter 3
zoo Rolling-window calculations in Chapter 4
scales Percentage formatting in figures

Although some packages are not required directly in Chapter 1, they are listed here to document the complete software environment used in the empirical study.

Data and Investment Universe

The first empirical analysis uses the course dataset log_returns_high5_120m. It contains 120 monthly observations for 5 stocks:

Apple, Amazon, Home Depot, Coca-Cola, and Walmart.

The covariance matrix of stock returns is estimated using the course function cov.calc(). The same estimated covariance matrix is used for the construction and comparison of all three portfolios.

Dataset characteristic Value
Number of monthly observations 120
Number of assets 5
Assets Apple, Amazon, Home Depot, Coca-Cola, Walmart

Equal Weight Portfolio

The Equal Weight Portfolio assigns the same proportion of capital to each asset. For a portfolio containing \(N\) assets, each weight is

\[ w_i^{EWP} = \frac{1}{N}, \qquad i=1,\ldots,N. \]

Because the investment universe contains five stocks, each asset receives a weight of 20%.

# Equal Weight Portfolio
w_ewp <- port_naive.calc(stock_returns)
Asset Ticker Portfolio weight
Apple AAPL.OQ 20.00%
Amazon AMZN.OQ 20.00%
Home Depot HD.N 20.00%
Coca-Cola KO.N 20.00%
Walmart WMT.N 20.00%

The main advantage of the Equal Weight Portfolio is its simplicity. It does not require estimates of expected returns, volatilities, or correlations. However, equal capital weights do not imply that each asset contributes equally to total portfolio risk.

Global Minimum Variance Portfolio

The Global Minimum Variance Portfolio selects the portfolio weights that minimize total portfolio variance. Its optimization problem can be written as

\[ \min_{\mathbf{w}} \quad \mathbf{w}^{\prime}\boldsymbol{\Sigma}\mathbf{w} \]

subject to

\[ \mathbf{1}^{\prime}\mathbf{w}=1, \]

where \(\mathbf{w}\) denotes the vector of portfolio weights and \(\boldsymbol{\Sigma}\) is the covariance matrix of asset returns.

The GMVP used in this section is the classical unconstrained solution. The weights are required to sum to one, but they are not restricted to be non-negative. Consequently, short positions are permitted if negative weights help reduce total portfolio variance.

This differs from the Risk Parity Portfolio introduced later in the chapter, which is explicitly constructed under long-only constraints,

\[ 0 \leq w_i \leq 1. \]

It is important to distinguish between the constraints permitted by the model and the weights observed in this particular sample. Even if all estimated GMVP weights happen to be positive, the analytical GMVP formulation itself still allows short selling.

# Classical unconstrained Global Minimum Variance Portfolio
#
# The weights must sum to one, but no non-negativity constraint
# is imposed. Short selling is therefore permitted by the model.
w_gmv <- port_gmv.calc(
  stock_returns,
  cov.calc
)
Asset Ticker Portfolio weight
Apple AAPL.OQ 1.43%
Amazon AMZN.OQ 7.83%
Home Depot HD.N 10.66%
Coca-Cola KO.N 48.13%
Walmart WMT.N 31.95%

Unlike the Equal Weight Portfolio, the GMV Portfolio explicitly incorporates asset volatilities and correlations. It can therefore reduce total portfolio volatility by allocating more capital to assets that provide favorable diversification characteristics.

However, minimizing total variance does not guarantee that portfolio risk is well distributed across assets. A portfolio can achieve low total volatility while remaining strongly dependent on only one or two major risk contributors.

Long-Only Global Minimum Variance Portfolio

For a more consistent comparison with Risk Parity, a second GMVP is constructed under long-only constraints:

\[ \min_{\mathbf{w}} \quad \mathbf{w}^{\top}\boldsymbol{\Sigma}\mathbf{w} \]

subject to

\[ \mathbf{1}^{\top}\mathbf{w}=1, \qquad w_i\geq0. \]

The non-negativity constraint prevents short selling. This makes the investment constraints directly comparable with the long-only Risk Parity Portfolio.

# Long-only Global Minimum Variance Portfolio
#
# lower_bound = 0 prevents negative portfolio weights.
# upper_bound = 1 prevents any asset from exceeding 100%.
w_gmv_long <- port_gmv_cvxr.calc(
  stock_returns,
  cov.calc,
  lower_bound = 0,
  upper_bound = 1
)

w_gmv_long
##    AAPL.OQ    AMZN.OQ       HD.N       KO.N      WMT.N 
## 0.01426565 0.07831596 0.10655460 0.48131969 0.31954410
sum(w_gmv_long)
## [1] 1
Asset Unconstrained GMVP Long-Only GMVP
Apple 1.43% 1.43%
Amazon 7.83% 7.83%
Home Depot 10.66% 10.66%
Coca-Cola 48.13% 48.13%
Walmart 31.95% 31.95%

The analytical Global Minimum Variance Portfolio (GMVP) allows short selling because no non-negativity constraints are imposed. To provide a fair comparison with the long-only Risk Parity Portfolio, the GMVP was also estimated under the constraint

\[ w_i \geq 0. \]

For the selected five-stock dataset, the unconstrained and long-only GMVP produced identical portfolio weights. This indicates that although short selling was permitted by the unconstrained optimization, it was not required to obtain the minimum-variance portfolio. Consequently, all portfolio strategies considered in this empirical comparison are effectively long-only.

Relative Risk Contributions

To evaluate how portfolio risk is distributed, the analysis uses the Relative Risk Contribution, abbreviated as RRC.

Portfolio variance is

\[ \sigma_p^2 = \mathbf{w}^{\prime}\boldsymbol{\Sigma}\mathbf{w}. \]

The relative risk contribution of asset \(i\) is defined as

\[ RRC_i = \frac{ w_i(\boldsymbol{\Sigma}\mathbf{w})_i }{ \mathbf{w}^{\prime}\boldsymbol{\Sigma}\mathbf{w} }. \]

The relative risk contributions sum to one:

\[ \sum_{i=1}^{N}RRC_i = 1. \]

An RRC of 0.20 therefore means that the corresponding asset contributes 20% of the portfolio’s total variance.

# Relative Risk Contribution function
rrc.calc <- function(w, cov_mat) {
  
  port_var <- as.numeric(
    t(w) %*% cov_mat %*% w
  )
  
  rrc <- w * as.numeric(
    cov_mat %*% w
  ) / port_var
  
  names(rrc) <- names(w)
  
  return(rrc)
}
Asset Equal Weight RRC GMV RRC
Apple 27.13% 1.43%
Amazon 29.09% 7.83%
Home Depot 19.71% 10.66%
Coca-Cola 11.24% 48.13%
Walmart 12.84% 31.95%

The Equal Weight Portfolio distributes capital equally, but its risk contributions are uneven. Amazon contributes approximately 29.09% of total portfolio risk, even though its capital weight is only 20%.

The GMV Portfolio achieves the lowest possible volatility for the estimated covariance matrix, but its risk allocation is even more concentrated. Coca-Cola contributes approximately 48.13% of total portfolio risk.

These results illustrate an important distinction:

Equal Weight diversifies capital, while Global Minimum Variance minimizes total variance. Neither method necessarily diversifies portfolio risk.

Risk Parity Portfolio

The Risk Parity Portfolio aims to distribute total portfolio risk equally across all assets. With \(N=5\), the target relative risk contribution is

\[ b_i = \frac{1}{5}=0.20. \]

The portfolio is obtained by minimizing the squared deviations between the realized relative risk contributions and the equal-risk target:

\[ \min_{\mathbf{w}} \quad \sum_{i=1}^{N} \left( RRC_i(\mathbf{w})-\frac{1}{N} \right)^2. \]

The optimization is subject to long-only bounds:

\[ 0 \leq w_i \leq 1. \]

The numerical optimization is performed using the L-BFGS-B algorithm implemented in the base R function optim(). The algorithm starts from equal weights and searches for a portfolio whose relative risk contributions are as close as possible to the equal-risk target.

port_rpp.calc <- function(
    stock_rets_mat,
    cov_func,
    ...
) {
  
  n_stocks <- ncol(stock_rets_mat)
  names_stocks <- colnames(stock_rets_mat)
  
  # Estimate the covariance matrix
  cov_mat <- cov_func(
    stock_rets_mat,
    ...
  )
  
  # Equal target risk contributions
  target_rrc <- rep(
    1 / n_stocks,
    n_stocks
  )
  
  # Objective function
  objective <- function(w) {
    
    port_var <- as.numeric(
      t(w) %*% cov_mat %*% w
    )
    
    rrc <- w * as.numeric(
      cov_mat %*% w
    ) / port_var
    
    sum(
      (rrc - target_rrc)^2
    )
  }
  
  # Numerical optimization
  result <- optim(
    par = rep(1 / n_stocks, n_stocks),
    fn = objective,
    method = "L-BFGS-B",
    lower = rep(0, n_stocks),
    upper = rep(1, n_stocks)
  )
  
  # Normalize the final portfolio weights
  port_weights <- result$par
  port_weights <- port_weights / sum(port_weights)
  
  names(port_weights) <- names_stocks
  
  return(port_weights)
}
Asset Ticker Portfolio weight Relative risk contribution
Apple AAPL.OQ 14.64% 20.00%
Amazon AMZN.OQ 14.28% 20.00%
Home Depot HD.N 18.49% 20.00%
Coca-Cola KO.N 27.70% 20.00%
Walmart WMT.N 24.89% 20.00%

The Risk Parity Portfolio assigns lower weights to Apple and Amazon and higher weights to Coca-Cola and Walmart. This reflects the fact that less volatile or more diversifying assets can receive larger capital allocations while still contributing the same amount of risk.

The resulting relative risk contributions are approximately 20% for every asset. The portfolio therefore achieves its intended objective of equal risk allocation.

Portfolio Weights and Risk Contributions

The following figure compares the capital weights and relative risk contributions of the three portfolios.

The upper panel shows that the Equal Weight Portfolio assigns identical capital weights, whereas the GMV and Risk Parity portfolios tilt capital toward Coca-Cola and Walmart.

The lower panel reveals that equal capital allocation does not produce equal risk contributions. The GMV Portfolio exhibits the strongest risk concentration, while the Risk Parity Portfolio aligns all contributions with the 20% target.

Volatility Comparison

Portfolio volatility is calculated as

\[ \sigma_p = \sqrt{ \mathbf{w}^{\prime} \boldsymbol{\Sigma} \mathbf{w} }. \]

Portfolio Volatility Maximum RRC Largest risk contributor
Equal Weight 4.56% 29.09% Amazon
Global Minimum Variance 3.85% 48.13% Coca-Cola
Risk Parity 4.18% 20.00% All assets approximately equal

The Global Minimum Variance Portfolio produces the lowest volatility at approximately 3.85%. This is expected, because minimizing portfolio variance is its direct objective.

The Risk Parity Portfolio has a volatility of approximately 4.18%, which is lower than the 4.56% volatility of the Equal Weight Portfolio.

However, the lowest-volatility portfolio is not necessarily the portfolio with the best risk diversification. Almost half of the GMV Portfolio’s total risk is associated with Coca-Cola. By contrast, Risk Parity distributes risk almost equally across all five assets.

Why Risk Parity Sits Between Equal Weight and GMV

The Risk Parity Portfolio can be interpreted as a compromise between the Equal Weight Portfolio and the Global Minimum Variance Portfolio.

The Equal Weight Portfolio assigns equal capital without considering risk. The GMV Portfolio focuses entirely on minimizing portfolio variance and may therefore create highly concentrated capital allocations or risk exposures.

Risk Parity incorporates the covariance matrix and tilts capital toward lower-risk or more diversifying assets. However, unlike GMV, it does not pursue the smallest possible portfolio variance. Its central objective is instead to prevent individual assets from dominating total portfolio risk.

Portfolio Primary objective Main strength Main limitation
Equal Weight Allocate equal capital Simple and estimation-free Equal capital does not mean equal risk
Global Minimum Variance Minimize total volatility Lowest in-sample volatility Risk may become highly concentrated
Risk Parity Equalize risk contributions Balanced portfolio-risk allocation Volatility may remain above GMV

Risk Parity therefore sits between the two traditional approaches:

  • it is more risk-aware than Equal Weight;
  • it is less concentrated than Global Minimum Variance;
  • it reduces volatility relative to Equal Weight;
  • but it does not necessarily achieve the minimum possible volatility.

Chapter 1 Conclusion

The empirical results demonstrate that the three portfolio strategies produce substantially different risk structures.

The Equal Weight Portfolio diversifies capital but leaves risk contributions uneven. The Global Minimum Variance Portfolio achieves the lowest estimated volatility, but it does so with a strongly concentrated risk structure. In the five-stock portfolio, approximately 48.13% of its risk is associated with Coca-Cola.

The Risk Parity Portfolio successfully balances all relative risk contributions at approximately 20%. At the same time, it reduces portfolio volatility from 4.56% under Equal Weight to 4.18%.

Main finding: The Global Minimum Variance Portfolio delivers the lowest total volatility, whereas the Risk Parity Portfolio provides the most balanced distribution of portfolio risk. This illustrates the trade-off between variance minimization and risk diversification.


Chapter 2 - Naive Risk Parity versus Risk Parity

Risk Parity under Diagonal and Full Covariance Assumptions

Chapter 1 demonstrated that the Risk Parity Portfolio can achieve equal risk contributions while producing lower volatility than the Equal Weight Portfolio. However, the Risk Parity Portfolio requires an estimate of the full covariance matrix and a numerical optimization procedure.

A simpler alternative is obtained by assuming that the covariance matrix is diagonal. Under this assumption, all correlations between assets are ignored, and the risk-budgeting equations admit a closed-form solution. The resulting portfolio is called the Naive Risk Parity Portfolio, the Diagonal Risk Parity Portfolio, or the Inverse Volatility Portfolio.

Research question of Chapter 2

Does the computationally simple Naive Risk Parity Portfolio provide a good approximation to the Risk Parity Portfolio, or is accounting for correlations worth the additional complexity?

The analysis is first conducted using the five-stock investment universe from Chapter 1. It is then repeated using twenty S&P 500 stocks to examine whether correlations become more important in a larger portfolio.

Risk Budgeting under a Diagonal Covariance Matrix

The general risk-budgeting condition requires the risk contribution of asset \(i\) to equal its target risk budget \(b_i\):

\[ w_i(\boldsymbol{\Sigma}\mathbf{w})_i = b_i \mathbf{w}^{\top} \boldsymbol{\Sigma} \mathbf{w}. \]

Assume that the covariance matrix is diagonal:

\[ \boldsymbol{\Sigma} = \operatorname{Diag} \left( \sigma_1^2,\ldots,\sigma_N^2 \right). \]

A diagonal covariance matrix contains the individual asset variances but sets all covariances equal to zero. Therefore,

\[ (\boldsymbol{\Sigma}\mathbf{w})_i = \sigma_i^2w_i. \]

Portfolio variance becomes

\[ \mathbf{w}^{\top} \boldsymbol{\Sigma} \mathbf{w} = \sum_{j=1}^{N} w_j^2\sigma_j^2. \]

Substituting these expressions into the risk-budgeting equation gives

\[ w_i^2\sigma_i^2 = b_i \sum_{j=1}^{N} w_j^2\sigma_j^2, \qquad i=1,\ldots,N. \]

Taking the positive square root yields

\[ w_i\sigma_i = \sqrt{b_i} \sqrt{ \sum_{j=1}^{N} w_j^2\sigma_j^2 }. \]

Solving for \(w_i\) gives

\[ w_i = \frac{\sqrt{b_i}}{\sigma_i} \sqrt{ \sum_{j=1}^{N} w_j^2\sigma_j^2 }. \]

The final square-root expression is common to all assets and acts only as a scaling factor. Consequently,

\[ w_i \propto \frac{\sqrt{b_i}}{\sigma_i}. \]

For equal risk budgets,

\[ b_i=\frac{1}{N}, \]

and \(\sqrt{b_i}\) is identical for every asset. Therefore,

\[ \boxed{ w_i \propto \frac{1}{\sigma_i} } \]

After normalizing the weights so that they sum to one, the Naive Risk Parity weights are

\[ \boxed{ w_i^{IV} = \frac{\frac{1}{\sigma_i}} {\sum_{j=1}^{N}\frac{1}{\sigma_j}} } \]

Interpretation

The Naive Risk Parity Portfolio assigns lower weights to assets with high individual volatility and higher weights to assets with low individual volatility. It reproduces the risk-budgeting solution exactly only under the assumption that the covariance matrix is diagonal.

Naive Risk Parity Implementation

The following function estimates the covariance matrix, extracts the individual asset volatilities from its diagonal, and constructs normalized inverse-volatility weights.

# Naive Risk Parity / Inverse Volatility Portfolio
#
# Under a diagonal covariance assumption:
#
#     w_i proportional to 1 / sigma_i
#
# The method uses individual asset volatilities but ignores
# all correlations between assets.

port_ivol.calc <- function(
    stock_rets_mat,
    cov_func,
    ...
) {
  
  # Estimate the covariance matrix.
  cov_mat <- cov_func(
    stock_rets_mat,
    ...
  )
  
  # Extract each asset's variance from the diagonal and
  # convert the variances into standard deviations.
  asset_vols <- sqrt(
    diag(cov_mat)
  )
  
  # Assign a larger preliminary weight to assets with
  # lower volatility and a smaller weight to assets with
  # higher volatility.
  port_weights <- 1 / asset_vols
  
  # Normalize the weights so that they sum to one.
  port_weights <- port_weights /
    sum(port_weights)
  
  # Preserve the stock names.
  names(port_weights) <-
    colnames(stock_rets_mat)
  
  return(port_weights)
}

Because code_folding: hide is specified in the YAML header and this chunk uses echo=TRUE, the code is initially hidden but remains available through the Code button.

Five-Stock Portfolio

Portfolio Construction

The first comparison uses the same five stocks as Chapter 1:

  • Apple,
  • Amazon,
  • Home Depot,
  • Coca-Cola,
  • Walmart.

The Equal Weight and Risk Parity weights were already calculated in Chapter 1. The following code constructs the Naive Risk Parity Portfolio and calculates its relative risk contributions and volatility.

# Construct the Naive Risk Parity Portfolio.
w_ivol <- port_ivol.calc(
  stock_returns,
  cov.calc
)

# Confirm that the portfolio weights sum to one.
sum(w_ivol)
## [1] 1
# Calculate the relative risk contributions.
rrc_ivol <- rrc.calc(
  w_ivol,
  cov_mat
)

# Calculate portfolio volatility.
vol_ivol <- port_vola.calc(
  w_ivol,
  cov_mat
)

# Display the principal results.
w_ivol
##   AAPL.OQ   AMZN.OQ      HD.N      KO.N     WMT.N 
## 0.1575756 0.1417362 0.1972234 0.2679983 0.2354666
rrc_ivol
##   AAPL.OQ   AMZN.OQ      HD.N      KO.N     WMT.N 
## 0.2164783 0.1979882 0.2140087 0.1888168 0.1827080
vol_ivol
## [1] 0.04224007

Portfolio Weights

Asset Equal Weight Naive Risk Parity Risk Parity
Apple 20.00% 15.76% 14.64%
Amazon 20.00% 14.17% 14.28%
Home Depot 20.00% 19.72% 18.49%
Coca-Cola 20.00% 26.80% 27.70%
Walmart 20.00% 23.55% 24.89%

The Naive Risk Parity weights are already very close to the Risk Parity weights. Both strategies assign lower weights to Apple and Amazon and higher weights to Coca-Cola and Walmart.

This indicates that differences in individual volatility explain a large part of the Risk Parity allocation in the five-stock universe. Nevertheless, the weights are not identical because the optimized Risk Parity Portfolio also accounts for correlations.

Relative Risk Contributions

Asset Equal Weight RRC Naive Risk Parity RRC Risk Parity RRC
Apple 27.13% 21.65% 20.00%
Amazon 29.09% 19.80% 20.00%
Home Depot 19.71% 21.40% 20.00%
Coca-Cola 11.24% 18.88% 20.00%
Walmart 12.84% 18.27% 20.00%

The Equal Weight Portfolio assigns 20% of capital to each asset but produces uneven risk contributions. Amazon contributes approximately 29.09% of total portfolio risk.

Naive Risk Parity substantially improves the distribution. Its risk contributions range around the 20% target, although they are not perfectly equal. This occurs because the actual covariance matrix is not diagonal.

The optimized Risk Parity Portfolio uses the complete covariance matrix and therefore achieves almost exactly equal risk contributions.

Visual Comparison

# Combine portfolio weights in one data frame.
df_w_ivol <- data.frame(
  Stock = unname(
    asset_names[names(w_ewp)]
  ),
  `Equal Weight` = as.numeric(w_ewp),
  `Naive Risk Parity` = as.numeric(w_ivol),
  `Risk Parity` = as.numeric(w_rpp),
  check.names = FALSE
)

# Combine relative risk contributions.
df_rrc_ivol <- data.frame(
  Stock = unname(
    asset_names[names(rrc_ewp)]
  ),
  `Equal Weight` = as.numeric(rrc_ewp),
  `Naive Risk Parity` = as.numeric(rrc_ivol),
  `Risk Parity` = as.numeric(rrc_rpp),
  check.names = FALSE
)

# Convert both data frames into long format for ggplot.
df_w_ivol_long <- reshape2::melt(
  df_w_ivol,
  id.vars = "Stock",
  variable.name = "Portfolio",
  value.name = "Weight"
)

df_rrc_ivol_long <- reshape2::melt(
  df_rrc_ivol,
  id.vars = "Stock",
  variable.name = "Portfolio",
  value.name = "Risk_Contribution"
)
chapter2_colors <- c(
  "Equal Weight" = "#244A78",
  "Naive Risk Parity" = "#D9A514",
  "Risk Parity" = "#7A7A7A"
)

# Portfolio-weight plot
p_w_ivol <- ggplot(
  df_w_ivol_long,
  aes(
    x = Stock,
    y = Weight,
    fill = Portfolio
  )
) +
  geom_col(
    position = position_dodge(width = 0.8),
    width = 0.7
  ) +
  scale_fill_manual(
    values = chapter2_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  labs(
    title = "Portfolio Weights",
    subtitle = "Five-stock investment universe",
    x = NULL,
    y = "Portfolio Weight",
    fill = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

# Relative-risk-contribution plot
p_rrc_ivol <- ggplot(
  df_rrc_ivol_long,
  aes(
    x = Stock,
    y = Risk_Contribution,
    fill = Portfolio
  )
) +
  geom_col(
    position = position_dodge(width = 0.8),
    width = 0.7
  ) +
  geom_hline(
    yintercept = 0.20,
    linetype = "dashed",
    linewidth = 0.7
  ) +
  scale_fill_manual(
    values = chapter2_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  labs(
    title = "Relative Risk Contributions",
    subtitle = "Dashed line represents the equal-risk target of 20%",
    x = NULL,
    y = "Relative Risk Contribution",
    fill = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

# Display the figures vertically.
p_w_ivol / p_rrc_ivol

Volatility Comparison

# Calculate portfolio volatilities.
vol_ewp_ch2 <- port_vola.calc(
  w_ewp,
  cov_mat
)

vol_ivol_ch2 <- port_vola.calc(
  w_ivol,
  cov_mat
)

vol_rpp_ch2 <- port_vola.calc(
  w_rpp,
  cov_mat
)

# Prepare the plotting data.
df_vol_ivol <- data.frame(
  Portfolio = factor(
    c(
      "Equal Weight",
      "Naive Risk Parity",
      "Risk Parity"
    ),
    levels = c(
      "Equal Weight",
      "Naive Risk Parity",
      "Risk Parity"
    )
  ),
  Volatility = c(
    vol_ewp_ch2,
    vol_ivol_ch2,
    vol_rpp_ch2
  )
)
ggplot(
  df_vol_ivol,
  aes(
    x = Portfolio,
    y = Volatility,
    fill = Portfolio
  )
) +
  geom_col(width = 0.62) +
  geom_text(
    aes(
      label = scales::percent(
        Volatility,
        accuracy = 0.01
      )
    ),
    vjust = -0.5,
    size = 4
  ) +
  scale_fill_manual(
    values = chapter2_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    ),
    expand = expansion(
      mult = c(0, 0.12)
    )
  ) +
  labs(
    title = "Portfolio Volatility Comparison",
    subtitle = "Five-stock investment universe",
    x = NULL,
    y = "Portfolio Volatility"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "none",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

# Summary of volatility and risk concentration.
summary_ivol <- data.frame(
  Portfolio = c(
    "Equal Weight",
    "Naive Risk Parity",
    "Risk Parity"
  ),
  Volatility = c(
    vol_ewp_ch2,
    vol_ivol_ch2,
    vol_rpp_ch2
  ),
  Maximum_RRC = c(
    max(rrc_ewp),
    max(rrc_ivol),
    max(rrc_rpp)
  )
)

summary_ivol
##           Portfolio Volatility Maximum_RRC
## 1      Equal Weight 0.04559593   0.2908852
## 2 Naive Risk Parity 0.04224007   0.2164783
## 3       Risk Parity 0.04181368   0.2000007
Portfolio Volatility Maximum RRC
Equal Weight 4.56% 29.09%
Naive Risk Parity 4.22% 21.65%
Risk Parity 4.18% 20.00%

Naive Risk Parity reduces volatility from 4.56% to 4.22%.

The Risk Parity Portfolio produces a slightly lower volatility of 4.18%. The incremental volatility reduction from including correlations is therefore small in the five-stock portfolio.

Five-stock finding

Naive Risk Parity captures most of the benefit of the Risk Parity Portfolio. It substantially improves risk allocation and reduces volatility, while the complete covariance matrix provides a small additional improvement.

Extension to Twenty Stocks

The five-stock results were obtained using a relatively small investment universe. A natural question is whether the difference between Naive Risk Parity and Risk Parity becomes more pronounced when more assets are included.

The analysis is therefore repeated using twenty S&P 500 stocks.

Data Selection and Portfolio Construction

# Select twenty stocks from the S&P 500 return dataset.
stocks20 <- c(
  "AAPL.OQ", "MSFT.OQ", "NVDA.OQ", "AMZN.OQ", "HD.N",
  "MCD.N", "WMT.N", "KO.N", "COST.OQ", "PG.N",
  "JPM.N", "V.N", "BRKb.N", "JNJ.N", "LLY.N",
  "UNH.N", "XOM.N", "CVX.N", "CAT.N", "PEP.OQ"
)

# Create the twenty-stock return matrix.
sp500_20 <- log_returns_sp500[
  ,
  stocks20
]

# Estimate the covariance matrix.
cov_mat20 <- cov.calc(
  sp500_20
)

# Construct the Equal Weight Portfolio.
w_ewp20 <- port_naive.calc(
  sp500_20
)

# Construct the Naive Risk Parity Portfolio.
w_ivol20 <- port_ivol.calc(
  sp500_20,
  cov.calc
)

# Construct the Risk Parity Portfolio.
w_rpp20 <- port_rpp.calc(
  sp500_20,
  cov.calc
)

# Verify that all portfolio weights sum to one.
sum(w_ewp20)
## [1] 1
sum(w_ivol20)
## [1] 1
sum(w_rpp20)
## [1] 1

With twenty assets, the equal-risk target is

\[ \frac{1}{20}=0.05. \]

Each asset should therefore contribute approximately 5% of total portfolio risk under the optimized Risk Parity Portfolio.

Hypothesis

As the number of assets increases, the covariance structure contains more pairwise relationships. Consequently, ignoring correlations may produce larger deviations from the target risk contributions.

Risk Contribution Calculations

# Calculate relative risk contributions.
rrc_ewp20 <- rrc.calc(
  w_ewp20,
  cov_mat20
)

rrc_ivol20 <- rrc.calc(
  w_ivol20,
  cov_mat20
)

rrc_rpp20 <- rrc.calc(
  w_rpp20,
  cov_mat20
)

# Calculate maximum relative risk contributions.
max_rrc_ewp20 <- max(
  rrc_ewp20
)

max_rrc_ivol20 <- max(
  rrc_ivol20
)

max_rrc_rpp20 <- max(
  rrc_rpp20
)

# Display rounded risk contributions.
round(rrc_ewp20, 4)
## AAPL.OQ MSFT.OQ NVDA.OQ AMZN.OQ    HD.N   MCD.N   WMT.N    KO.N COST.OQ    PG.N 
##  0.0659  0.0540  0.0982  0.0660  0.0554  0.0394  0.0315  0.0354  0.0462  0.0268 
##   JPM.N     V.N  BRKb.N   JNJ.N   LLY.N   UNH.N   XOM.N   CVX.N   CAT.N  PEP.OQ 
##  0.0575  0.0535  0.0497  0.0369  0.0246  0.0352  0.0564  0.0623  0.0701  0.0350
round(rrc_ivol20, 4)
## AAPL.OQ MSFT.OQ NVDA.OQ AMZN.OQ    HD.N   MCD.N   WMT.N    KO.N COST.OQ    PG.N 
##  0.0511  0.0547  0.0405  0.0437  0.0563  0.0564  0.0413  0.0547  0.0518  0.0440 
##   JPM.N     V.N  BRKb.N   JNJ.N   LLY.N   UNH.N   XOM.N   CVX.N   CAT.N  PEP.OQ 
##  0.0515  0.0599  0.0645  0.0570  0.0242  0.0435  0.0454  0.0510  0.0499  0.0585
round(rrc_rpp20, 4)
## AAPL.OQ MSFT.OQ NVDA.OQ AMZN.OQ    HD.N   MCD.N   WMT.N    KO.N COST.OQ    PG.N 
##    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05 
##   JPM.N     V.N  BRKb.N   JNJ.N   LLY.N   UNH.N   XOM.N   CVX.N   CAT.N  PEP.OQ 
##    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05

Relative Risk Contributions

# Combine the relative risk contributions.
df_rrc20 <- data.frame(
  Stock = names(rrc_ewp20),
  `Equal Weight` = as.numeric(rrc_ewp20),
  `Naive Risk Parity` = as.numeric(rrc_ivol20),
  `Risk Parity` = as.numeric(rrc_rpp20),
  check.names = FALSE
)

# Convert to long format.
df_rrc20_long <- reshape2::melt(
  df_rrc20,
  id.vars = "Stock",
  variable.name = "Portfolio",
  value.name = "Risk_Contribution"
)
ggplot(
  df_rrc20_long,
  aes(
    x = Stock,
    y = Risk_Contribution,
    fill = Portfolio
  )
) +
  geom_col(
    position = position_dodge(width = 0.85),
    width = 0.75
  ) +
  geom_hline(
    yintercept = 1 / 20,
    linetype = "dashed",
    linewidth = 0.7
  ) +
  scale_fill_manual(
    values = chapter2_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  labs(
    title = "Relative Risk Contributions",
    subtitle = "Twenty-stock portfolio; dashed line represents the 5% target",
    x = NULL,
    y = "Relative Risk Contribution",
    fill = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    axis.text.x = element_text(
      angle = 45,
      hjust = 1
    ),
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

The Equal Weight Portfolio again produces unequal risk contributions. Its maximum contribution is approximately 9.82%, almost twice the 5% target.

Naive Risk Parity reduces the maximum contribution to approximately 6.45%. It therefore provides a substantial improvement, but its contributions still deviate from the target.

The Risk Parity Portfolio incorporates the complete covariance matrix and achieves a maximum contribution of approximately 5.00%, which is almost exactly equal to the theoretical target.

Impact of Correlations on Portfolio Weights

The following calculation isolates the effect of using correlations:

\[ w_i^{RPP}-w_i^{IV}. \]

A positive difference means that Risk Parity assigns a larger weight than Naive Risk Parity. A negative difference indicates that the full covariance structure leads to a lower weight.

# Calculate weight differences between Risk Parity and
# Naive Risk Parity.
df_diff <- data.frame(
  Stock = names(w_rpp20),
  Difference = as.numeric(
    w_rpp20 - w_ivol20
  )
)

# Sort stocks by the largest absolute difference.
df_diff <- df_diff[
  order(
    abs(df_diff$Difference),
    decreasing = TRUE
  ),
]
ggplot(
  df_diff,
  aes(
    x = reorder(
      Stock,
      Difference
    ),
    y = Difference,
    fill = Difference > 0
  )
) +
  geom_col(width = 0.7) +
  geom_hline(
    yintercept = 0,
    linewidth = 0.7
  ) +
  coord_flip() +
  scale_fill_manual(
    values = c(
      "FALSE" = "#B85C4A",
      "TRUE" = "#4A7C6F"
    ),
    labels = c(
      "Lower weight",
      "Higher weight"
    ),
    name = "Risk Parity"
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 0.1
    )
  ) +
  labs(
    title = "Impact of Correlations on Portfolio Weights",
    subtitle = "Difference = Risk Parity − Naive Risk Parity",
    x = NULL,
    y = "Weight Difference"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.y = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

These differences arise because individual volatility is not the only determinant of an asset’s portfolio-risk contribution. An asset with relatively high standalone volatility may still receive a larger Risk Parity allocation if it has favorable correlations with other assets.

Conversely, an asset can receive a lower allocation when it has high correlations with other major portfolio holdings.

Direct Weight Comparison

# Prepare a direct comparison of Naive Risk Parity and
# Risk Parity weights.
df_weights20 <- data.frame(
  Stock = names(w_ivol20),
  `Naive Risk Parity` = as.numeric(w_ivol20),
  `Risk Parity` = as.numeric(w_rpp20),
  check.names = FALSE
)

# Calculate the absolute weight differences.
df_weights20$Difference <- abs(
  df_weights20$`Risk Parity` -
    df_weights20$`Naive Risk Parity`
)

# Sort by the largest absolute difference.
df_weights20 <- df_weights20[
  order(
    df_weights20$Difference,
    decreasing = TRUE
  ),
]

# Preserve this order in the figure.
df_weights20$Stock <- factor(
  df_weights20$Stock,
  levels = df_weights20$Stock
)

# Remove the temporary sorting variable.
df_weights20$Difference <- NULL

# Convert to long format.
df_weights20_long <- reshape2::melt(
  df_weights20,
  id.vars = "Stock",
  variable.name = "Portfolio",
  value.name = "Weight"
)
ggplot(
  df_weights20_long,
  aes(
    x = Stock,
    y = Weight,
    fill = Portfolio
  )
) +
  geom_col(
    position = position_dodge(width = 0.8),
    width = 0.7
  ) +
  scale_fill_manual(
    values = c(
      "Naive Risk Parity" = "#D9A514",
      "Risk Parity" = "#7A7A7A"
    )
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  labs(
    title = "Naive Risk Parity and Risk Parity Weights",
    subtitle = "Twenty-stock portfolio sorted by absolute weight difference",
    x = NULL,
    y = "Portfolio Weight",
    fill = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    axis.text.x = element_text(
      angle = 45,
      hjust = 1
    ),
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

Twenty-Stock Volatility Comparison

# Calculate portfolio volatilities.
vol_ewp20 <- port_vola.calc(
  w_ewp20,
  cov_mat20
)

vol_ivol20 <- port_vola.calc(
  w_ivol20,
  cov_mat20
)

vol_rpp20 <- port_vola.calc(
  w_rpp20,
  cov_mat20
)

# Prepare the summary table.
summary_twenty <- data.frame(
  Portfolio = c(
    "Equal Weight",
    "Naive Risk Parity",
    "Risk Parity"
  ),
  Maximum_RRC = c(
    max_rrc_ewp20,
    max_rrc_ivol20,
    max_rrc_rpp20
  ),
  Volatility = c(
    vol_ewp20,
    vol_ivol20,
    vol_rpp20
  ),
  Main_Finding = c(
    "Equal capital, unequal risk",
    "Good approximation; ignores correlations",
    "Best risk balance; uses full covariance matrix"
  )
)

summary_twenty
##           Portfolio Maximum_RRC Volatility
## 1      Equal Weight  0.09818252 0.03974818
## 2 Naive Risk Parity  0.06448651 0.03724739
## 3       Risk Parity  0.05000758 0.03670597
##                                     Main_Finding
## 1                    Equal capital, unequal risk
## 2       Good approximation; ignores correlations
## 3 Best risk balance; uses full covariance matrix
Portfolio Maximum RRC Portfolio Volatility Main Finding
Equal Weight 9.82% 3.97% Equal capital, unequal risk
Naive Risk Parity 6.45% 3.72% Good approximation; ignores correlations
Risk Parity 5.00% 3.67% Best risk balance; uses full covariance matrix
vol_summary20 <- data.frame(
  Portfolio = factor(
    c(
      "Equal Weight",
      "Naive Risk Parity",
      "Risk Parity"
    ),
    levels = c(
      "Equal Weight",
      "Naive Risk Parity",
      "Risk Parity"
    )
  ),
  Volatility = c(
    vol_ewp20,
    vol_ivol20,
    vol_rpp20
  )
)

ggplot(
  vol_summary20,
  aes(
    x = Portfolio,
    y = Volatility,
    fill = Portfolio
  )
) +
  geom_col(width = 0.62) +
  geom_text(
    aes(
      label = scales::percent(
        Volatility,
        accuracy = 0.01
      )
    ),
    vjust = -0.5,
    size = 4
  ) +
  scale_fill_manual(
    values = chapter2_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    ),
    expand = expansion(
      mult = c(0, 0.12)
    )
  ) +
  labs(
    title = "Portfolio Volatility Comparison",
    subtitle = "Twenty-stock investment universe",
    x = NULL,
    y = "Portfolio Volatility"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "none",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

The Equal Weight Portfolio has the highest volatility at approximately 3.97%.

Naive Risk Parity reduces volatility to approximately 3.72%, while Risk Parity achieves the lowest volatility at approximately 3.67%.

The difference between the two risk-based methods is relatively small in terms of total volatility. The larger distinction is their ability to equalize risk contributions.

Comparison of Five and Twenty Assets

The following table compares the three strategies across both investment universes.

Investment Universe Portfolio Volatility Maximum RRC
5 Stocks Equal Weight 4.56% 29.09%
5 Stocks Naive Risk Parity 4.22% 21.65%
5 Stocks Risk Parity 4.18% 20.00%
20 Stocks Equal Weight 3.97% 9.82%
20 Stocks Naive Risk Parity 3.72% 6.45%
20 Stocks Risk Parity 3.67% 5.00%

Volatility across Investment Universes

ggplot(
  summary_results,
  aes(
    x = Portfolio,
    y = Volatility,
    fill = Dataset
  )
) +
  geom_col(
    position = position_dodge(width = 0.8),
    width = 0.7
  ) +
  geom_text(
    aes(
      label = scales::percent(
        Volatility,
        accuracy = 0.01
      )
    ),
    position = position_dodge(width = 0.8),
    vjust = -0.5,
    size = 3.5
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    ),
    expand = expansion(
      mult = c(0, 0.14)
    )
  ) +
  labs(
    title = "Portfolio Volatility across Investment Universes",
    subtitle = "Five-stock and twenty-stock portfolios",
    x = NULL,
    y = "Portfolio Volatility",
    fill = "Investment Universe"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

Maximum Risk Contribution

ggplot(
  summary_results,
  aes(
    x = Portfolio,
    y = Maximum_RRC,
    fill = Dataset
  )
) +
  geom_col(
    position = position_dodge(width = 0.8),
    width = 0.7
  ) +
  geom_text(
    aes(
      label = scales::percent(
        Maximum_RRC,
        accuracy = 0.1
      )
    ),
    position = position_dodge(width = 0.8),
    vjust = -0.5,
    size = 3.5
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    ),
    expand = expansion(
      mult = c(0, 0.14)
    )
  ) +
  labs(
    title = "Maximum Relative Risk Contribution",
    subtitle = "Lower values indicate a more balanced risk allocation",
    x = NULL,
    y = "Maximum Relative Risk Contribution",
    fill = "Investment Universe"
  ) +
  theme_bw(base_size = 12) +
  theme(
    panel.grid.major.x = element_blank(),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

Why Is Volatility Lower with Twenty Assets?

Portfolio volatility is lower in the twenty-stock analysis for all three strategies:

Portfolio Five stocks Twenty stocks
Equal Weight 4.56% 3.97%
Naive Risk Parity 4.22% 3.72%
Risk Parity 4.18% 3.67%

A larger investment universe can reduce portfolio volatility because asset-specific, or idiosyncratic, risk is spread across more securities. Provided that the assets are not perfectly correlated, positive and negative return movements partially offset one another.

The twenty-stock universe also contains firms from a broader range of sectors, including technology, consumer goods, healthcare, financial services, energy, and industrials. This creates more opportunities for diversification than the five-stock portfolio.

However, the decline in volatility cannot be attributed only to the number of assets. The five-stock and twenty-stock portfolios contain different securities, and their covariance structures are different. Therefore, the comparison reflects both:

  1. increased diversification from holding more assets, and
  2. the particular risk and correlation characteristics of the selected stocks.

Diversification finding

The twenty-stock portfolios exhibit lower volatility because risk is spread across a broader set of securities and sectors. Nevertheless, the comparison is descriptive rather than a controlled experiment because the composition of the investment universe also changes.

A strict test of the effect of portfolio size alone would require constructing nested portfolios or repeatedly sampling portfolios of different sizes from the same investment universe.

Is Accounting for Correlations Worth the Complexity?

The empirical answer depends on the portfolio objective.

If the aim is to construct a simple portfolio that improves on Equal Weight, Naive Risk Parity performs well. It requires only individual volatility estimates and does not require numerical optimization.

If the objective is to achieve precise equality of risk contributions, correlations must be incorporated. Two assets with the same individual volatility can contribute different amounts of portfolio risk if their correlations with the remaining assets differ.

Criterion Naive Risk Parity Risk Parity
Covariance assumption Diagonal Complete covariance matrix
Individual volatilities Included Included
Correlations Ignored Included
Closed-form solution Yes No
Numerical optimization Not required Required
Computational complexity Low Higher
Risk balancing Approximate Nearly exact
Portfolio volatility Low Slightly lower

The additional complexity produces only a modest improvement in volatility, but it produces a clearer improvement in the equality of risk contributions.

Chapter 2 Conclusion

The objective of this chapter was to determine whether the Naive Risk Parity Portfolio provides a good approximation to the Risk Parity Portfolio.

Under a diagonal covariance matrix, the risk-budgeting equations simplify to inverse-volatility weighting. This provides a closed-form portfolio solution that accounts for differences in individual asset risk but ignores correlations.

In the five-stock portfolio, Naive Risk Parity captured most of the benefit of the optimized method. It substantially improved the allocation of risk and reduced volatility relative to Equal Weight. The remaining advantage of Risk Parity was small but measurable.

The twenty-stock analysis showed that Equal Weight continued to produce uneven risk contributions. Its maximum contribution was approximately 9.82%, compared with the 5% target.

Naive Risk Parity reduced the maximum contribution to approximately 6.45%, confirming that inverse-volatility weighting captures the main principle of Risk Parity.

The Risk Parity Portfolio achieved an almost perfectly equal distribution, with a maximum contribution of approximately 5.00%. It also produced the lowest portfolio volatility.

Main finding

Naive Risk Parity is an effective and computationally simple approximation. However, incorporating the complete covariance matrix produces superior risk balancing and slightly lower volatility. The value of correlations becomes more visible as the investment universe expands.

Chapter 3 examines how the Risk Parity Portfolio can be constructed using Spinu’s Vanilla Convex Risk Budgeting formulation. Newton’s method and Cyclical Coordinate Descent are implemented and compared in terms of numerical accuracy and computational efficiency.


Chapter 3 - Computational Implementation

Computational Implementation of Convex Risk Parity

In Chapters 1 and 2, the Risk Parity Portfolio was obtained by directly minimizing the squared deviations between realized and target relative risk contributions:

\[ \min_{\mathbf{w}} \sum_{i=1}^{N} \left( RRC_i(\mathbf{w})-b_i \right)^2. \]

This formulation is intuitive because its objective directly measures how far the portfolio is from its target risk allocation.

In this chapter, the same Risk Parity Portfolio is obtained using a different optimization problem: Spinu’s Vanilla Convex Risk Budgeting formulation.

Objective of Chapter 3

To implement Newton’s method and Cyclical Coordinate Descent for Spinu’s convex Risk Parity formulation, verify the results against the riskParityPortfolio package, and compare the numerical accuracy and computational efficiency of the different implementations.

Spinu’s Vanilla Convex Formulation

The convex optimization problem proposed by Spinu is

\[ \boxed{ \min_{\mathbf{x}\geq 0} f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} - \mathbf{b}^{\top} \log(\mathbf{x}) } \]

where:

  • \(\mathbf{x}=(x_1,\ldots,x_N)^\top\) is a positive auxiliary vector;
  • \(\boldsymbol{\Sigma}\) is the covariance matrix;
  • \(\mathbf{b}=(b_1,\ldots,b_N)^\top\) contains the risk budgets;
  • \(\log(\mathbf{x})\) is applied element by element.

For equal risk contributions,

\[ b_i=\frac{1}{N}. \]

The vector \(\mathbf{x}\) is not yet the final portfolio-weight vector. Once the optimization has been solved, it is normalized:

\[ \boxed{ \mathbf{w} = \frac{\mathbf{x}} {\mathbf{1}^{\top}\mathbf{x}} } \]

This normalization ensures that the portfolio weights sum to one.

Why Does This Formulation Produce Risk Parity?

The objective is

\[ f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} - \mathbf{b}^{\top} \log(\mathbf{x}). \]

At the minimum, the gradient must equal zero:

\[ \nabla f(\mathbf{x})=\mathbf{0}. \]

The gradient of this objective is

\[ \nabla f(\mathbf{x}) = \boldsymbol{\Sigma}\mathbf{x} - \frac{\mathbf{b}}{\mathbf{x}}, \]

where the division is performed element by element.

Setting the gradient equal to zero gives

\[ \boldsymbol{\Sigma}\mathbf{x} - \frac{\mathbf{b}}{\mathbf{x}} = \mathbf{0}. \]

Therefore,

\[ \boxed{ \boldsymbol{\Sigma}\mathbf{x} = \frac{\mathbf{b}}{\mathbf{x}} } \]

Multiplying the \(i\)-th equation by \(x_i\) gives

\[ x_i \left( \boldsymbol{\Sigma}\mathbf{x} \right)_i = b_i. \]

The expression

\[ x_i \left( \boldsymbol{\Sigma}\mathbf{x} \right)_i \]

is the unnormalized risk contribution of asset \(i\). Therefore, the first-order condition requires each asset’s contribution to correspond to its risk budget \(b_i\).

For equal risk budgets,

\[ b_i=\frac{1}{N}, \]

all assets receive the same risk contribution.

Central result

Although the convex objective does not explicitly contain relative risk contributions, setting its gradient equal to zero produces the risk-budgeting equations directly:

\[ x_i(\boldsymbol{\Sigma}\mathbf{x})_i=b_i. \]

Core Derivatives and Implementation

Spinu’s objective combines a quadratic risk term with a logarithmic barrier. The logarithmic term incorporates the risk budgets and ensures that the auxiliary vector remains strictly positive.

The gradient and Hessian used by the numerical algorithms are

\[ \boxed{ \nabla f(\mathbf{x}) = \boldsymbol{\Sigma}\mathbf{x} - \frac{\mathbf{b}}{\mathbf{x}} } \]

and

\[ \boxed{ H_f(\mathbf{x}) = \boldsymbol{\Sigma} + \operatorname{Diag} \left( \frac{\mathbf{b}}{\mathbf{x}^2} \right). } \]

The complete mathematical derivations are provided in Appendix A. The corresponding objective, gradient, and Hessian functions are documented in Appendix B.

The functions must nevertheless be defined before the solvers are executed. The following compact implementation is therefore retained in the main chapter.

# Spinu's Vanilla Convex Risk Budgeting formulation
#
# Objective:
#
# f(x) = 0.5 * x' Sigma x - b' log(x)
#
# The vector x must remain strictly positive because log(x_i)
# is only defined when x_i > 0.

vanilla_objective.calc <- function(
    x,
    cov_mat,
    b
) {
  
  # First part:
  # 0.5 * x' Sigma x
  #
  # This is the quadratic risk-related component.
  quadratic_term <- 0.5 * as.numeric(
    t(x) %*% cov_mat %*% x
  )
  
  # Second part:
  # b' log(x) = sum_i b_i * log(x_i)
  #
  # log(x) is calculated element by element in R.
  logarithmic_term <- sum(
    b * log(x)
  )
  
  # Complete objective:
  # quadratic term minus logarithmic term
  objective_value <- quadratic_term -
    logarithmic_term
  
  return(objective_value)
}


# Gradient:
#
# grad f(x) = Sigma x - b / x
#
# In R, b / x performs element-by-element division.

gradient.calc <- function(
    x,
    cov_mat,
    b
) {
  
  # Covariance component: Sigma x
  covariance_component <- as.numeric(
    cov_mat %*% x
  )
  
  # Logarithmic component: b / x
  budget_component <- b / x
  
  # Combine the two components
  gradient <- covariance_component -
    budget_component
  
  return(gradient)
}


# Hessian:
#
# H(x) = Sigma + Diag(b / x^2)
#
# diag(b / x^2) creates a diagonal matrix with
# b_i / x_i^2 on the diagonal.

hessian.calc <- function(
    x,
    cov_mat,
    b
) {
  
  logarithmic_hessian <- diag(
    b / x^2
  )
  
  H <- cov_mat +
    logarithmic_hessian
  
  return(H)
}

Scaled Heuristic Initialization

Newton’s method and Cyclical Coordinate Descent require a positive and appropriately scaled starting vector. The implementation uses

\[ \boxed{ \mathbf{x}^{(0)} = \bar{\mathbf{x}} \sqrt{ \frac{ \mathbf{1}^{\top} (\mathbf{b}/\bar{\mathbf{x}}) }{ \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}} } } }, \qquad \bar{\mathbf{x}}=\mathbf{1}. \]

The full derivation is presented in Appendix C.

# Scaled heuristic initialization
#
# We begin with a positive reference vector x_bar = 1.
#
# The scaling factor is:
#
# t = sqrt(
#       [1' (b / x_bar)] /
#       [1' Sigma x_bar]
#     )
#
# The starting point is:
#
# x0 = t * x_bar

scaled_initialization.calc <- function(
    cov_mat,
    b
) {
  
  N <- length(b)
  
  # Positive reference vector:
  # x_bar = (1, ..., 1)'
  x_bar <- rep(
    1,
    N
  )
  
  # Vector of ones used in the summations
  one_vec <- rep(
    1,
    N
  )
  
  # Numerator:
  #
  # 1' (b / x_bar)
  #
  # Since x_bar contains only ones, this is equal to sum(b).
  # The general formula is retained for clarity.
  numerator <- as.numeric(
    t(one_vec) %*%
      (b / x_bar)
  )
  

  # Denominator:
  #
  # 1' Sigma x_bar
  #
  # This measures the scale of the covariance component.

  denominator <- as.numeric(
    t(one_vec) %*%
      cov_mat %*%
      x_bar
  )
  
  # Calculate the positive scaling factor t
  scaling_factor <- sqrt(
    numerator /
      denominator
  )
  
  # Construct the positive starting point
  x0 <- scaling_factor *
    x_bar
  
  names(x0) <- colnames(
    cov_mat
  )
  
  return(x0)
}

Newton’s Method

Newton’s method updates all elements of \(\mathbf{x}\) simultaneously using the gradient and Hessian:

\[ \boxed{ \mathbf{x}^{k+1} = \mathbf{x}^{k} - H_f(\mathbf{x}^{k})^{-1} \nabla f(\mathbf{x}^{k}). } \]

In the numerical implementation, the inverse is not calculated explicitly. Instead, the linear system

\[ H_f(\mathbf{x}^{k})\mathbf{s} = \nabla f(\mathbf{x}^{k}) \]

is solved and the update is performed as \(\mathbf{x}^{k+1}=\mathbf{x}^{k}-\mathbf{s}\). A backtracking step preserves positivity whenever a full Newton step would produce a non-positive element.

The derivation from the second-order Taylor approximation and a small numerical example are provided in Appendix D and Appendix E.

Manual Newton Solver

newton_rpp.calc <- function(
    cov_mat,
    b = NULL,
    tol = 1e-8,
    max_iter = 100
) {
  
  # Number of assets
  N <- ncol(cov_mat)
  
  
  # 1. Define the risk budgets

  # If no budgets are supplied, use equal risk budgets:
  #
  # b_i = 1 / N
  if (is.null(b)) {
    b <- rep(
      1 / N,
      N
    )
  }
  
  # Normalize the budgets so that they sum to one.
  b <- b /
    sum(b)
  
  
  # 2. Construct the positive starting point

  x <- scaled_initialization.calc(
    cov_mat = cov_mat,
    b = b
  )
  
  converged <- FALSE
  
  
  # 3. Begin Newton iterations

  for (iter in 1:max_iter) {
    
    # Calculate:
    #
    # grad f(x) = Sigma x - b / x
    grad <- gradient.calc(
      x = x,
      cov_mat = cov_mat,
      b = b
    )
    
    
    # Stopping rule
    #
    # At the optimum:
    #
    # grad f(x) = 0
    #
    # The solver stops when the largest absolute gradient
    # component is smaller than the tolerance.

    if (
      max(abs(grad)) < tol
    ) {
      
      converged <- TRUE
      break
    }
    
    
    # Calculate:
    #
    # H(x) = Sigma + Diag(b / x^2)
    H <- hessian.calc(
      x = x,
      cov_mat = cov_mat,
      b = b
    )
    
    
    # Newton step
    #
    # Solve:
    #
    # H * step = grad
    #
    # This is equivalent to:
    #
    # step = H^{-1} grad
    #
    # but avoids calculating the inverse explicitly.

    step <- as.numeric(
      solve(
        H,
        grad
      )
    )
    
    
    # Full Newton update:
    #
    # x_new = x - H^{-1} grad
    x_new <- x -
      step
    
    
    # 4. Preserve positivity

    # The objective contains log(x_i), so every element must
    # remain strictly positive.
    #
    # If the full Newton step produces x_i <= 0, reduce the
    # step size by one half until a positive vector is found.
    
    step_size <- 1
    
    while (
      any(x_new <= 0)
    ) {
      
      step_size <- step_size /
        2
      
      x_new <- x -
        step_size * step
      
      # Safety check: stop if no valid positive step can be
      # found after repeated reductions.
      if (
        step_size < 1e-12
      ) {
        stop(
          "Could not find a positive Newton step."
        )
      }
    }
    
    
    # Accept the new point
    x <- x_new
  }
  
  
  # 5. Convert x into portfolio weights

  # x solves the risk-budgeting equations up to scale.
  # Portfolio weights must sum to one.
  w <- x /
    sum(x)
  
  names(w) <- colnames(
    cov_mat
  )
  
  names(x) <- colnames(
    cov_mat
  )
  
  
  # Return weights and diagnostic information
  return(
    list(
      w = w,
      x = x,
      iterations = iter,
      converged = converged,
      maximum_gradient = max(
        abs(
          gradient.calc(
            x = x,
            cov_mat = cov_mat,
            b = b
          )
        )
      )
    )
  )
}

Cyclical Coordinate Descent

Cyclical Coordinate Descent updates one coordinate at a time while holding the remaining coordinates fixed. For each coordinate, the first-order condition reduces to a quadratic equation. The positive solution is

\[ \boxed{ x_i = \frac{ -\mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} + \sqrt{ \left( \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} \right)^2 + 4\Sigma_{ii}b_i } }{ 2\Sigma_{ii} }. } \]

The complete derivation is provided in Appendix F.

Main difference from Newton’s method

Newton’s method updates the entire vector at once using the gradient and Hessian. CCD updates the coordinates sequentially by solving one-dimensional quadratic problems.

Manual CCD Solver

ccd_rpp.calc <- function(
    cov_mat,
    b,
    tol = 1e-8,
    max_iter = 100
) {
  
  # Number of assets
  N <- length(b)
  
  # Normalize the risk budgets
  b <- b /
    sum(b)
  
  
  # 1. Positive starting point

  # Use exactly the same scaled heuristic as the Newton solver.
  x <- scaled_initialization.calc(
    cov_mat = cov_mat,
    b = b
  )
  
  converged <- FALSE
  
  
  # 2. Begin complete coordinate cycles
  for (iter in 1:max_iter) {
    
    # Save x before the cycle so that the total change can be
    # measured after every coordinate has been updated.
    x_old <- x
    
    
    # Update coordinates one after another:
    #
    # x_1, x_2, ..., x_N
    for (i in 1:N) {
      
      # Remove coordinate i from x
      #
      # x_minus_i = x without x_i

      x_minus_i <- x[-i]
      
      
      # Select covariances between asset i and every other
      # asset.
      #
      # This is column i of Sigma without Sigma_ii.

      sigma_minus_i <- cov_mat[-i, i]
      
      
      # Calculate:
      #
      # c_i = x_{-i}' Sigma_{-i,i}
      #
      # This is the cross-covariance contribution from all
      # other assets.

      cross_term <- sum(
        x_minus_i *
          sigma_minus_i
      )
      
      
      # Variance of asset i
      sigma_ii <- cov_mat[i, i]
      
      

      # Positive solution of:
      #
      # Sigma_ii * x_i^2 +
      # cross_term * x_i -
      # b_i = 0
      #
      # Only the positive root is permitted because x_i > 0.

      x[i] <- (
        -cross_term +
          sqrt(
            cross_term^2 +
              4 *
              sigma_ii *
              b[i]
          )
      ) / (
        2 *
          sigma_ii
      )
    }
    
    
    # 3. Stopping rule

    # Stop when the largest coordinate change over one entire
    # cycle is smaller than the tolerance.
    if (
      max(
        abs(
          x - x_old
        )
      ) < tol
    ) {
      
      converged <- TRUE
      break
    }
  }
  
  
  # 4. Normalize into portfolio weights

  w <- x /
    sum(x)
  
  names(w) <- colnames(
    cov_mat
  )
  
  names(x) <- colnames(
    cov_mat
  )
  
  
  return(
    list(
      w = w,
      x = x,
      iterations = iter,
      converged = converged
    )
  )
}

Five-Asset Newton Solution

The manual Newton implementation is first evaluated using the five-stock covariance matrix from Chapter 1. Equal risk budgets are assigned:

\[ b_i=\frac{1}{5}=0.20. \]

# Five-asset Newton solution

# Number of assets
N_ch3 <- ncol(cov_mat)

# Equal target risk budgets
b_ch3 <- rep(
  1 / N_ch3,
  N_ch3
)

# Run the manually implemented Newton solver
newton_result <- newton_rpp.calc(
  cov_mat = cov_mat,
  b = b_ch3
)

# Extract the final normalized portfolio weights
w_newton <- newton_result$w

# Solver diagnostics
w_newton
##   AAPL.OQ   AMZN.OQ      HD.N      KO.N     WMT.N 
## 0.1464304 0.1427904 0.1848667 0.2770172 0.2488954
newton_result$converged
## [1] TRUE
newton_result$iterations
## [1] 5
newton_result$maximum_gradient
## [1] 1.946372e-11

The manual Newton solver converged in 5 iterations. Its final gradient is close to zero, indicating that the first-order optimality condition has been satisfied.

Comparison with riskParityPortfolio

# Package Newton solution

package_result_newton <-
  riskParityPortfolio::riskParityPortfolio(
    Sigma = cov_mat,
    b = b_ch3,
    method_init = "newton"
  )

w_package_newton <-
  package_result_newton$w

# Compare the two portfolio-weight vectors
newton_weight_comparison <- cbind(
  Manual_Newton = w_newton,
  Package_Newton = w_package_newton
)

newton_weight_comparison
##         Manual_Newton Package_Newton
## AAPL.OQ     0.1464304      0.1464304
## AMZN.OQ     0.1427904      0.1427904
## HD.N        0.1848667      0.1848667
## KO.N        0.2770172      0.2770172
## WMT.N       0.2488954      0.2488954
# Largest absolute difference between corresponding weights
newton_max_difference <- max(
  abs(
    w_newton -
      w_package_newton
  )
)

newton_max_difference
## [1] 3.22798e-09
Asset Manual Newton Package Newton
Apple 14.6430% 14.6430%
Amazon 14.2790% 14.2790%
Home Depot 18.4867% 18.4867%
Coca-Cola 27.7017% 27.7017%
Walmart 24.8895% 24.8895%

The maximum absolute difference between the two weight vectors is

\[ 3.23e-09. \]

This difference is negligible and is attributable to floating-point precision and minor differences in numerical stopping rules.

Verification of Risk Contributions

# Calculate relative risk contributions
rrc_newton <- rrc.calc(
  w_newton,
  cov_mat
)

rrc_package_newton <- rrc.calc(
  w_package_newton,
  cov_mat
)

# Compare manual solution, package solution, and target
newton_rrc_comparison <- cbind(
  Manual_Newton = rrc_newton,
  Package_Newton = rrc_package_newton,
  Target = b_ch3
)

newton_rrc_comparison
##         Manual_Newton Package_Newton Target
## AAPL.OQ           0.2            0.2    0.2
## AMZN.OQ           0.2            0.2    0.2
## HD.N              0.2            0.2    0.2
## KO.N              0.2            0.2    0.2
## WMT.N             0.2            0.2    0.2
# Maximum deviation from the equal-risk target
max_newton_target_error <- max(
  abs(
    rrc_newton -
      b_ch3
  )
)

max_package_target_error <- max(
  abs(
    rrc_package_newton -
      b_ch3
  )
)

max_newton_target_error
## [1] 1.026426e-10
max_package_target_error
## [1] 3.503878e-09
Asset Manual Newton RRC Package Newton RRC Target
Apple 20.0000% 20.0000% 20.00%
Amazon 20.0000% 20.0000% 20.00%
Home Depot 20.0000% 20.0000% 20.00%
Coca-Cola 20.0000% 20.0000% 20.00%
Walmart 20.0000% 20.0000% 20.00%

All relative risk contributions are approximately 20%. The manual Newton method therefore solves the equal-risk-budgeting problem correctly.

Newton validation: The manual Newton solver converges to the same portfolio as the specialized package and reproduces the target risk contributions within numerical precision.

Five-Asset CCD Solution

The manual CCD implementation is now evaluated using the same five-asset covariance matrix and equal risk budgets.

# Run the manual CCD solver.
ccd_result <- ccd_rpp.calc(
  cov_mat = cov_mat,
  b = b_ch3
)

# Extract the normalized portfolio weights.
w_manual_ccd <- ccd_result$w

# Solver diagnostics
w_manual_ccd
##   AAPL.OQ   AMZN.OQ      HD.N      KO.N     WMT.N 
## 0.1464304 0.1427904 0.1848667 0.2770172 0.2488954
ccd_result$converged
## [1] TRUE
ccd_result$iterations
## [1] 10

The manual CCD solver converged in 10 complete coordinate cycles.

Comparison with Package CCD

package_result_ccd <-
  riskParityPortfolio::riskParityPortfolio(
    Sigma = cov_mat,
    b = b_ch3,
    method_init = "cyclical-spinu"
  )

w_package_ccd <-
  package_result_ccd$w

# Side-by-side comparison
ccd_weight_comparison <- cbind(
  Manual_CCD = w_manual_ccd,
  Package_CCD = w_package_ccd
)

ccd_weight_comparison
##         Manual_CCD Package_CCD
## AAPL.OQ  0.1464304   0.1464304
## AMZN.OQ  0.1427904   0.1427904
## HD.N     0.1848667   0.1848667
## KO.N     0.2770172   0.2770172
## WMT.N    0.2488954   0.2488954
# Maximum absolute weight difference
ccd_max_difference <- max(
  abs(
    w_manual_ccd -
      w_package_ccd
  )
)

ccd_max_difference
## [1] 4.291016e-09
Asset Manual CCD Package CCD Manual CCD RRC
Apple 14.6430% 14.6430% 20.0000%
Amazon 14.2790% 14.2790% 20.0000%
Home Depot 18.4867% 18.4867% 20.0000%
Coca-Cola 27.7017% 27.7017% 20.0000%
Walmart 24.8895% 24.8895% 20.0000%

The maximum absolute difference between the manual and package CCD weights is

\[ 4.29e-09. \]

The relative risk contributions are approximately 20% for every asset, confirming that CCD also solves the risk-budgeting problem correctly.

CCD validation: The manual CCD solver reproduces the package solution and achieves the target equal risk contributions within numerical precision.

Combined Computational Benchmark

The final benchmark compares four implementations:

  1. manual Newton;
  2. manual Cyclical Coordinate Descent;
  3. package Newton;
  4. package cyclical Spinu.

Each method is tested using portfolios containing 5, 20, 50, and 100 assets. The complete benchmark functions and warm-up procedure are documented in Appendix G. They are not rerun during every knit because they perform thousands of optimization calls. The recorded experimental results are reported below.

Numerical Accuracy Comparison

Assets Manual Newton vs Package Manual CCD vs Package Manual Newton vs CCD
5 8.88e-11 3.20e-09 4.75e-11
20 3.14e-13 9.57e-10 2.26e-11
50 3.18e-09 3.59e-09 3.19e-09
100 1.87e-10 4.67e-09 5.80e-11

Across all tested portfolio sizes, the differences lie between approximately \(10^{-13}\) and \(10^{-9}\). Such differences are negligible and reflect ordinary floating-point precision.

The comparison between manual Newton and manual CCD also shows that both algorithms converge to virtually identical portfolio weights. This confirms that they solve the same strictly convex optimization problem.

Runtime Comparison

Assets Manual CCD Manual Newton Package CCD Package Newton
5 0.164 0.177 0.075 0.073
20 0.219 0.712 0.086 0.075
50 0.567 2.171 0.242 0.121
100 2.665 5.360 0.850 0.268

For the manual implementations, Newton’s method becomes substantially faster than CCD as the portfolio dimension increases. With 100 assets, manual Newton requires approximately 2.67 milliseconds, whereas manual CCD requires approximately 5.36 milliseconds.

The manual CCD implementation contains an inner R loop that updates each asset sequentially. This becomes increasingly expensive as the number of assets grows.

The package implementations are faster than the corresponding manual implementations. Interestingly, the package’s cyclical Spinu implementation is the fastest recorded method for the larger portfolios.

Computational finding

Manual Newton is faster than manual CCD for the larger portfolios, but package CCD is the fastest package implementation. Computational performance therefore depends on both the mathematical algorithm and the efficiency of its software implementation.

Newton and CCD Compared

Characteristic Newton’s Method Cyclical Coordinate Descent
Update All coordinates simultaneously One coordinate at a time
Mathematical information Gradient and Hessian Coordinate first-order equation
Main computation Solving a linear system Solving scalar quadratic equations
Positivity Protected with backtracking Positive root guarantees positivity
Iteration structure One vector update One complete cycle through all assets
Manual implementation Efficient for tested larger portfolios Slower because of sequential R loops
Final portfolio Risk Parity Portfolio Same Risk Parity Portfolio

Chapter 3 Conclusion

This chapter implemented two classical algorithms for Spinu’s Vanilla Convex Risk Budgeting formulation:

\[ \min_{\mathbf{x}>0} \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} - \mathbf{b}^{\top} \log(\mathbf{x}). \]

The gradient condition

\[ \boldsymbol{\Sigma}\mathbf{x} = \frac{\mathbf{b}}{\mathbf{x}} \]

implies

\[ x_i (\boldsymbol{\Sigma}\mathbf{x})_i = b_i, \]

which establishes the connection between the convex optimization problem and the desired risk-budgeting equations.

Newton’s method was implemented using the gradient, Hessian, scaled initialization, and a positivity-preserving backtracking step. Cyclical Coordinate Descent was implemented by solving the coordinate-specific quadratic equation and selecting its positive root.

For the five-asset portfolio, both manual algorithms converged to equal relative risk contributions of approximately 20%. Their portfolio weights were numerically equivalent to those produced by the riskParityPortfolio package.

Across portfolios containing 5, 20, 50, and 100 assets, the maximum absolute weight differences remained between approximately \(10^{-13}\) and \(10^{-9}\). These differences are negligible and confirm the correctness and numerical stability of the manual implementations.

The runtime comparison showed that manual Newton was faster than manual CCD as the portfolio dimension increased. However, the optimized package implementations were faster than both manual functions, and package CCD recorded the shortest runtime for the largest portfolios.

Main finding

Both manual algorithms reproduce the specialized package solutions with negligible numerical error. The package implementations provide the same Risk Parity Portfolio more efficiently and are therefore used in the rolling out-of-sample analysis.

Chapter 4 uses the validated riskParityPortfolio implementation to determine whether the theoretical advantages of Risk Parity translate into improved realized out-of-sample performance.


Chapter 4 - Out-of-Sample Walk-Forward Backtesting

The previous chapters evaluated the portfolio strategies using the same return observations that were used to estimate their inputs and construct their weights. Such an in-sample analysis is useful for understanding how the strategies work, but it may provide an overly optimistic assessment of their practical performance.

The true covariance matrix is unknown and must be estimated from historical returns. Consequently, portfolio construction is subject to estimation risk. Errors in estimated volatilities and correlations can affect both the portfolio weights and their measured risk.

This issue is especially relevant for Risk Parity because it uses the complete estimated covariance matrix. Naive Risk Parity uses only estimated individual volatilities, while Equal Weight does not estimate any risk parameters.

Research question of Chapter 4

Does the Risk Parity Portfolio achieve lower realized out-of-sample risk and competitive return performance compared with Equal Weight and Naive Risk Parity, despite its greater exposure to covariance-estimation risk?

The three strategies are evaluated using a rolling-window backtest:

  • Equal Weight Portfolio;
  • Naive Risk Parity Portfolio;
  • Risk Parity Portfolio.

All strategies use identical estimation windows and out-of-sample periods. Differences in realized performance can therefore be attributed to the portfolio-construction methods rather than different information sets.

Why an Out-of-Sample Evaluation Is Necessary

Suppose the covariance matrix is estimated using a historical sample. The same estimate is then used to construct a portfolio and calculate its volatility.

This creates an in-sample performance measure because the same observations are used twice:

  1. to estimate the portfolio inputs and determine the weights;
  2. to evaluate the resulting portfolio.

An optimized portfolio is specifically selected to perform well under the estimated covariance matrix. Its in-sample risk may therefore understate the risk that will be realized when the portfolio is applied to new observations.

In practice, the true covariance matrix is unavailable, so the actual volatility of an estimated portfolio cannot be observed directly. A practical solution is to evaluate the portfolio using future returns that were not used during weight estimation.

Repeating this procedure generates a chronological sequence of realized out-of-sample returns. Their standard deviation provides an empirical estimate of the risk experienced by the investor.

Key principle

Portfolio weights must be estimated only from information available at the rebalancing date. Performance is then measured using a subsequent return that was excluded from the estimation window.

Walk-Forward Backtest Design

The analysis uses the twenty-stock investment universe introduced in Chapter 2. At every rebalancing date:

  1. the previous 60 monthly observations are selected;
  2. the covariance matrix and portfolio weights are estimated;
  3. the weights are applied to the return in the following month;
  4. the estimation window is moved forward by one month.

The process can be represented as follows:

\[ \underbrace{ r_{t-59},\ldots,r_t }_{\text{60-month estimation window}} \quad\longrightarrow\quad \widehat{\mathbf{w}}_t \quad\longrightarrow\quad \underbrace{ r_{t+1} }_{\text{out-of-sample return}}. \]

The one-month portfolio return is

\[ r_{p,t+1} = \widehat{\mathbf{w}}_t^\top \mathbf{r}_{t+1}. \]

The estimation window is then shifted forward:

\[ r_{t-58},\ldots,r_{t+1}. \]

Because the portfolio at each date uses only past information, the procedure avoids look-ahead bias.

Strategy Estimated inputs Relative model complexity
Equal Weight None Lowest
Naive Risk Parity Individual asset volatilities Intermediate
Risk Parity Volatilities and correlations Highest

Data Preparation

The backtest uses the same twenty S&P 500 stocks as Chapter 2.

The data-loading commands and package calls are already contained in the setup chunk at the beginning of the report. They are therefore not repeated here, and no absolute file paths are required.

# ============================================================
# Twenty-stock investment universe
# ============================================================

stocks20_backtest <- c(
  "AAPL.OQ", "MSFT.OQ", "NVDA.OQ", "AMZN.OQ", "HD.N",
  "MCD.N", "WMT.N", "KO.N", "COST.OQ", "PG.N",
  "JPM.N", "V.N", "BRKb.N", "JNJ.N", "LLY.N",
  "UNH.N", "XOM.N", "CVX.N", "CAT.N", "PEP.OQ"
)

# Extract the dates from the course dataset.
dates_backtest <- as.Date(
  log_returns_sp500$Date
)

# Select the twenty stock-return columns and convert the
# result into a numeric matrix.
log_returns_backtest <- log_returns_sp500 |>
  dplyr::select(
    dplyr::all_of(stocks20_backtest)
  ) |>
  as.data.frame() |>
  as.matrix()

# Ensure that the matrix contains numeric double values.
storage.mode(log_returns_backtest) <- "double"

# Store the dates as row names.
rownames(log_returns_backtest) <-
  as.character(dates_backtest)

# Dataset dimensions
n_obs_backtest <- nrow(
  log_returns_backtest
)

n_stocks_backtest <- ncol(
  log_returns_backtest
)

stock_names_backtest <- colnames(
  log_returns_backtest
)

# Basic data checks
dim(log_returns_backtest)
## [1] 120  20
all(is.finite(log_returns_backtest))
## [1] TRUE
Backtest characteristic Value
Number of observations 120
Number of stocks 20
Estimation-window length 60 months
Rebalancing frequency Monthly
Number of out-of-sample months 60

Portfolio Functions Used in the Backtest

The Equal Weight function is provided by the course functions. The inverse-volatility function was introduced in Chapter 2.

For Risk Parity, the backtest uses the validated riskParityPortfolio implementation examined in Chapter 3. A separate function name is used here so that the original Chapter 1 function is not overwritten.

# Naive Risk Parity / Inverse Volatility Portfolio


port_ivol_backtest.calc <- function(
    stock_rets_mat,
    cov_func,
    ...
) {
  
  # Estimate the covariance matrix using the current
  # rolling estimation window.
  cov_mat_window <- cov_func(
    stock_rets_mat,
    ...
  )
  
  # Extract individual asset volatilities.
  asset_vols <- sqrt(
    diag(cov_mat_window)
  )
  
  # Inverse-volatility weights:
  # higher volatility -> lower weight
  preliminary_weights <- 1 /
    asset_vols
  
  # Normalize the weights so that they sum to one.
  portfolio_weights <- preliminary_weights /
    sum(preliminary_weights)
  
  names(portfolio_weights) <-
    colnames(stock_rets_mat)
  
  return(portfolio_weights)
}


# Risk Parity Portfolio using riskParityPortfolio

port_rpp_package.calc <- function(
    stock_rets_mat,
    cov_func,
    ...
) {
  
  n_assets <- ncol(
    stock_rets_mat
  )
  
  asset_names <- colnames(
    stock_rets_mat
  )
  
  # Estimate the covariance matrix using only returns in the
  # current rolling estimation window.
  cov_mat_window <- cov_func(
    stock_rets_mat,
    ...
  )
  
  # Equal target risk budgets.
  b <- rep(
    1 / n_assets,
    n_assets
  )
  
  # Solve Spinu's convex Risk Parity formulation using the
  # package's Newton initialization.
  result <- riskParityPortfolio::riskParityPortfolio(
    Sigma = cov_mat_window,
    b = b,
    method_init = "newton"
  )
  
  # Extract and normalize the portfolio weights.
  portfolio_weights <- result$w
  
  portfolio_weights <- portfolio_weights /
    sum(portfolio_weights)
  
  names(portfolio_weights) <-
    asset_names
  
  return(portfolio_weights)
}

Test of the First Estimation Window

Before running the complete backtest, the first 60-month window is used to verify that all three functions return valid portfolios.

# Select the first 60 monthly observations.
returns_test <- log_returns_backtest[
  1:60,
  ,
  drop = FALSE
]

# Equal Weight Portfolio
w_ewp_test <- port_naive.calc(
  returns_test
)

# Naive Risk Parity Portfolio
w_ivol_test <- port_ivol_backtest.calc(
  returns_test,
  cov.calc,
  type = "MLE"
)

# Risk Parity Portfolio
w_rpp_test <- port_rpp_package.calc(
  returns_test,
  cov.calc,
  type = "MLE"
)

# Verify that each portfolio sums to one.
sum(w_ewp_test)
## [1] 1
sum(w_ivol_test)
## [1] 1
sum(w_rpp_test)
## [1] 1
# Verify the Risk Parity risk contributions.
cov_test <- cov.calc(
  returns_test,
  type = "MLE"
)

rrc_rpp_test <- rrc.calc(
  w_rpp_test,
  cov_test
)

round(rrc_rpp_test, 4)
## AAPL.OQ MSFT.OQ NVDA.OQ AMZN.OQ    HD.N   MCD.N   WMT.N    KO.N COST.OQ    PG.N 
##    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05 
##   JPM.N     V.N  BRKb.N   JNJ.N   LLY.N   UNH.N   XOM.N   CVX.N   CAT.N  PEP.OQ 
##    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05    0.05

The Risk Parity relative risk contributions are approximately equal to 5%, confirming that the package implementation solves the intended problem within the first estimation window.

Initializing the Backtest

The backtest stores two types of results:

  • one realized out-of-sample return for each strategy and month;
  • one complete vector of portfolio weights for each strategy and month.
# Portfolio names
port_types <- c(
  "Equal Weight",
  "Naive Risk Parity",
  "Risk Parity"
)

# Length of the rolling estimation window
n_is <- 60

# Number of out-of-sample observations
n_oos <- n_obs_backtest -
  n_is

# Dates corresponding to the out-of-sample observations
oos_dates <- dates_backtest[
  (n_is + 1):n_obs_backtest
]


# Matrix for realized out-of-sample portfolio returns

port_rets_oos <- matrix(
  NA_real_,
  nrow = n_oos,
  ncol = length(port_types)
)

colnames(port_rets_oos) <-
  port_types

rownames(port_rets_oos) <-
  as.character(oos_dates)



# List of portfolio-weight matrices
#
# Each list entry represents one portfolio strategy.
# Each matrix row represents one rebalancing date.
# Each matrix column represents one stock.
port_weights_oos <- replicate(
  length(port_types),
  matrix(
    NA_real_,
    nrow = n_oos,
    ncol = n_stocks_backtest
  ),
  simplify = FALSE
)

names(port_weights_oos) <-
  port_types

port_weights_oos <- lapply(
  port_weights_oos,
  function(x) {
    
    colnames(x) <-
      stock_names_backtest
    
    rownames(x) <-
      as.character(oos_dates)
    
    x
  }
)

Rolling-Window Loop

The following loop performs the walk-forward backtest.

for (j in 1:n_oos) {
  
  # 1. Define the in-sample and out-of-sample observations

  # The current estimation window contains 60 months.
  index_is <- j:(
    j + n_is - 1
  )
  
  # The immediately following month is the OOS observation.
  index_oos <- j +
    n_is
  
  returns_is <- log_returns_backtest[
    index_is,
    ,
    drop = FALSE
  ]
  
  returns_oos <- log_returns_backtest[
    index_oos,
    ,
    drop = TRUE
  ]
  
  
  # 2. Estimate portfolio weights using past information

  # Equal Weight does not depend on estimated risk inputs.
  w_ewp_window <- port_naive.calc(
    returns_is
  )
  
  # Naive Risk Parity estimates individual volatilities.
  w_ivol_window <- port_ivol_backtest.calc(
    returns_is,
    cov.calc,
    type = "MLE"
  )
  
  # Risk Parity estimates the full covariance matrix.
  w_rpp_window <- port_rpp_package.calc(
    returns_is,
    cov.calc,
    type = "MLE"
  )
  
  
  # 3. Save the estimated weights

  port_weights_oos[["Equal Weight"]][j, ] <-
    w_ewp_window
  
  port_weights_oos[["Naive Risk Parity"]][j, ] <-
    w_ivol_window
  
  port_weights_oos[["Risk Parity"]][j, ] <-
    w_rpp_window
  
  
  # 4. Apply the weights to the following month's returns

  # None of the following returns were used when estimating
  # the corresponding portfolio weights.
  
  port_rets_oos[
    j,
    "Equal Weight"
  ] <- port_ret.calc(
    w_ewp_window,
    returns_oos
  )
  
  port_rets_oos[
    j,
    "Naive Risk Parity"
  ] <- port_ret.calc(
    w_ivol_window,
    returns_oos
  )
  
  port_rets_oos[
    j,
    "Risk Parity"
  ] <- port_ret.calc(
    w_rpp_window,
    returns_oos
  )
}

Backtest Validation

Several checks are performed before evaluating performance.

# Dimensions of the realized OOS return matrix
dim(port_rets_oos)
## [1] 60  3
# Confirm that no returns are missing
anyNA(port_rets_oos)
## [1] FALSE
# Check the maximum weight-sum error for every strategy
weight_sum_errors <- sapply(
  port_weights_oos,
  function(x) {
    max(
      abs(
        rowSums(x) - 1
      )
    )
  }
)

weight_sum_errors
##      Equal Weight Naive Risk Parity       Risk Parity 
##      0.000000e+00      1.110223e-16      1.110223e-16

All out-of-sample returns are available, and the portfolio weights sum to one within numerical precision.

Performance Measures

The backtest produces monthly logarithmic returns. These are converted into simple returns:

\[ R_{p,t} = \exp(r_{p,t})-1. \]

Several performance measures are calculated.

Geometric annualized return

For \(T\) monthly returns, final wealth is

\[ W_T = \prod_{t=1}^{T} (1+R_{p,t}). \]

If the backtest covers \(T/12\) years, the geometric annualized return is

\[ R_{\text{geo}} = W_T^{12/T}-1. \]

Annualized volatility

\[ \sigma_{\text{ann}} = \operatorname{sd}(R_{p,t}) \sqrt{12}. \]

Sharpe ratio

Assuming a zero risk-free rate,

\[ SR = \sqrt{12} \frac{ \overline{R}_p }{ \operatorname{sd}(R_p) }. \]

The Sharpe ratio uses the arithmetic average monthly return, whereas the reported annual return is geometric. This is why the ratio is not calculated by simply dividing the displayed geometric return by displayed volatility.

Maximum drawdown

At time \(t\), drawdown is

\[ D_t = \frac{ W_t }{ \max_{s\leq t}W_s } -1. \]

Maximum drawdown is the lowest value of \(D_t\).

Calculating Out-of-Sample Performance

# Convert monthly log returns into monthly simple returns.
port_rets_simple <- exp(
  port_rets_oos
) - 1


# Cumulative wealth
#
# Because port_rets_oos contains log returns:
#
# W_t = exp(cumulative sum of log returns)

port_wealth_oos <- apply(
  port_rets_oos,
  2,
  function(x) {
    exp(
      cumsum(x)
    )
  }
)

rownames(port_wealth_oos) <-
  rownames(port_rets_oos)


# Geometric annualized return

annual_return_geo <- apply(
  port_rets_simple,
  2,
  function(x) {
    
    total_wealth <- prod(
      1 + x
    )
    
    n_years <- length(x) /
      12
    
    total_wealth^(
      1 / n_years
    ) - 1
  }
)


# Annualized realized volatility

annual_volatility <- apply(
  port_rets_simple,
  2,
  sd
) * sqrt(12)


# Annualized Sharpe ratio
#
# Risk-free rate assumed to be zero.

annual_sharpe <- sqrt(12) *
  colMeans(port_rets_simple) /
  apply(
    port_rets_simple,
    2,
    sd
  )


# Maximum drawdown function

max_drawdown.calc <- function(
    wealth_series
) {
  
  # Highest wealth level observed up to each date
  running_peak <- cummax(
    wealth_series
  )
  
  # Percentage loss relative to the previous peak
  drawdown <- wealth_series /
    running_peak - 1
  
  # Most severe drawdown
  min(drawdown)
}

max_drawdown <- apply(
  port_wealth_oos,
  2,
  max_drawdown.calc
)


# Final performance table

performance_table_extended <- data.frame(
  Portfolio = colnames(
    port_rets_oos
  ),
  Geometric_Annual_Return = annual_return_geo,
  Annualized_Volatility = annual_volatility,
  Sharpe_Ratio = annual_sharpe,
  Maximum_Drawdown = max_drawdown,
  Final_Wealth = port_wealth_oos[
    nrow(port_wealth_oos),
    ]
)

performance_table_extended
##                           Portfolio Geometric_Annual_Return
## Equal Weight           Equal Weight               0.1634541
## Naive Risk Parity Naive Risk Parity               0.1350080
## Risk Parity             Risk Parity               0.1503909
##                   Annualized_Volatility Sharpe_Ratio Maximum_Drawdown
## Equal Weight                  0.1652525    1.0024034       -0.1650156
## Naive Risk Parity             0.1570188    0.8872825       -0.1628518
## Risk Parity                   0.1554799    0.9820292       -0.1528268
##                   Final_Wealth
## Equal Weight          2.131799
## Naive Risk Parity     1.883626
## Risk Parity           2.014778
Portfolio Geometric Annual Return Annualized Volatility Sharpe Ratio Maximum Drawdown Final Wealth
Equal Weight Equal Weight 16.35% 16.53% 1.002 -16.50% 2.132
Naive Risk Parity Naive Risk Parity 13.50% 15.70% 0.887 -16.29% 1.884
Risk Parity Risk Parity 15.04% 15.55% 0.982 -15.28% 2.015

The Equal Weight Portfolio generated the highest geometric annual return, approximately 16.35%, and the highest final wealth.

The Risk Parity Portfolio generated a lower annual return of approximately 15.04%, but it also produced the lowest annualized volatility and the smallest maximum drawdown.

Naive Risk Parity achieved lower volatility than Equal Weight but generated the lowest return and final wealth over the evaluation period.

Cumulative Out-of-Sample Wealth

The wealth paths show the value of an initial investment of one monetary unit:

\[ W_0=1. \]

wealth_df <- data.frame(
  Date = as.Date(
    rownames(port_wealth_oos)
  ),
  port_wealth_oos,
  check.names = FALSE
)

wealth_long <- wealth_df |>
  tidyr::pivot_longer(
    cols = -Date,
    names_to = "Portfolio",
    values_to = "Wealth"
  )
chapter4_colors <- c(
  "Equal Weight" = "#244A78",
  "Naive Risk Parity" = "#D9A514",
  "Risk Parity" = "#4A7C6F"
)

ggplot(
  wealth_long,
  aes(
    x = Date,
    y = Wealth,
    color = Portfolio
  )
) +
  geom_line(
    linewidth = 1
  ) +
  scale_color_manual(
    values = chapter4_colors
  ) +
  scale_x_date(
    date_breaks = "1 year",
    date_labels = "%Y"
  ) +
  labs(
    title = "Cumulative Out-of-Sample Portfolio Wealth",
    subtitle = "Monthly rebalancing with a 60-month estimation window",
    x = NULL,
    y = "Value of an Initial Investment of 1",
    color = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    axis.text.x = element_text(
      angle = 45,
      hjust = 1
    ),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

By the end of the backtest, an initial investment of one unit grows to approximately:

  • 2.132 under Equal Weight;
  • 1.884 under Naive Risk Parity;
  • 2.015 under Risk Parity.

Equal Weight accumulated the most wealth because its higher realized return more than compensated for its higher volatility during this particular sample.

Rolling Realized Volatility

The full-sample annualized volatility summarizes average risk across the entire out-of-sample period. To examine how risk changed over time, a rolling 12-month volatility is calculated:

\[ \sigma_{t,12} = \operatorname{sd} \left( R_{p,t-11},\ldots,R_{p,t} \right) \sqrt{12}. \]

rolling_volatility <- apply(
  port_rets_simple,
  2,
  function(x) {
    
    zoo::rollapply(
      x,
      width = 12,
      FUN = sd,
      align = "right",
      fill = NA
    ) * sqrt(12)
  }
)

rownames(rolling_volatility) <-
  rownames(port_rets_oos)

colnames(rolling_volatility) <-
  colnames(port_rets_oos)

rolling_vol_df <- data.frame(
  Date = as.Date(
    rownames(rolling_volatility)
  ),
  rolling_volatility,
  check.names = FALSE
)

rolling_vol_long <- rolling_vol_df |>
  tidyr::pivot_longer(
    cols = -Date,
    names_to = "Portfolio",
    values_to = "Annualized_Volatility"
  )
ggplot(
  rolling_vol_long,
  aes(
    x = Date,
    y = Annualized_Volatility,
    color = Portfolio
  )
) +
  geom_line(
    linewidth = 1,
    na.rm = TRUE
  ) +
  scale_color_manual(
    values = chapter4_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  scale_x_date(
    date_breaks = "1 year",
    date_labels = "%Y"
  ) +
  labs(
    title = "Rolling 12-Month Out-of-Sample Volatility",
    subtitle = "Annualized volatility based on realized monthly returns",
    x = NULL,
    y = "Annualized Volatility",
    color = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    axis.text.x = element_text(
      angle = 45,
      hjust = 1
    ),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

The realized volatility paths vary considerably over time, showing that portfolio risk is not constant. Risk Parity generally maintains a slightly lower volatility than the other strategies, although the difference is not large in every subperiod.

The comparison also illustrates why an optimized in-sample risk estimate is not sufficient: realized portfolio risk changes as the market environment and estimated covariance matrix evolve.

Portfolio Drawdowns

A drawdown measures the percentage decline from the highest previously observed wealth level.

drawdown_matrix <- apply(
  port_wealth_oos,
  2,
  function(x) {
    
    x /
      cummax(x) - 1
  }
)

rownames(drawdown_matrix) <-
  rownames(port_wealth_oos)

colnames(drawdown_matrix) <-
  colnames(port_wealth_oos)

drawdown_df <- data.frame(
  Date = as.Date(
    rownames(drawdown_matrix)
  ),
  drawdown_matrix,
  check.names = FALSE
)

drawdown_long <- drawdown_df |>
  tidyr::pivot_longer(
    cols = -Date,
    names_to = "Portfolio",
    values_to = "Drawdown"
  )
ggplot(
  drawdown_long,
  aes(
    x = Date,
    y = Drawdown,
    color = Portfolio
  )
) +
  geom_line(
    linewidth = 1
  ) +
  scale_color_manual(
    values = chapter4_colors
  ) +
  scale_y_continuous(
    labels = scales::percent_format(
      accuracy = 1
    )
  ) +
  scale_x_date(
    date_breaks = "1 year",
    date_labels = "%Y"
  ) +
  labs(
    title = "Out-of-Sample Portfolio Drawdowns",
    subtitle = "Percentage decline from the previous wealth peak",
    x = NULL,
    y = "Drawdown",
    color = "Portfolio"
  ) +
  theme_bw(base_size = 12) +
  theme(
    axis.text.x = element_text(
      angle = 45,
      hjust = 1
    ),
    panel.grid.minor = element_blank(),
    legend.position = "top",
    plot.title = element_text(
      hjust = 0.5,
      face = "bold"
    ),
    plot.subtitle = element_text(
      hjust = 0.5
    )
  )

The Risk Parity Portfolio records the smallest maximum drawdown at approximately

\[ -15.28%. \]

This compares with approximately

\[ -16.50% \]

for Equal Weight and

\[ -16.29% \]

for Naive Risk Parity.

The difference is modest, but it is consistent with the primary purpose of Risk Parity: reducing the concentration and magnitude of portfolio risk.

Return and Risk Trade-Off

The out-of-sample results reveal two different dimensions of performance.

Risk performance

Risk Parity produced:

  • the lowest annualized volatility;
  • the smallest maximum drawdown;
  • balanced in-sample risk contributions at every rebalancing date.

It therefore successfully achieved its principal risk-management objective.

Return performance

Equal Weight produced:

  • the highest geometric annual return;
  • the greatest final wealth;
  • a marginally higher Sharpe ratio.

The higher return of Equal Weight was sufficient to compensate for its higher volatility during this sample.

Performance dimension Best-performing strategy
Geometric annual return Equal Weight
Final wealth Equal Weight
Annualized volatility Risk Parity
Maximum drawdown Risk Parity
Sharpe ratio Equal Weight, by a small margin
Equality of risk contributions Risk Parity

Does Risk Parity Improve Risk-Adjusted Performance?

The answer depends on how risk-adjusted performance is defined.

Using the Sharpe ratio, Equal Weight performs slightly better:

\[ SR_{EWP} = 1.002, \]

compared with

\[ SR_{RPP} = 0.982. \]

Risk Parity therefore does not deliver the highest Sharpe ratio in this sample.

However, it produces both lower volatility and a smaller maximum drawdown. Thus, Risk Parity improves realized risk and downside-risk characteristics, even though its reduced return prevents it from ranking first according to the Sharpe ratio.

Interpretation

Lower portfolio risk does not automatically imply the highest Sharpe ratio. A strategy can produce lower volatility but still have a lower risk-adjusted return when another strategy earns sufficiently higher returns.

The result should not be interpreted as evidence that Equal Weight is universally superior. The ranking reflects the particular stocks, estimation window, and market period used in this backtest. Different samples may produce different return rankings.

Chapter 4 Conclusion

This chapter evaluated Equal Weight, Naive Risk Parity, and Risk Parity using a monthly walk-forward backtest with a 60-month estimation window.

At each rebalancing date, portfolio weights were estimated using only the preceding 60 monthly observations and applied to the following month’s return. This generated a chronological sequence of realized out-of-sample returns and avoided look-ahead bias.

The Risk Parity Portfolio successfully fulfilled its main objective. It produced the lowest realized annualized volatility at approximately

\[ 15.55% \]

and the smallest maximum drawdown at approximately

\[ -15.28%. \]

Nevertheless, Equal Weight generated the highest geometric annual return at approximately

\[ 16.35% \]

and the greatest final wealth. Its higher return resulted in a marginally higher Sharpe ratio than Risk Parity.

Naive Risk Parity reduced volatility relative to Equal Weight but produced the lowest annual return and final wealth during the sample.

Main finding

Risk Parity improved the realized risk profile of the portfolio by producing the lowest volatility and smallest maximum drawdown. However, it did not produce the highest overall risk-adjusted performance according to the Sharpe ratio because Equal Weight earned sufficiently higher returns over the 2019–2024 evaluation period.

The results demonstrate that minimizing and diversifying risk are not the same as maximizing realized return. Risk Parity may be attractive to investors who prioritize volatility control and downside-risk reduction, but its theoretical risk advantage does not guarantee the highest Sharpe ratio in every market period.


Overall Conclusion

This study examined whether the Risk Parity Portfolio improves risk allocation and out-of-sample performance relative to traditional portfolio-construction methods, and which computational approach is most suitable for its implementation.

The empirical results show that the main advantage of Risk Parity lies in risk diversification rather than return maximization. In the in-sample analysis, the portfolio achieved approximately equal risk contributions and lower volatility than Equal Weight. Although the Global Minimum Variance Portfolio produced the lowest volatility, its risk allocation was considerably more concentrated.

Naive Risk Parity captured much of the benefit of the optimized approach. By using inverse-volatility weights, it improved risk allocation and reduced volatility without requiring numerical optimization. However, the complete Risk Parity Portfolio produced more precise risk balancing because it also incorporated correlations.

The computational analysis confirmed that both the manually implemented Newton and Cyclical Coordinate Descent algorithms solved Spinu’s convex Risk Budgeting problem correctly. Their solutions were numerically equivalent to those produced by the riskParityPortfolio package. Since the package implementations were substantially faster, they were more suitable for the rolling out-of-sample analysis.

The walk-forward backtest provided a more nuanced result. Risk Parity achieved the lowest realized volatility and the smallest maximum drawdown, confirming its effectiveness as a risk-management strategy. Nevertheless, Equal Weight generated the highest return and a marginally higher Sharpe ratio during the 2019–2024 evaluation period.

Final answer

Risk Parity improved the portfolio’s risk allocation, realized volatility, and drawdown characteristics. However, it did not produce the highest Sharpe ratio because the Equal Weight Portfolio earned higher returns during the examined sample.

Overall, Risk Parity is most attractive when the investor’s priority is balanced risk exposure and volatility control. Naive Risk Parity may be sufficient when simplicity is preferred, while the optimized Risk Parity Portfolio is preferable when precise risk budgeting is required. Its theoretical risk advantages, however, do not guarantee the highest realized return in every market period.


Limitations and Further Research

The empirical results should be interpreted in light of several limitations.

First, the analysis used a specific selection of twenty S&P 500 stocks. Different securities, asset classes, or international markets may produce different results.

Second, the covariance matrix was estimated using a rolling window of 60 monthly observations. Alternative estimation windows could affect the stability of the weights and the relative performance of the strategies.

Third, transaction costs, taxes, bid–ask spreads, and portfolio turnover were not included. This limitation is particularly relevant because the three strategies may require different amounts of trading. Equal Weight changes only when the portfolio is rebalanced, whereas Naive Risk Parity and Risk Parity respond to updated volatility and covariance estimates. More frequent or larger weight adjustments may therefore generate higher turnover and reduce net out-of-sample performance once realistic trading costs are considered.

Fourth, the backtest covered a limited historical period. The relative performance of Equal Weight and Risk Parity may vary across market regimes, including recessions, periods of rising interest rates, and prolonged equity bull markets.

Future research could extend the analysis by:

  • comparing different rolling-window lengths;
  • measuring portfolio turnover and incorporating realistic transaction costs;
  • using covariance shrinkage estimators;
  • evaluating portfolios across several asset classes;
  • testing alternative risk budgets;
  • and examining performance over different market regimes.

These extensions would help determine whether the observed risk advantages of Risk Parity remain robust under alternative assumptions and market environments.


References

Palomar, D. P. Portfolio Optimization. Chapter 11: “Risk Parity Portfolios.” Available at: https://portfoliooptimizationbook.com/book/11-RPP.html.

Vinícius, Z., and Palomar, D. P. (2019). “Fast Design of Risk Parity Portfolios.” riskParityPortfolio package vignette. Available at: https://cran.r-project.org/web/packages/riskParityPortfolio/vignettes/RiskParityPortfolio.html.

Cardoso, J. V. de M., and Palomar, D. P. (2019). riskParityPortfolio: Design of Risk Parity Portfolios. R package. Available at: https://CRAN.R-project.org/package=riskParityPortfolio.

Palomar, D. P., and contributors. riskParityPortfolio: Design of Risk Parity Portfolios. GitHub repository. Available at: https://github.com/dppalomar/riskParityPortfolio.

Spinu, F. (2013). “An Algorithm for Computing Risk Parity Weights.” SSRN.

Griveau-Billion, T., Richard, J.-C., and Roncalli, T. (2013). “A Fast Algorithm for Computing High-Dimensional Risk Parity Portfolios.”

Feng, Y., and Palomar, D. P. (2015). “SCRIP: Successive Convex Optimization Methods for Risk Parity Portfolio Design.” IEEE Transactions on Signal Processing, 63(19), 5285–5300.


Appendix

This appendix contains the mathematical derivations, numerical examples, and additional implementation details supporting Chapter 3. These materials are included for completeness and reproducibility but are not essential for understanding the main empirical findings.

Appendix A - Derivation of the Gradient and Hessian

Deriving the Gradient

The objective is

\[ f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} - \sum_{i=1}^{N} b_i\log(x_i). \]

Derivative of the quadratic term

Because the covariance matrix is symmetric,

\[ \frac{\partial} {\partial\mathbf{x}} \left( \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} \right) = \boldsymbol{\Sigma}\mathbf{x}. \]

The factor \(1/2\) cancels the factor of 2 that arises when differentiating the quadratic expression.

Derivative of the logarithmic term

For one element,

\[ \frac{\partial} {\partial x_i} \left( -b_i\log(x_i) \right) = -\frac{b_i}{x_i}. \]

Combining all elements gives

\[ -\frac{\mathbf{b}}{\mathbf{x}}. \]

Therefore,

\[ \boxed{ \nabla f(\mathbf{x}) = \boldsymbol{\Sigma}\mathbf{x} - \frac{\mathbf{b}}{\mathbf{x}} } \]

Deriving the Hessian

The Hessian contains the second derivatives of the objective.

The derivative of

\[ \boldsymbol{\Sigma}\mathbf{x} \]

with respect to \(\mathbf{x}\) is

\[ \boldsymbol{\Sigma}. \]

For the logarithmic term,

\[ \frac{\partial} {\partial x_i} \left( -\frac{b_i}{x_i} \right) = \frac{b_i}{x_i^2}. \]

These derivatives affect only the corresponding diagonal entries. Therefore,

\[ \boxed{ H_f(\mathbf{x}) = \boldsymbol{\Sigma} + \operatorname{Diag} \left( \frac{\mathbf{b}}{\mathbf{x}^2} \right) } \]

The covariance matrix is positive semidefinite. The diagonal matrix contains strictly positive entries when \(b_i>0\) and \(x_i>0\). Consequently, the Hessian is positive definite under the usual assumptions.

This makes the optimization problem strictly convex and gives it a unique solution.


Appendix B - Objective, Gradient, and Hessian Functions in R

# Spinu's Vanilla Convex Risk Budgeting formulation
#
# Objective:
#
# f(x) = 0.5 * x' Sigma x - b' log(x)
#
# The vector x must remain strictly positive because log(x_i)
# is only defined when x_i > 0.

vanilla_objective.calc <- function(
    x,
    cov_mat,
    b
) {
  
  # First part:
  # 0.5 * x' Sigma x
  #
  # This is the quadratic risk-related component.
  quadratic_term <- 0.5 * as.numeric(
    t(x) %*% cov_mat %*% x
  )
  
  # Second part:
  # b' log(x) = sum_i b_i * log(x_i)
  #
  # log(x) is calculated element by element in R.
  logarithmic_term <- sum(
    b * log(x)
  )
  
  # Complete objective:
  # quadratic term minus logarithmic term
  objective_value <- quadratic_term -
    logarithmic_term
  
  return(objective_value)
}


# Gradient:
#
# grad f(x) = Sigma x - b / x
#
# In R, b / x performs element-by-element division.

gradient.calc <- function(
    x,
    cov_mat,
    b
) {
  
  # Covariance component: Sigma x
  covariance_component <- as.numeric(
    cov_mat %*% x
  )
  
  # Logarithmic component: b / x
  budget_component <- b / x
  
  # Combine the two components
  gradient <- covariance_component -
    budget_component
  
  return(gradient)
}


# Hessian:
#
# H(x) = Sigma + Diag(b / x^2)
#
# diag(b / x^2) creates a diagonal matrix with
# b_i / x_i^2 on the diagonal.

hessian.calc <- function(
    x,
    cov_mat,
    b
) {
  
  logarithmic_hessian <- diag(
    b / x^2
  )
  
  H <- cov_mat +
    logarithmic_hessian
  
  return(H)
}

Appendix C - Scaled Heuristic Initialization

Newton’s method and CCD both require a positive starting vector.

A simple possibility would be

\[ \mathbf{x}^{(0)} = \frac{\sqrt{\mathbf{b}}} {\boldsymbol{\sigma}}, \]

where the division is element by element. This resembles the inverse-volatility solution from Chapter 2.

The implementation used in this project follows the scaled heuristic described for Spinu’s formulation.

Begin with any positive reference vector

\[ \bar{\mathbf{x}}>0. \]

The reference vector is scaled as

\[ \mathbf{x} = t\bar{\mathbf{x}}. \]

The nonlinear equation is

\[ \mathbf{1}^{\top} \boldsymbol{\Sigma}\mathbf{x} = \mathbf{1}^{\top} \left( \frac{\mathbf{b}}{\mathbf{x}} \right). \]

Substitute

\[ \mathbf{x}=t\bar{\mathbf{x}}. \]

The left-hand side becomes

\[ \mathbf{1}^{\top} \boldsymbol{\Sigma} (t\bar{\mathbf{x}}) = t \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}}. \]

The right-hand side becomes

\[ \mathbf{1}^{\top} \left( \frac{\mathbf{b}} {t\bar{\mathbf{x}}} \right) = \frac{1}{t} \mathbf{1}^{\top} \left( \frac{\mathbf{b}} {\bar{\mathbf{x}}} \right). \]

Equating both sides gives

\[ t \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}} = \frac{1}{t} \mathbf{1}^{\top} \left( \frac{\mathbf{b}} {\bar{\mathbf{x}}} \right). \]

Multiplying by \(t\) gives

\[ t^2 \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}} = \mathbf{1}^{\top} \left( \frac{\mathbf{b}} {\bar{\mathbf{x}}} \right). \]

Therefore,

\[ \boxed{ t = \sqrt{ \frac{ \mathbf{1}^{\top} (\mathbf{b}/\bar{\mathbf{x}}) }{ \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}} } } } \]

and the starting vector becomes

\[ \boxed{ \mathbf{x}^{(0)} = \bar{\mathbf{x}} \sqrt{ \frac{ \mathbf{1}^{\top} (\mathbf{b}/\bar{\mathbf{x}}) }{ \mathbf{1}^{\top} \boldsymbol{\Sigma} \bar{\mathbf{x}} } } } \]

In the code, the reference vector is

\[ \bar{\mathbf{x}}=\mathbf{1}. \]

This gives a positive starting vector whose covariance and logarithmic gradient components are already on a similar numerical scale.

# Scaled heuristic initialization
#
# We begin with a positive reference vector x_bar = 1.
#
# The scaling factor is:
#
# t = sqrt(
#       [1' (b / x_bar)] /
#       [1' Sigma x_bar]
#     )
#
# The starting point is:
#
# x0 = t * x_bar

scaled_initialization.calc <- function(
    cov_mat,
    b
) {
  
  N <- length(b)
  
  # Positive reference vector:
  # x_bar = (1, ..., 1)'
  x_bar <- rep(
    1,
    N
  )
  
  # Vector of ones used in the summations
  one_vec <- rep(
    1,
    N
  )
  
  # Numerator:
  #
  # 1' (b / x_bar)
  #
  # Since x_bar contains only ones, this is equal to sum(b).
  # The general formula is retained for clarity.
  numerator <- as.numeric(
    t(one_vec) %*%
      (b / x_bar)
  )
  
  # Denominator:
  #
  # 1' Sigma x_bar
  #
  # This measures the scale of the covariance component.
  denominator <- as.numeric(
    t(one_vec) %*%
      cov_mat %*%
      x_bar
  )
  
  # Calculate the positive scaling factor t
  scaling_factor <- sqrt(
    numerator /
      denominator
  )
  
  # Construct the positive starting point
  x0 <- scaling_factor *
    x_bar
  
  names(x0) <- colnames(
    cov_mat
  )
  
  return(x0)
}

Appendix D - Derivation of Newton’s Update

Newton’s method uses the gradient and Hessian to construct a quadratic approximation of the objective around the current point.

The update is

\[ \boxed{ \mathbf{x}^{k+1} = \mathbf{x}^{k} - H_f(\mathbf{x}^{k})^{-1} \nabla f(\mathbf{x}^{k}) } \]

where:

  • \(\mathbf{x}^{k}\) is the current vector;
  • \(\nabla f(\mathbf{x}^{k})\) is the gradient;
  • \(H_f(\mathbf{x}^{k})\) is the Hessian;
  • \(H^{-1}\nabla f\) determines the Newton step.

Where does the update come from?

Around the current point, the objective is approximated using a second-order Taylor expansion:

\[ f(\mathbf{x}^{k}+\mathbf{d}) \approx f(\mathbf{x}^{k}) + \nabla f(\mathbf{x}^{k})^\top\mathbf{d} + \frac{1}{2} \mathbf{d}^\top H_f(\mathbf{x}^{k}) \mathbf{d}. \]

To find the step \(\mathbf{d}\) that minimizes this approximation, differentiate with respect to \(\mathbf{d}\):

\[ \nabla f(\mathbf{x}^{k}) + H_f(\mathbf{x}^{k}) \mathbf{d} = \mathbf{0}. \]

Therefore,

\[ H_f(\mathbf{x}^{k})\mathbf{d} = - \nabla f(\mathbf{x}^{k}), \]

and

\[ \mathbf{d} = - H_f(\mathbf{x}^{k})^{-1} \nabla f(\mathbf{x}^{k}). \]

Since

\[ \mathbf{x}^{k+1} = \mathbf{x}^{k} + \mathbf{d}, \]

we obtain the Newton update.

Numerical implementation

The code does not explicitly calculate the matrix inverse

solve(H) %*% grad

Instead, it solves

\[ H\mathbf{s} = \nabla f \]

using

solve(H, grad)

and then performs

\[ \mathbf{x}_{new} = \mathbf{x}-\mathbf{s}. \]

This is numerically more stable and computationally preferable.


Appendix E - Small Newton Example

The following simple example illustrates one Newton update.

Suppose

\[ \boldsymbol{\Sigma} = \begin{pmatrix} 0.04 & 0.01\\ 0.01 & 0.09 \end{pmatrix}, \qquad \mathbf{b} = \begin{pmatrix} 0.5\\ 0.5 \end{pmatrix} \]

and

\[ \mathbf{x}^{(0)} = \begin{pmatrix} 1\\ 1 \end{pmatrix}. \]

The covariance component of the gradient is

\[ \boldsymbol{\Sigma}\mathbf{x}^{(0)} = \begin{pmatrix} 0.05\\ 0.10 \end{pmatrix}. \]

The budget component is

\[ \frac{\mathbf{b}} {\mathbf{x}^{(0)}} = \begin{pmatrix} 0.5\\ 0.5 \end{pmatrix}. \]

Therefore,

\[ \nabla f(\mathbf{x}^{(0)}) = \begin{pmatrix} -0.45\\ -0.40 \end{pmatrix}. \]

The gradient is not zero, so the starting point is not optimal. Newton’s method uses the Hessian to calculate a better point.

# Two-asset covariance matrix
Sigma_example <- matrix(
  c(
    0.04, 0.01,
    0.01, 0.09
  ),
  nrow = 2,
  byrow = TRUE
)

# Equal risk budgets
b_example <- c(
  0.5,
  0.5
)

# Starting vector
x_example <- c(
  1,
  1
)

# Objective before the Newton update
objective_before <- vanilla_objective.calc(
  x = x_example,
  cov_mat = Sigma_example,
  b = b_example
)

# Gradient at the starting point
gradient_example <- gradient.calc(
  x = x_example,
  cov_mat = Sigma_example,
  b = b_example
)

# Hessian at the starting point
hessian_example <- hessian.calc(
  x = x_example,
  cov_mat = Sigma_example,
  b = b_example
)

# Solve:
#
# H * step = gradient
newton_step_example <- as.numeric(
  solve(
    hessian_example,
    gradient_example
  )
)

# Perform one Newton update
x_example_new <- x_example -
  newton_step_example

# Objective after the update
objective_after <- vanilla_objective.calc(
  x = x_example_new,
  cov_mat = Sigma_example,
  b = b_example
)

data.frame(
  Quantity = c(
    "Objective before update",
    "Objective after update"
  ),
  Value = c(
    objective_before,
    objective_after
  )
)
##                  Quantity      Value
## 1 Objective before update  0.0750000
## 2  Objective after update -0.3330958

The objective decreases after the Newton update. The procedure is repeated until the gradient is sufficiently close to zero.


Appendix F - Derivation of the Cyclical Coordinate Update

Newton’s method updates all elements of \(\mathbf{x}\) simultaneously. Cyclical Coordinate Descent updates only one element \(x_i\) at a time while holding all other elements fixed.

For Spinu’s objective,

\[ f(\mathbf{x}) = \frac{1}{2} \mathbf{x}^{\top} \boldsymbol{\Sigma} \mathbf{x} - \mathbf{b}^{\top}\log(\mathbf{x}), \]

the coordinate-specific minimization problem is

\[ \min_{x_i\geq0} \quad \frac{1}{2} x_i^2\Sigma_{ii} + x_i \left( \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} \right) - b_i\log(x_i). \]

Here:

  • \(\mathbf{x}_{-i}\) is the vector \(\mathbf{x}\) without element \(i\);
  • \(\boldsymbol{\Sigma}_{-i,i}\) is column \(i\) of the covariance matrix without element \(\Sigma_{ii}\).

Differentiate the coordinate-specific objective with respect to \(x_i\):

\[ \Sigma_{ii}x_i + \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} - \frac{b_i}{x_i} = 0. \]

Multiply by \(x_i\):

\[ \Sigma_{ii}x_i^2 + \left( \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} \right)x_i - b_i = 0. \]

Define

\[ c_i = \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i}. \]

The equation becomes

\[ \Sigma_{ii}x_i^2 + c_ix_i - b_i = 0. \]

This is a quadratic equation of the form

\[ ax^2+cx+d=0. \]

Using the quadratic formula gives

\[ x_i = \frac{ -c_i \pm \sqrt{ c_i^2 + 4\Sigma_{ii}b_i } }{ 2\Sigma_{ii} }. \]

Since \(x_i\) must remain positive, only the positive solution is used:

\[ \boxed{ x_i = \frac{ -\mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} + \sqrt{ \left( \mathbf{x}_{-i}^{\top} \boldsymbol{\Sigma}_{-i,i} \right)^2 + 4\Sigma_{ii}b_i } }{ 2\Sigma_{ii} } } \]

After updating \(x_i\), the algorithm immediately uses the new value when updating the next coordinate. This is why the method is described as cyclical and is related to the Gauss-Seidel method.

Main difference from Newton’s method

Newton’s method updates the entire vector at once using the gradient and Hessian. CCD solves a sequence of one-dimensional quadratic problems and updates the coordinates one after another.


Appendix G - Additional Benchmark Code

The following functions were used to generate the computational comparisons reported in Chapter 3. They are placed in the appendix because the main chapter focuses on the benchmark results rather than the full benchmarking procedure.

Initial Newton Runtime Benchmark

# Warm-up calls
invisible(
  newton_rpp.calc(
    cov_mat = cov_mat,
    b = b_ch3
  )
)

invisible(
  riskParityPortfolio::riskParityPortfolio(
    Sigma = cov_mat,
    b = b_ch3,
    method_init = "newton"
  )
)

# Compare the two Newton implementations over 1,000 runs
benchmark_newton <- microbenchmark::microbenchmark(
  
  Manual_Newton = {
    newton_rpp.calc(
      cov_mat = cov_mat,
      b = b_ch3
    )
  },
  
  Package_Newton = {
    riskParityPortfolio::riskParityPortfolio(
      Sigma = cov_mat,
      b = b_ch3,
      method_init = "newton"
    )
  },
  
  times = 1000L
)

# Convert the reported times into milliseconds
benchmark_summary <- summary(
  benchmark_newton
)

benchmark_summary$Median_ms <-
  benchmark_summary$median / 1e6

benchmark_summary$Mean_ms <-
  benchmark_summary$mean / 1e6

Because the benchmark performs many repeated optimization calls, it is shown for reproducibility but is not executed during every knit.

Newton Benchmark across Portfolio Sizes

benchmark_newton_size <- function(
    returns_mat,
    n_assets,
    times = 200L
) {
  
  # Ensure that enough assets are available.
  if (n_assets > ncol(returns_mat)) {
    stop(
      "Not enough assets available in returns_mat."
    )
  }
  
  # Select the first n_assets return series.
  returns_subset <- returns_mat[
    ,
    seq_len(n_assets),
    drop = FALSE
  ]
  
  # Estimate the covariance matrix.
  Sigma <- cov.calc(
    returns_subset,
    type = "MLE"
  )
  
  # Equal target risk budgets.
  b <- rep(
    1 / n_assets,
    n_assets
  )
  
  # Warm-up runs
  invisible(
    newton_rpp.calc(
      cov_mat = Sigma,
      b = b
    )
  )
  
  invisible(
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "newton"
    )
  )
  
  # Obtain both weight vectors for the accuracy comparison.
  w_manual <- newton_rpp.calc(
    cov_mat = Sigma,
    b = b
  )$w
  
  w_package <-
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "newton"
    )$w
  
  max_weight_difference <- max(
    abs(
      w_manual -
        w_package
    )
  )
  
  # Repeated runtime comparison
  timing <- microbenchmark::microbenchmark(
    
    Manual_Newton = {
      newton_rpp.calc(
        cov_mat = Sigma,
        b = b
      )
    },
    
    Package_Newton = {
      riskParityPortfolio::riskParityPortfolio(
        Sigma = Sigma,
        b = b,
        method_init = "newton"
      )
    },
    
    times = as.integer(times)
  )
  
  timing_summary <- summary(
    timing,
    unit = "ms"
  )
  
  data.frame(
    Assets = n_assets,
    Method = as.character(
      timing_summary$expr
    ),
    Median_ms = timing_summary$median,
    Mean_ms = timing_summary$mean,
    Max_Weight_Difference =
      max_weight_difference
  )
}

Combined Four-Method Benchmark

benchmark_all_methods <- function(
    returns_mat,
    n_assets,
    times = 200L
) {
  
  if (n_assets > ncol(returns_mat)) {
    stop(
      "Not enough assets available."
    )
  }
  
  # Select the required number of assets.
  returns_subset <- returns_mat[
    ,
    seq_len(n_assets),
    drop = FALSE
  ]
  
  # Estimate the covariance matrix.
  Sigma <- cov.calc(
    returns_subset,
    type = "MLE"
  )
  
  # Equal target risk budgets.
  b <- rep(
    1 / n_assets,
    n_assets
  )
  
  # Warm-up runs
  invisible(
    newton_rpp.calc(
      cov_mat = Sigma,
      b = b
    )
  )
  
  invisible(
    ccd_rpp.calc(
      cov_mat = Sigma,
      b = b
    )
  )
  
  invisible(
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "newton"
    )
  )
  
  invisible(
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "cyclical-spinu"
    )
  )
  
  # Portfolio weights for the accuracy comparison
  w_manual_newton <- newton_rpp.calc(
    cov_mat = Sigma,
    b = b
  )$w
  
  w_manual_ccd <- ccd_rpp.calc(
    cov_mat = Sigma,
    b = b
  )$w
  
  w_package_newton <-
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "newton"
    )$w
  
  w_package_ccd <-
    riskParityPortfolio::riskParityPortfolio(
      Sigma = Sigma,
      b = b,
      method_init = "cyclical-spinu"
    )$w
  
  # Numerical accuracy
  diff_manual_newton_package <- max(
    abs(
      w_manual_newton -
        w_package_newton
    )
  )
  
  diff_manual_ccd_package <- max(
    abs(
      w_manual_ccd -
        w_package_ccd
    )
  )
  
  diff_newton_ccd <- max(
    abs(
      w_manual_newton -
        w_manual_ccd
    )
  )
  
  # Runtime benchmark
  timing <- microbenchmark::microbenchmark(
    
    Manual_Newton = {
      newton_rpp.calc(
        cov_mat = Sigma,
        b = b
      )
    },
    
    Manual_CCD = {
      ccd_rpp.calc(
        cov_mat = Sigma,
        b = b
      )
    },
    
    Package_Newton = {
      riskParityPortfolio::riskParityPortfolio(
        Sigma = Sigma,
        b = b,
        method_init = "newton"
      )
    },
    
    Package_CCD = {
      riskParityPortfolio::riskParityPortfolio(
        Sigma = Sigma,
        b = b,
        method_init = "cyclical-spinu"
      )
    },
    
    times = as.integer(times)
  )
  
  timing_summary <- summary(
    timing,
    unit = "ms"
  )
  
  data.frame(
    Assets = n_assets,
    Method = as.character(
      timing_summary$expr
    ),
    Median_ms = timing_summary$median,
    Mean_ms = timing_summary$mean,
    Manual_Newton_vs_Package =
      diff_manual_newton_package,
    Manual_CCD_vs_Package =
      diff_manual_ccd_package,
    Manual_Newton_vs_CCD =
      diff_newton_ccd
  )
}

The complete benchmark functions are retained to document how the reported accuracy and runtime results were generated.