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:
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.
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.
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 |
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%.
| 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.
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.
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
## [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.
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.
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.
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.
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.
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:
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 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.
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.
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.
The first comparison uses the same five stocks as Chapter 1:
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
## AAPL.OQ AMZN.OQ HD.N KO.N WMT.N
## 0.2164783 0.1979882 0.2140087 0.1888168 0.1827080
## [1] 0.04224007
| 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.
| 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.
# 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# 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.
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.
# 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
## [1] 1
## [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.
# 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
## 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
## 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
# 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.
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.
# 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
)
)# 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.
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% |
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
)
)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
)
)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:
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.
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.
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.
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.
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:
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.
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. \]
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)
}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 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.
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 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.
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
)
)
}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
## [1] TRUE
## [1] 5
## [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.
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.
# 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
## [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.
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
## [1] TRUE
## [1] 10
The manual CCD solver converged in 10 complete coordinate cycles.
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.
The final benchmark compares four implementations:
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.
| 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.
| 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.
| 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 |
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.
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:
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.
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:
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.
The analysis uses the twenty-stock investment universe introduced in Chapter 2. At every rebalancing date:
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 |
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
## [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 |
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)
}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
## [1] 1
## [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.
The backtest stores two types of results:
# 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
}
)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
)
}Several checks are performed before evaluating performance.
## [1] 60 3
## [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.
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.
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. \]
\[ \sigma_{\text{ann}} = \operatorname{sd}(R_{p,t}) \sqrt{12}. \]
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\).
# 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.
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:
Equal Weight accumulated the most wealth because its higher realized return more than compensated for its higher volatility during this particular sample.
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.
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.
The out-of-sample results reveal two different dimensions of performance.
Risk Parity produced:
It therefore successfully achieved its principal risk-management objective.
Equal Weight produced:
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 |
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.
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.
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.
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:
These extensions would help determine whether the observed risk advantages of Risk Parity remain robust under alternative assumptions and market environments.
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.
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.
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). \]
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.
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}} } \]
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.
# 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)
}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)
}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:
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.
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.
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:
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.
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.
# 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 / 1e6Because the benchmark performs many repeated optimization calls, it is shown for reproducibility but is not executed during every knit.
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
)
}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.