# basic finance
library(xts) # to manipulate time series of stock data
library(PerformanceAnalytics) # to compute performance measures
library(portfolioBacktest) # to conduct backtests
library(pob) # book package with financial data
# install with: devtools::install_github("dppalomar/pob")
library(fitHeavyTail) # to fit the multivariate skewed t distribution
library(highOrderPortfolios) # package for high-order portfolios
# plotting
library(ggplot2) # for nice plots
library(reshape2) # to reshape data
# optimization
library(CVXR)
library(quadprog)
library(nloptr)
Portfolio Optimization
Chapter 9: High-Order Portfolios
R code
R code examples for Chapter 9 of the book:
Daniel P. Palomar (2024). Portfolio Optimization: Theory and Application. Cambridge University Press.
Loading packages
The following packages are used in the examples:
Market data and benchmark portfolios
For illustrative purposes, we simply choose a few stocks from the S&P 500 index during 2015-2019.
data(SP500_2015to2020)
<- SP500_2015to2020$stocks[, sample(ncol(SP500_2015to2020$stocks), 10)]
stock_prices
log(stock_prices) |>
fortify(melt = TRUE) |>
ggplot(aes(x = Index, y = Value, color = Series)) +
geom_line(linewidth = 1) +
scale_x_date(date_breaks = "6 months", date_labels = "%b %Y", date_minor_breaks = "1 week") +
labs(title = "Log-prices", x = "", y = "", color = "stocks")
As benchmark portfolios, we consider the following:
Global maximum return portfolio (GMRP): \[ \begin{array}{ll} \underset{\w}{\textm{maximize}} & \w^\T\bmu\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}; \end{array} \]
Global minimum variance portfolio (GMVP): \[ \begin{array}{ll} \underset{\w}{\textm{minimize}} & \w^\T\bSigma\w\\ \textm{subject to} & \bm{1}^\T\w=1, \quad \w\ge\bm{0}. \end{array} \]
<- function(dataset, ...) {
GMRP <- ncol(dataset$prices)
N <- diff(log(dataset$prices))[-1]
X <- colMeans(X)
mu <- which.max(mu)
i_max <- rep(0, N)
w <- 1
w[i_max] return(w)
}
<- function(Sigma) {
design_GMVP <- Variable(nrow(Sigma))
w <- Problem(Minimize(quad_form(w, Sigma)),
prob constraints = list(w >= 0, sum(w) == 1))
<- solve(prob)
result <- as.vector(result$getValue(w))
w return(w)
}
<- function(dataset, ...) {
GMVP <- ncol(dataset$prices)
N <- diff(log(dataset$prices))[-1]
X <- cov(X)
Sigma <- design_GMVP(Sigma)
w return(w)
}
MVSK portfolio
The mean-variance-skewness-kurtosis (MVSK) portfolio is formulated as \[ \begin{array}{ll} \underset{\w}{\textm{minimize}} & - \lambda_1 \phi_1(\w) + \lambda_2 \phi_2(\w) - \lambda_3 \phi_3(\w) + \lambda_4 \phi_4(\w)\\ \textm{subject to} & \w \in \mathcal{W}, \end{array} \] where the hyper-parameters can be chosen, for example, as \(\lambda_1 = 1\), \(\lambda_2 = \frac{\gamma}{2}\), \(\lambda_3 = \frac{\gamma(\gamma + 1)}{6}\), and \(\lambda_4 = \frac{\gamma(\gamma + 1)(\gamma + 2)}{24}\).
We will consider three different ways to solve the problem, namely:
- simply by using an off-the-shelf nonlinear solver (e.g.,
nloptr
); - implementing the SCA-Q-MVSK method described in (Palomar, 2024, Algorithm 9.2) (see (Zhou and Palomar, 2021) for more details); and
- conveniently using the MVSK solver implemented in the R package
highOrderPortfolios
.
Recall that under the skewed \(t\) distribution (Palomar, 2024, Chapter 9):
- The moments are given by \[ \begin{aligned} \phi_1(\w) & = \w^\T\bmu + a_1 \w^\T\bm{\gamma}\\ \phi_2(\w) & = a_{21}\w^\T\bSigma\w + a_{22}(\w^\T\bm{\gamma})^2\\ \phi_3(\w) & = a_{31}(\w^\T\bm{\gamma})^3 + a_{32}(\w^\T\bm{\gamma})\w^\T\bSigma\w\\ \phi_4(\w) & = a_{41}(\w^\T\bm{\gamma})^4 + a_{42}(\w^\T\bm{\gamma})^2\w^\T\bSigma\w + a_{43}(\w^\T\bSigma\w)^2, \end{aligned} \] where \(a_1=\frac{\nu}{\nu-2}\), \(a_{21}=a_1\), \(a_{22}=\frac{2\nu^2}{(\nu-2)^2(\nu-4)}\), \(a_{31}=\frac{16\nu^3}{(\nu-2)^3(\nu-4)(\nu-6)}\), \(a_{32}=\frac{6\nu^2}{(\nu-2)^2(\nu-4)}\), \(a_{41}=\frac{(12\nu+120)\nu^4}{(\nu-2)^4(\nu-4)(\nu-6)(\nu-8)}\), \(a_{42}=\frac{6(2\nu+4)\nu^3}{(\nu-2)^3(\nu-4)(\nu-6)}\), and \(a_{43}=\frac{3\nu^2}{(\nu-2)(\nu-4)}\). Therefore, the code for the objective function \(f(\w) = - \lambda_1 \phi_1(\w) + \lambda_2 \phi_2(\w) - \lambda_3 \phi_3(\w) + \lambda_4 \phi_4(\w)\) is
<- function(nu) {
compute_a <- a11 <- nu/(nu-2)
a21 <- (2 * nu ** 2)/((nu - 2) ** 2 * (nu - 4))
a22 <- 16 * (nu ** 3)/(((nu - 2) ** 3) * (nu - 4) * (nu - 6))
a31 <- 6 * (nu ** 2)/(((nu -2) ** 2) * (nu - 4))
a32 <- (12 * nu + 120) * (nu ** 4)/ (((nu - 2) ** 4) * (nu - 4) * (nu - 6) * (nu - 8))
a41 <- (2 * nu + 4) * (nu ** 3)/(((nu - 2) ** 3) * (nu - 4) * (nu - 6)) * 6
a42 <- (nu ** 2)/((nu - 2) * (nu - 4)) * 3
a43 return(list("a11" = a11, "a21" = a21, "a22" = a22,
"a31" = a31, "a32" = a32,
"a41" = a41, "a42" = a42, "a43" = a43))
}
<- function(w, lambda, parameters) {
obj_value_skewt <- t(w) %*% parameters$mu
wT_mu <- t(w) %*% parameters$gamma
wT_gamma <- sum((parameters$chol_Sigma %*% w) ** 2)
wT_Sigma_w <- - lambda[1] * (wT_mu + parameters$a$a11 * wT_gamma) +
obj 2] * (parameters$a$a21 * wT_Sigma_w + parameters$a$a22 * (wT_gamma**2) ) -
lambda[3] * (parameters$a$a31 * (wT_gamma**3) + parameters$a$a32 * wT_gamma * wT_Sigma_w) +
lambda[4] * (parameters$a$a41 * (wT_gamma**4) + parameters$a$a42 * wT_Sigma_w * (wT_gamma**2) + parameters$a$a43 * (wT_Sigma_w**2))
lambda[return(as.numeric(obj))
}
- The gradients of the moments are \[ \begin{aligned} \nabla\phi_1(\w) &= \bmu + a_1 \bm{\gamma}\\ \nabla\phi_2(\w) &= 2a_{21}\bSigma\w + 2a_{22}(\w^\T\bm{\gamma})\bm{\gamma}\\ \nabla\phi_3(\w) &= 3a_{31}(\w^\T\bm{\gamma})^2\bm{\gamma} + a_{32}\left((\w^\T\bSigma\w)\bm{\gamma} + 2(\w^\T\bm{\gamma})\bSigma\w\right)\\ \nabla\phi_4(\w) &= 4a_{41}(\w^\T\bm{\gamma})^3\bm{\gamma}\\ &+ 2a_{42}\left((\w^\T\bm{\gamma})^2\bSigma\w + (\w^\T\bSigma\w)(\w^\T\bm{\gamma})\bm{\gamma}\right) + 4a_{43}(\w^\T\bSigma\w)\bSigma\w\\ \end{aligned} \] so the code for the gradient of the objective function is
<- function(w, lambda, parameters) {
grad_f_skewt <- as.numeric(t(w) %*% parameters$gamma)
wT_gamma <- sum((parameters$chol_Sigma %*% w) ** 2)
wT_Sigma_w <- - lambda[1] * (parameters$mu + parameters$a$a11 * parameters$gamma) +
grad 2] * (2 * parameters$a$a21 * parameters$scatter %*% w +
lambda[$a$a22 * 2 * wT_gamma * as.matrix(parameters$gamma)) -
parameters3] * (parameters$a$a31 * 3 * (wT_gamma**2) * as.matrix(parameters$gamma) +
lambda[$a$a32 * wT_Sigma_w * as.matrix(parameters$gamma) +
parameters2 * wT_gamma * parameters$scatter %*% w) +
4] * (4 * parameters$a$a41 * (wT_gamma**3) *as.matrix(parameters$gamma) +
lambda[$a$a42 * (2 * (wT_gamma**2) * parameters$scatter %*% w +
parameters2 * wT_Sigma_w * wT_gamma * as.matrix(parameters$gamma)) +
4 * parameters$a$a43 * wT_Sigma_w * parameters$scatter %*% w)
return(grad)
}
- The Hessians of the moments are \[ \begin{aligned} \nabla^2\phi_1(\w) &= \bm{0}\\ \nabla^2\phi_2(\w) &= 2a_{21}\bSigma + 2a_{22}\bm{\gamma}\bm{\gamma}^\T\\ \nabla^2\phi_3(\w) &= 6a_{31}(\w^\T\bm{\gamma})\bm{\gamma}\bm{\gamma}^\T + 2a_{32}\left(\bm{\gamma}\w^\T\bSigma + \bSigma\w\bm{\gamma}^\T + (\w^\T\bm{\gamma})\bSigma\right)\\ \nabla^2\phi_4(\w) &= 12a_{41}(\w^\T\bm{\gamma})^2\bm{\gamma}\bm{\gamma}^\T\\ &+ 2a_{42}\left(2(\w^\T\bm{\gamma})\bSigma\w\bm{\gamma}^\T + (\w^\T\bm{\gamma})^2\bSigma + 2(\w^\T\bm{\gamma})\bm{\gamma}\w^\T\bSigma + (\w^\T\bSigma\w)\bm{\gamma}\bm{\gamma}^\T\right)\\ &+ 4a_{43}\left(2\bSigma\w\w^\T\bSigma + (\w^\T\bSigma\w)\bSigma\right), \end{aligned} \] so the code for the Hessian of the objective function is
<- function(w, lambda, parameters) {
Hessian_f_skewt <- as.numeric(t(w) %*% parameters$gamma)
wT_gamma <- sum((parameters$chol_Sigma %*% w) ** 2)
wT_Sigma_w <- parameters$scatter %*% w
Sigma_w
<- 2 * (parameters$a$a21 * parameters$scatter + parameters$a$a22 * parameters$gamma %*% t(parameters$gamma))
H_phi2 <- 6 * parameters$a$a31 * wT_gamma * parameters$gamma %*% t(parameters$gamma) + 2 * parameters$a$a32 * (parameters$gamma %*% t(Sigma_w) + Sigma_w %*% t(parameters$gamma) + wT_gamma * parameters$scatter)
H_phi3 <- 12 * parameters$a$a41 * (wT_gamma**2) * parameters$gamma %*% t(parameters$gamma) + 2 * parameters$a$a42 * (2 * wT_gamma * Sigma_w %*% t(parameters$gamma) + (wT_gamma**2) * parameters$scatter + 2 * wT_gamma * parameters$gamma %*% t(Sigma_w) + wT_Sigma_w * parameters$gamma %*% t(parameters$gamma)) + 4 * parameters$a$a43 * (wT_Sigma_w * parameters$scatter + 2 * Sigma_w %*% t(Sigma_w))
H_phi4
return(lambda[2] * H_phi2 - lambda[3] * H_phi3 + lambda[4] * H_phi4)
}
1. Using off-the-shelf nonlinear solver
We can then write the code to solve the MVSK portfolio invoking the solver nloptr
and using the package fitHeavyTail
to fit the multivariate skewed \(t\) distribution with the function fit_mvst()
:
# hyper-parameters
<- 10
xi <- c(0, xi/2, xi * (xi+1) / 6, xi * (xi + 1) * (xi + 2)/24)
lambda
<- function(dataset, ...) {
MVSK_nloptr <- ncol(dataset$prices)
N <- diff(log(dataset$prices))[-1]
X
# fit skewed t distribution to data
options(nu_min = 8.5)
<- fit_mvst(X)
parameters $a <- compute_a(parameters$nu)
parameters$chol_Sigma <- chol(parameters$scatter)
parameters
# define auxiliary function for equality constraints
<- function(w, lambda, parameters) {
eval_g_eq <- c(sum(w) - 1)
constr <- c(rep(1, N))
grad return(list("constraints" = constr,
"jacobian" = grad))
}
# call solver
<- nloptr(x0 = pmax(design_GMVP(parameters$scatter), 1e-5), #rep(1/N, N), parameters$scatter
res eval_f = obj_value_skewt,
eval_grad_f = grad_f_skewt,
lb = rep(0, N),
ub = rep(Inf, N),
eval_g_eq = eval_g_eq,
opts = list("algorithm" = "NLOPT_LD_SLSQP", maxeval = 2500, "xtol_rel" = 1e-4,
"local_opts" = list( "algorithm" = "NLOPT_LD_SLSQP",
"xtol_rel" = 1e-4,
"maxeval" = 2500)),
lambda = lambda,
parameters = parameters)
return(res$solution)
}
2. Using the SCA-based method
The SCA-Q-MVSK method is described in (Palomar, 2024, Algorithm 9.2) (see (Zhou and Palomar, 2021) for more details). The corresponding R code is:
<- function(Q, tau) {
project_PSD <- ncol(Q)
N <- eigen(Q)
eigen_Q <- eigen_Q$values
d if (min(d) < 0) {
which(d < 0)] <- 0
d[<- eigen_Q$vectors %*% diag(d) %*% t(eigen_Q$vectors)
Q
}return(Q + tau * diag(N))
}
<- function(dataset, ...) {
MVSK_Q_MVSK <- ncol(dataset$prices)
N <- diff(log(dataset$prices))[-1]
X
# fit skewed t distribution to data
options(nu_min = 8.5)
<- fit_mvst(X)
parameters $a <- compute_a(parameters$nu)
parameters$chol_Sigma <- chol(parameters$scatter)
parameters
# main loop
<- pmax(design_GMVP(parameters$scatter), 1e-5) # initial point
wk <- 0
iter while(iter < 100) {
<- iter + 1
iter <- wk
wk_old
<- Hessian_f_skewt(wk, lambda, parameters)
Qk <- grad_f_skewt(wk, lambda, parameters) - Qk %*% wk
qk <- project_PSD(Qk, tau = 1e-8)
Qk <- solve.QP(Dmat = Qk/norm(Qk,"2"), # for numerical stability we normalize with norm(Qk,"2")
wk dvec = -qk/norm(Qk,"2"),
Amat = t(rbind(rep(1, N), diag(N))),
bvec = c(1, rep(0, N)),
meq = 1)$solution
which(wk < 0)] <- 0 # just in case
wk[<- wk/sum(wk)
wk <- all(abs(wk - wk_old) <= .5 * 1e-8 * (abs(wk) + abs(wk_old)))
has_w_converged if (has_w_converged) break
}
return(wk)
}
3. Using the R package highOrderPortfolios
The R package highOrderPortfolios
conveniently implements algorithms to solve the MVSK portfolio and other variations, such as the MVSK tilting portfolio. See (Palomar, 2024, Chapter 9) and (Wang et al., 2023) for more details. The code is simply:
library(highOrderPortfolios)
<- function(dataset, ...) {
MVSK_package <- diff(log(dataset$prices))[-1]
X
# fit skewed t distribution to data
<- estimate_skew_t(X)
X_skew_t_params
# call solver
<- design_MVSK_portfolio_via_skew_t(lambda, X_skew_t_params,
w w_init = pmax(design_GMVP(X_skew_t_params$scatter), 1e-5),
method = "Q-MVSK")$w
return(w)
}
Sanity check of the different MVSK portfolio methods
We first make sure that the three described ways of solving the MVSK portfolio design agree:
<- MVSK_nloptr(dataset = list("prices" = stock_prices))
w_MVSK_nloptr <- MVSK_Q_MVSK(dataset = list("prices" = stock_prices))
w_MVSK_Q_MVSK <- MVSK_package(dataset = list("prices" = stock_prices))
w_MVSK_package
data.frame(
"stocks" = names(stock_prices),
"Nonlinear solver nloptr" = w_MVSK_nloptr,
"SCA-Q-MVSK" = w_MVSK_Q_MVSK,
"Package highOrderPortfolios" = w_MVSK_package,
check.names = FALSE) |>
melt(id.vars = "stocks") |>
ggplot(aes(x = stocks, y = value, fill = variable)) +
geom_bar(stat = "identity", position = "dodge", color = "black", width = 0.8) +
labs(fill = "Algorithms") +
ylab("weight") +
ggtitle("MVSK portfolio allocation for different algorithms")
Backtest
We are now ready to perform a backtest of the MVSK portfolio and the benchmarks. We will use the package portfolioBacktest
.
data(SP500_2015to2020)
<- SP500_2015to2020$stocks["2015::2019", sample(ncol(SP500_2015to2020$stocks), 20)]
stock_prices
<- portfolioBacktest(list("GMRP" = GMRP,
bt "GMVP" = GMVP,
"MVSK" = MVSK_Q_MVSK),
list("dataset1" = list("prices" = stock_prices)),
lookback = 252, optimize_every = 20, rebalance_every = 5)
listPortfoliosWithFailures(bt) # just in case there are errors
backtestChartCumReturn(bt) +
scale_x_date(date_breaks = "6 months", date_labels = "%b %Y", date_minor_breaks = "1 week") +
ggtitle("Cumulative return")
backtestChartDrawdown(bt) +
scale_x_date(date_breaks = "6 months", date_labels = "%b %Y", date_minor_breaks = "1 week") +
ggtitle("Drawdown")
<- backtestSummary(bt)
summary_bt summaryTable(summary_bt, type = "DT",
measures = c("annual return", "annual volatility", "max drawdown",
"Sharpe ratio", "Sortino ratio", "Sterling ratio",
"VaR (0.95)", "CVaR (0.95)"))
Multiple backtests
We now consider multiple randomized backtests (Palomar, 2024, Chapter 8) for a better characterization of the performance of the portfolios. Again, we will use the package portfolioBacktest
.
We take a dataset of \(N=20\) stocks over the period 2015-2020 and generate 100 resamples each with \(N=8\) randomly selected stocks and a random period of two years. Then we perform a walk-forward backtest with a lookback window of 1 year, reoptimizing the portfolio every month.
<- SP500_2015to2020$stocks[, sample(ncol(SP500_2015to2020$stocks), 20)]
stock_prices
# resample data
<- financialDataResample(list("prices" = stock_prices),
stock_prices_resampled num_datasets = 100, N_sample = 8, T_sample = 252*2)
# perform multiple backtest on the resampled data
<- portfolioBacktest(list("GMRP" = GMRP,
bt "GMVP" = GMVP,
"MVSK" = MVSK_Q_MVSK),
dataset_list = stock_prices_resampled,
lookback = 252, optimize_every = 20, rebalance_every = 5,
paral_datasets = 6)
listPortfoliosWithFailures(bt) # just in case there are errors
<- backtestSummary(bt)
summary_bt summaryTable(summary_bt, type = "simple",
measures = c("annual return", "annual volatility", "max drawdown",
"Sharpe ratio", "Sortino ratio", "Sterling ratio",
"VaR (0.95)", "CVaR (0.95)"))
annual return annual volatility max drawdown Sharpe ratio Sortino ratio
GMRP 0.09 0.26 0.20 0.33 0.45
GMVP 0.13 0.13 0.12 1.05 1.48
MVSK 0.14 0.13 0.12 1.15 1.58
Sterling ratio VaR (0.95) CVaR (0.95)
GMRP 0.41 0.03 0.04
GMVP 1.18 0.01 0.02
MVSK 1.29 0.01 0.02
summaryBarPlot(summary_bt, measures = c("max drawdown", "annual volatility"))
backtestBoxPlot(bt, "Sharpe ratio")
backtestBoxPlot(bt, "max drawdown")