Portfolio selection with higher moments

This note was motivated by a question on Stack Exchange. We will look at selecting a portfolio based on mean, variance and skewnewss of its returns: the aim is to find a portfolio that minimises

\[ -\mathrm{mean}(x) + \gamma_1 \mathrm{variance}(x) - \gamma_2 \mathrm{skewness}(x) \]

in which x are the portfolio weights, and mean() and so on are functions that return the moments of the portfolio return. Note that typical investors like mean and skewness, and they dislike variance. But since we minimise, we invert the signs of those functions.

Suppose we are given a set of return scenarios, collected in a matrix R. Each column holds the returns of a particular asset; each row holds the returns of all assets in one scenario. Mean and variance of the assets' return we could easily get from R via the column means m and the variance-covariance matrix S. To arrive at the portfolio mean and variance, we then multiply those summary statistics with the weights (i.e. m'x and x'Sx). It turns out that we can do something similar with skewness and kurtosis; a concise summary of the formulas can for instance be found in the first section of Portfolio Decisions with Higher Order Moments. The trouble with that approach is that it is cumbersome and scales badly, since the necessary arrays get very large.

So we'll instead handle the model with scenario optimisation (see Gilli et al., 2019, chapter 14, for more details). For scenarios, we use a set of industry-portfolio returns: time-series of industry portfolios from Kenneth French's website at https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ The dataset comprises 30 series of monthly data, and we use a subset that starts in January 2010. (But note that practically, using historical data as scenarios may not be advisable; see e.g. Gilli et al., 2011.)

library("NMOF")
library("PMwR")
library("zoo")
P <- French(dest.dir = tempdir(),
            "30_Industry_Portfolios_CSV.zip",
            price.series = TRUE,            
            na.rm = TRUE, frequency = "monthly")

P <- zoo(P, as.Date(row.names(P)))
P <- window(P, start = as.Date("2010-1-1"))
R <- returns(P)

str(P)
str(R)
‘zoo’ series from 2010-01-31 to 2020-09-30
  Data: num [1:129, 1:30] 5095 5243 5476 5396 5106 ...
 - attr(*, "dimnames")=List of 2
  ..$ : chr [1:129] "2010-01-31" "2010-02-28" "2010-03-31" "2010-04-30" ...
  ..$ : chr [1:30] "Food" "Beer" "Smoke" "Games" ...
  Index:  Date[1:129], format: "2010-01-31" "2010-02-28" "2010-03-31" ...

‘zoo’ series from 2010-02-28 to 2020-09-30
  Data: num [1:128, 1:30] 0.0292 0.0444 -0.0146 -0.0537 -0.0197 ...
 - attr(*, "dimnames")=List of 2
  ..$ : chr [1:128] "2010-02-28" "2010-03-31" "2010-04-30" "2010-05-31" ...
  ..$ : chr [1:30] "Food" "Beer" "Smoke" "Games" ...
  Index:  Date[1:128], format: "2010-02-28" "2010-03-31" "2010-04-30" ...

Let us start with a simpler model, as a benchmark: minimising portfolio variance. A few constraints: full investment, no short-sales, maximum position weight is 20%. We'll solve the model with function minvar in the NMOF package.

Sigma <- cov(R)
x.mv <- minvar(Sigma, wmax = 0.2)
attr(x.mv, "variance")
[1] 0.0009141

The variance is computed from monthly returns; let us calculate a corresponding annual volatility.

sqrt(attr(x.mv, "variance")) * sqrt(12)
[1] 0.1047

Now, how would we handle this with scenario optimisation? We have already two ingredients: the scenario set R and a way to represent a solution (a numeric vector x). Next, we need an objective function. It maps a portfolio x into a real number. The lower this number, the better (recall that we minimise). The function p.var computes portfolio variance for a portfolio x, given a scenario set R.

p.var <- function(x, R, ...) {
  Rx <- c(R %*% x)
  var(Rx)
}

Let us try the function. No surprise: with the minimum-variance portfolio that we computed before, we get the same numeric result.

p.var(x.mv, R) 
[1] 0.0009141

Note that we did not compute x'Sx. Instead the function first computed Rx, i.e. the portfolio returns under the scenarios. The portfolio returns Rx are a univariate sample, so it is easy to evaluate any objective function on them.

The final ingredient is a method that evolves one or perhaps several initial solutions into the optimal – or at least a very good – one. For this note, we'll use a method called Threshold Accepting (TA). In fact, for this particular model, we could use a simpler method. But TA's advantage is its robustness when it comes to possible alternative objective functions. TA's only requirement is that the chosen objective function can be evaluated for a given portfolio x; it has no requirements concerning the shape or other properties of the objective function. The method is described in much detail in Gilli et al. (2019). We use the implementation in function TAopt in the NMOF package.

library("neighbours")   ## https://github.com/enricoschumann/neighbours
nb <- neighbourfun(type = "numeric", max = 0.2, stepsize = 0.2/100)

algo.settings <- list(nI = 20000,                    ## number of iterations
                      x0 = rep(1/ncol(R), ncol(R)),  ## initial solution
                      neighbour = nb)
sol <- TAopt(p.var,
             algo = algo.settings,
             R = R)
sol$xbest     ## the best portfolio found by TA
sol$OFvalue   ## the objective function of this portfolio
[1] 0.0009141

We get the same result as with function minvar. Now we can move to the actual problem. All we need is a new objective function. Since R (the language, not the matrix) does not have a built-in skewness function, we use the one provided by package e1071.

mom3 <- function(x, R, gamma) {
    Rx <- c(R %*% x)
    -mean(Rx) + gamma[1]*var(Rx) - gamma[2]*e1071::skewness(Rx)
}

We run the algorithm, for some value of \(\gamma\).

sol.mom3 <- TAopt(mom3,
                  algo = algo.settings,
                  R = R,
                  gamma = c(1, 1))
sol.mom3$OFvalue
[1] -0.3638

For this model, we do not have a benchmark implementation. So it becomes more difficult to judge the quality of the solution. In particular, since TA is a stochastic algorithm, so repeated runs of the algorithm may result in different solutions. TAopt simply runs a loop with a fixed number of iterations, for which I have chosen 20,000. How could I know that this was an appropriate number? I couldn't. Instead, I ran a number of small experiments: I restarted the algorithm a number of times (in the code below, 50 times) with different numbers of iterations. For all runs, I recorded the objective-function value of the solution. (See http://enricoschumann.net/R/remarks.htm for a lengthier discussion.)

n <- 50 ## number of restarts

iterations <- c(2000, 5000, 10000, 20000, 30000, 50000)

restarts <- vector("list", length(iterations))
names(restarts) <- as.character(iterations)

for (i in iterations) {
  algo.settings$nI <- i
  r <- restartOpt(TAopt, n = n, OF = mom3,
                  algo = algo.settings,
                  R = R, gamma = c(1, 1))

  restarts[[ as.character(i) ]] <- sapply(r, `[[`, "OFvalue")

}

for (i in seq_along(iterations)) {
  if (i == 1)
    plot(ecdf(restarts[[i]]),
         xlim = range(restarts),
         do.points = FALSE,
         verticals = TRUE, main = "",
         xlab = "Objective function value")
  else 
    lines(ecdf(restarts[[i]]),
          do.points = FALSE,
          verticals = TRUE)
}

portfolio-dist.png

The figure shows the empirical distribution functions of the objective-function values for 2000, 5000, … iterations. There is not even a need to label them. With increasing numbers of iterations, the distributions move to the left and become steeper (i.e. solutions become better and less variable). For 20000 iterations and more, one can hardly distinguish betweens the distribution functions any more. In other words, every restart of the algorithm returned, in terms of solution quality, the same solution.

@ARTICLE{,
  author       = {Manfred Gilli and Enrico Schumann and
                  Giacomo {di Tollo} and Gerda Cabej},
  title        = {Constructing 130/30-Portfolios with the {O}mega Ratio},
  journal      = {Journal of Asset Management},
  year         = 2011,
  volume       = 12,
  pages        = {94--108},
  number       = 2
  doi          = {10.1057/jam.2010.25}
}

@BOOK{,
  author       = {Gilli, Manfred and Maringer, Dietmar and Schumann, Enrico},
  title        = {Numerical Methods and Optimization in Finance},
  publisher    = {Elsevier/Academic Press},
  year         = 2019,
  edition      = 2,
  url          = {http://nmof.net},
  doi          = {10.1016/C2017-0-01621-X}
}