Random portfolios: correlation clustering

We investigate whether two clustering techniques, k-means clustering and hierarchical clustering, can improve the risk-adjusted return of a random equity portfolio. We find that both techniques yield significantly higher Sharpe ratios compared to random portfolio with hierarchical clustering coming out on top.

Our debut blog post Towards a better benchmark: random portfolios resulted in a lot of feedback (thank you) and also triggered some thoughts about the diversification aspect of random portfolios. Inspired by the approach in our four-factor equity model we want to investigate whether it is possible to lower the variance of the portfolios by taking account of the correlation clustering present in the data. On the other hand, we do not want to stray too far from the random portfolios idea due to its simplicity.

We will investigate two well-known clustering techniques today, k-means clustering and hierarchical clustering (HCA), and see how they stack up against the results from random portfolios. We proceed by calculating the sample correlation matrix over five years (60 monthly observations) across the S&P 500 index’s constituents every month in the period 2000-2015, i.e. 192 monthly observations. We then generate 50 clusters using either k-means (100 restarts) or HCA on the correlation matrix and assign all equities to a cluster. From each of these 50 clusters we randomly pick one stock and include it in our portfolio for the following month. This implies that our portfolios will have 50 holdings every month. (None of the parameters used have been optimized.)


The vertical dark-grey line represents the annualized return of a buy-and-hold strategy in the S&P 500 index.

The mean random portfolio returns 9.1% on an annualized basis compared to the benchmark S&P 500 index, which returns 4.1%. The k-means algorithm improves a tad on random portfolios with 9.2% while HCA delivers 9.5%. Not a single annualized return from the 1,000 portfolios is below the buy-and-hold return no matter whether we look at random portfolios, k-means or HCA.

We have added a long-only minimum variance portfolio (MinVar) to the mix. The 1,000 MinVar portfolios use the exact same draws as the random portfolios, but then proceed to assign weights so they minimize the portfolios’ variances using the 1,000 covariance matrices rather than simply use equal weights.

Unsurprisingly, MinVar delivers a mean return well below the others at 8.1%. The weights in the MinVar portfolios are only constrained to 0-100% implying that in any given MinVar portfolio a few equities may dominate in terms of weights while the rest have weights very close to 0%. As a result, the distribution of the annualized mean returns of the MinVar portfolios is much wider and 1.2% of the portfolio (12) are below the S&P 500 index’s annualized mean.


The vertical dark-grey line represents the annualized Sharpe ratio of a buy-and-hold strategy in the S&P 500 index.

Turning to the annualized Sharpe ratio using for simplicity a risk-free rate of 0%, we find that a buy-and hold strategy yields a Sharpe ratio of 0.25. Random portfolios, meanwhile, yields 0.46 while k-means and HCA yield 0.49 and 0.58, respectively.

The k-means and HCA algorithms result in lower volatility compared to standard random portfoliios (see table below). While the random portfolios have a mean annualized standard deviation of 19.8%, k-means and HCA have mean standard deviations of 18.7% and 16.3% respectively (in addition to the slightly higher mean annualized returns). Put differently, it seems from the table below and charts above that k-means and in particular HCA add value relative to random portfolios, but do they do so significantly?


Using a t-test on the 1,000 portfolios’ annualized means we find p-values of 0.7% and ~0% respectively, supporting the view that significant improvements can be made by using k-means and HCA. The p-values fluctuate a bit for the k-means algorithm with every run of the code, but the HCA’s outperformance (in terms of annualized mean return) is always highly significant. Testing the Sharpe ratios rather than mean returns yield highly significant p-values for both k-means and HCA.

We have looked at two ways to potentially improve random portfolios by diversifying across correlation clusters. The choice of k-means and HCA as clustering techniques was made to keep it as simple as possible, but this choice does come with some assumptions. Variance Explained – for example – details why using k-means clustering is not a free lunch. In addition, what distance function to use in the HCA was not considered in this post, we simply opted for the simple choice of: 1 – correlation matrix. We leave it to the reader to test other, more advanced clustering methods, and/or change the settings used in this blog post.

Portfolio size

So far we have only looked at a portfolio size of 50, but in the original post on random portfolios we also included portfolios of size 10 and 20. For completeness we provide the results below noting simply that the pattern is the same across portfolio sizes. In other words, HCA is the best followed by k-means and random portfolios – though the outperformance decreases with the sample size.


Excess returns

The approach above uses the historical correlation matrix of the returns, but there is a case to be made for using excess returns instead. We have therefore replicated the analysis above using excess returns instead of returns, where excess returns are defined as the residuals from regressing the available constituent equity returns on the market returns (i.e. S&P 500 index returns) using the same lookback window of five years (60 observations).

Using the matrix of residuals we construct a correlation matrix and repeat the analysis above; we follow the same steps and use the same parameter settings. Disappointingly, the results do not improve. In fact, the results deteriorate to such a degree that neither k-means nor HCA outperform regardless of whether we look at the mean return or Sharpe ratio.

Postscript: while we use k-means clustering on the correlation matrix we have seen it used in a variety of ways in the finance blogosphere, from Intelligent Trading Tech and Robot Wealth, both of which look at candlestick patterns, over Turing Finance’s focus on countries’ GDP growth to MKTSTK’s trading volume prediction, to mention a few.

# #
# INPUT: #
# prices: (months x equities) matrix of close prices #
# prices.index: (months x 1) matrix of index close prices #
# tickers: (months x equities) binary matrix indicating #
# whether a stock was present in the index in that month #
# #


# Function for finding the minimum-variance portfolio
min_var <- function(cov.mat, short = FALSE) {

if (short) {

aMat <- matrix(1, size.port, 1)
res <- solve.QP(cov.mat, rep(0, size.port), aMat, bvec = 1, meq = 1)

} else {

aMat <- cbind(matrix(1, size.port, 1), diag(size.port))
b0 <- as.matrix(c(1, rep(0, size.port)))
res <- solve.QP(cov.mat, rep(0, size.port), aMat, bvec = b0, meq = 1)




N <- NROW(prices) # Number of months
J <- NCOL(prices) # Number of constituents in the S&P 500 index

prices <- as.matrix(prices)
prices.index <- as.matrix(prices.index)

port.names <- c("Random", "K-means", "HCA", "MinVar")

# Array that stores performance statistics
perf <- array(NA, dim = c(3, draws, length(port.names)),
dimnames = list(c("Ret (ann)", "SD (ann)", "SR (ann)"), NULL, port.names))

# Storage array
ARR <- array(NA, dim = c(N, draws, size.port, length(port.names)),
dimnames = list(rownames(prices), NULL, NULL, port.names))

rows <- which(start.port == rownames(prices)):N

# Loop across time (months)
for (n in rows) {

cat(rownames(prices)[n], "\n")

# Which equities are available?
cols <- which(tickers[n, ] == 1)

# Forward and backward return for available equities
fwd.returns <- prices[n, cols]/prices[n - 1, cols] - 1
bwd.returns <- log(prices[(n - lookback):(n - 1), cols]/
prices[(n - lookback - 1):(n - 2), cols])

# Are these equities also available at n + 1?
cols <- which(is.na(fwd.returns) == FALSE & apply(is.na(bwd.returns) == FALSE, 2, all))

# Returns for available equities
fwd.returns <- fwd.returns[cols]
bwd.returns <- bwd.returns[, cols]

bwd.returns.index <- log(prices.index[(n - lookback):(n - 1), 1]/
prices.index[(n - lookback - 1):(n - 2), 1])

# Covariance and correlation matrices
cov.mat <- cov(bwd.returns)
cor.mat <- cor(bwd.returns)

# K-means on covariance matrix
km <- kmeans(x = scale(cor.mat), centers = size.port, iter.max = 100, nstart = 100)
hc <- hclust(d = as.dist(1 - cor.mat), method = "average")

for (d in 1:draws) {

samp <- sample(x = 1:length(cols), size = size.port, replace = FALSE)
opt <- min_var(cov.mat[samp, samp], short = FALSE)

ARR[n, d, , "Random"] <- fwd.returns[samp]
ARR[n, d, , "MinVar"] <- opt$solution * fwd.returns[samp] * size.port


hc.cut <- cutree(hc, k = size.port)

for (q in 1:size.port) {

ARR[n, , q, "K-means"] <- sample(x = fwd.returns[which(km$cluster == q)], size = draws, replace = TRUE)
ARR[n, , q, "HCA"] <- sample(x = fwd.returns[which(hc.cut == q)], size = draws, replace = TRUE)



ARR <- ARR[rows, , , ]

# Performance calculations
returns.mean <- apply(ARR, c(1, 2, 4), mean) - cost.port/100 ; N2 <- NROW(ARR)
returns.cum <- apply(returns.mean + 1, c(2, 3), cumprod)
returns.ann <- returns.cum[N2, , ]^(12/N2) - 1

std.ann <- exp(apply(log(1 + returns.mean), c(2, 3), StdDev.annualized, scale = 12)) - 1
sr.ann <- returns.ann / std.ann

returns.index <- c(0, prices.index[-1, 1]/prices.index[-N, 1] - 1)
returns.index <- returns.index[rows]
returns.cum.index <- c(1, cumprod(returns.index + 1))
returns.ann.index <- tail(returns.cum.index, 1)^(12/N2) - 1

std.ann.index <- exp(StdDev.annualized(log(1 + returns.index[-1]), scale = 12)) - 1
sr.ann.index <- returns.ann.index / std.ann.index

perf["Ret (ann)", , ] <- returns.ann * 100
perf["SD (ann)", , ] <- std.ann * 100
perf["SR (ann)", , ] <- sr.ann

results.mean <- as.data.frame(apply(perf, c(1, 3), mean))
results.mean$Benchmark <- c(returns.ann.index * 100, std.ann.index * 100, sr.ann.index)

print(round(results.mean, 2))

t.test(returns.ann[,2], returns.ann[, 1], alternative = "greater")
t.test(returns.ann[,3], returns.ann[, 1], alternative = "greater")

Bootstrapping avoids seductive backtest results

Nothing gets the adrenaline rushing as strong backtesting results of your latest equity trading idea. Often, however, it is a mirage created by a subset of equities, which have performed particularly well or poorly thereby inflating the results beyond what seems reasonable to expect going forward.

The investment community has come a long way in terms of becoming more statistically sound, but it is still surprising how few research papers on cross-sectional equity factors mention bootstrapping. Without bootstrapping researchers are simply presenting results from the full sample, implying that the same type of – potentially spectacular – returns will happen again in the future and will be captured satisfactorily by the model. In other words, the backtesting results may be heavily skewed by outliers. In our latest post on equity factor models we mentioned bootstrapping, but we postponed any real discussion of the topic. In today’s blog post we return to the topic of bootstrapping and specifically how outliers influence the results of the aforementioned factor model.

Outlier sample bias

In our equity factor model research our backtest is based on 1,000 bootstrapped samples where each sample is a subset of the constituents in the S&P 1200 Global index. Due to our use of bootstrapped samples the same stock can appear twice or more in any given holding period and it is possible to use subsampling instead.

What is particularly interesting in our backtest is that the historical sample (running the backtest simply on the available historical data set) delivers impressive results with an annualized return of 11% (vertical orange line in the chart below). This is 2.2%-points better than the bootstrapped mean of 8.8%

Bootstrapping vs historical sample

Only 7.2% of the bootstrapped results have a higher annualized return than the historical sample putting it firmly in the right tail of the distribution of annualized returns. Without bootstrapping the historical sample backtest could lead to inflated expectations due to outliers. While it is possible that the model will be able to capture such outliers in the future as well, we prefer to err on the side of caution and therefore prefer to use the boostrapped mean as our expected annualized return rather than that achieved with the full list of historical constituents.

Another advantage of the resampling methodology is that is creates a confidence band around our expectation. We expect an 8.8% annualized return from this model, but if it delivers 6.5% it would fit well within the distribution of annualized returns and hence not surprise us too much. Below the confidence band (5% and 95% percentiles) is shown for the cumulative return with a mean total return of 363% compared with 540% for the historical sample which is close to the upper band of 558%


It is our wish that the investment community steps up its use of bootstrapping on cross-sectional equity research or in other ways incorporate the uncertainty of the results more explicitly, thereby painting a more appropriate picture of the expected performance of a trading strategy.

Factor-based equity investing: is the magic gone?

Factor-based equity investing has shown remarkable results against passive buy-and-hold strategies. However, our research shows that the magic may have diminished over the years.

Equity factor models are used by many successful hedge funds and asset management firms. Their ability to create rather consistent alpha has been the driving force behind their widespread adoption. As we will show the magic seems to be under pressure.

Our four-factor model is based on the well-researched equity factors value, quality, momentum and low volatility with S&P 1200 Global as the universe. Value is defined by 12-month trailing EV/EBITDA and price-to-book (if the former is not available, which is often the case for financials). Quality is defined as return on invested capital (ROIC) and return on equity (ROE, if the former is not available, which is the case for financials). Momentum is defined as the three-month change in price (the window has not been optimized). The stocks’ betas are estimated by linear regression against the market return based on 18 months of monthly observations (the estimation window has not been optimized). All factors have been lagged properly to avoid look-ahead bias.

To avoid concentration risk, as certain industries often dominate a factor at a given period, the factor model spreads its exposure systematically across the equity market. A natural choice is to use industry classifications such as GICS, but from a risk management perspective we are more interested in correlation clusters. We find these clusters by applying hierarchical clustering on excess returns (the stocks’ returns minus the market return) looking back 24 months (again, this window has not been optimized). We require at least 50 stocks in each cluster otherwise the algorithm stops.

The median number of clusters across 1,000 bootstraps is robust around 6 over time compared to the 10 GICS sectors that represent the highest level of segmentation, even – surprisingly – during the financial crisis of 2008 despite a dramatic increase in cross-sectional correlation over that period. It is only the period from June 2000 to June 2003 that sees a big change in the market structure with fewer distinct clusters.

Clusters across time

For each cluster at any given point in time in the backtesting period we rescale the raw equity factors to the range [0, 1]. Another approach would be to normalize the factors but that approach would be more sensitive to outliers which are quite profound in our equity factors. The average of rescaled factors are then ranked and those stocks with the highest combined rank (high earnings yield, high return on capital, strong price momentum and low beta) are chosen.

This is probably the simplest method in factor investing, but why increase complexitity if the increase in risk-adjusted returns are not significant? Another approach is use factors in regressions and compute the expected returns based on the stocks’ factors.

Our data goes back to 1996 but with 24 months reserved for the estimation window for calculating clusters the actual results start in 1998 and end in November 2015. We have specified the portfolio size to 40 stocks based on research showing that after point the marginal reduction in risk is not meaningful (in a long-only framework). Increasing the portfolio size reduces monthly turnover and hence cost, but we have not optimized this parameter. We have set trading costs (one-way average bid-ask spread and commission) to 0.15% which is probably on the high side in today’s market but on the low side in 1998, but on average likely a fair average trade cost for investors investing in global equities with no access to prime brokerage services.

The backtest results are based on 1,000 bootstraps in order to get more robust estimates of performance measures such as annualized return. In a follow-up post we will explain the importance of bootstrapping when testing cross-sectional equity strategies. One may object to the replacement requirement because it creates situations where the portfolio will select the same stock more than once, which increases concentration risk and departs from an equal-weight framework. However, sampling without replacement would reduce the universe from 1,200 securities.

Despite high trading costs and high turnover our four-factor model delivers alpha against random portfolios with both Kolmogorov-Smirnov (comparing the two distributions) and t-test (on excess annualized returns across bootstraps) showing it to be highly significant. The Kolmogorov-Smirnov statistic (D) is 0.82 and the t-test statistics on excess returns is 60. The four-factor model delivers on average 8.8% annualized return over the 1,000 bootstraps compared to 5.8% annualized return for S&P 1200 Global (orange vertical line) and an average 5.3% annualized return for random portfolios.

Four-factor model annualised returns vs random portfolios

In our previous post we showed that random portfolios beat buy-and-hold for the S&P 500 index, but in this case they do not. The reason is the small portfolio size being roughly three percent of the universe leading to excessive turnover and hence costs.

The Sharpe ratio is also decent at 0.56 on average across the 1,000 bootstraps compared to 0.28 for random portfolios – hence a very significant improvement. A natural extension would be to explore ways to improve the risk-adjusted return further, for example by shorting the stocks with the worst total score thereby creating a market-neutral portfolio. is one possible solution.

Sharpe Ratio annualized vs random portfolio

However, our research shows that a market-neutral version does not deliver consistent alpha. In general we find that our equity models do not capture alpha equally well on the long and short side, indicating that drivers of equity returns are not symmetric. So far, we have found market-neutral equity strategies to have more merit on shorter time-frames (intraday or daily), but encourage readers to share any findings on longer time-frames (weekly or monthly frequency).

Cumulative excess performance vs S&P 1200 Global

Interestingly the cumulative excess performance chart shows exactly what Cliff Asness, co-founder of AQR Capital Management, has explained at multiple occasions about the firm’s early start. Namely persistent underperformance as the momentum effect dominated the late 1990s and catapulted the U.S. equity market into a historical bubble. Value investing together with Warren Buffett was ridiculed. Basically stocks that were richly valued, had low or negative return on capital, high beta and high momentum performed very well.

However, starting in 2000 our four-factor model enjoys a long streak of outperformance similar to the fortunes of AQR and it continued until summer 2009. Since then excess return against S&P 1200 Global (we choose this as benchmark here because it beats random portfolios) has been more or less flat. In other words, it performs similarly to the market, but does not generate alpha for our active approach.

Why are traditional equity factor models not producing alpha to the same degree as the period 2000-2009? Two possible explanations come to mind. Competition in financial markets has gone up and with cheap access to computers, widespread adoption of open-source code and equity factors well-researched, the alpha has been competed away. Alternatively, the standard way of creating a four-factor model has run out of juice and factors can still work, but have to be applied in different ways. Maybe the factors should not be blended into a combined score, but instead the best stocks from each factor should be selected. There are endless ways to construct an equity factor model.

### risk.factors is an array with dimensions 239, 2439 and 7 (months, unique stocks in S&amp;amp;amp;amp;P 1200 Global over the whole period, equity factors).
### variables such as cluster.window etc. are specified in our data handling script
### hist.tickers is a matrix with dimensions 239, 2439 (months, unique stocks in S&amp;amp;amp;amp;P 1200 Global over the whole period) - basically a matrix with ones or NAs indicating whether a ticker was part of the index or not at a given point in time
### tr is a matrix containing historical total returns (including reinvesting of dividends and adjustments for corporate actions) with same dimensions as hist.tickers 

# variables
no.pos <- 40 # number of positions in portfolio
strategy <- "long" # long or long-short?
min.stock.cluster <- 50 # minimum stocks per cluster
B <- 1000 # number of bootstraps
tc <- 0.15 # one-way trade cost in % (including bid-ask and commission)

# pre-allocate list with length of dates to contain portfolio info over dates
strat <- vector("list", length(dates))
names(strat) <- dates

# pre-allocate xts object for portfolio returns
port.ret <- xts(matrix(NA, N, B), order.by = dates)

# pre-allocate xts object for random portfolio retuns
rand.port.ret <- xts(matrix(NA, N, B), order.by = dates)

# number of clusters over time
no.clusters <- xts(matrix(NA, N, B), order.by = dates)

# initialise text progress bar
pb <- progress::progress_bar$new(format = "calculating [:bar] :percent eta: :eta",
 total = B, clear = FALSE, width = 60)

# loop of bootstraps
for(b in 1:B) {
 # loop over dates
 for(n in (cluster.window+2):N) {
 # rolling dates window
 dw <- (n - cluster.window):(n - 1)
 # index members at n time
 indx.memb <- which(hist.tickers[n - 1, ] == 1)
 # if number of bootstraps is above one
 if(b > 1) {
 # sample with replacement of index members at period n
 indx.memb <- sample(indx.memb, length(indx.memb), replace = T)
 complete.obs <- which(apply(is.na(tr[dw, indx.memb]), 2, sum) == 0)
 # update index members at n time by complete observations for correlation
 indx.memb <- indx.memb[complete.obs]
 # temporary total returns
 temp.tr <- tr[dw, indx.memb]
 # normalised returns
 norm.ret <- scale(temp.tr)
 # fit PCA on normalised returns
 pca.fit <- prcomp(norm.ret)
 # estimate market returns from first PCA component
 x <- (norm.ret %*% pca.fit$rotation)[, 1]
 # estimate beta
 betas <- as.numeric(solve(t(x) %*% x) %*% t(x) %*% norm.ret)
 # estimate residuals (normalised return minus market)
 res <- norm.ret - tcrossprod(x, betas)
 # correlation matrix
 cm <- cor(res)
 # distance matrix
 dm <- as.dist((1 - cm) / 2)
 # fit a hierarchical agglomerative clustering
 fit <- hclust(dm, method = "average")
 for(i in 2:20) {
 # assign tickers into clusters
 groups <- cutree(fit, k = i)
 # minimum number of tickers in a cluster
 group.min <- min(table(groups))
 # if smallest cluster has less than minimum required number of stocks break loop
 if(group.min < min.stock.cluster) {
 groups <- cutree(fit, k = i - 1)
 # number of clusters
 G <- length(unique(groups))
 # insert number of clusters
 no.clusters[n, b] <- G
 # stocks per cluster
 cluster.size <- table(groups)
 # number of positions per cluster
 risk.allocation <- round(table(groups) / sum(table(groups)) * no.pos)
 # pre-allocate list for containing all trade info on each cluster
 cluster.info <- vector("list", G)
 # loop over clusters
 for(g in 1:G) {
 # find the ticker positions in the specific cluster
 cluster.pos <- indx.memb[which(groups == g)]
 # which tickers have total returns for period n
 has.ret <- which(!is.na(tr[n, cluster.pos]))
 # adjust stock's position for g cluster based on available forward return
 cluster.pos <- cluster.pos[has.ret]
 # rescale quality risk factor
 quality.1 <- risk.factors[n - 1, cluster.pos, "quality.1"]
 quality.2 <- risk.factors[n - 1, cluster.pos, "quality.2"]
 quality.1.rank <- (quality.1 - min(quality.1, na.rm = T)) /
 (max(quality.1, na.rm = T) - min(quality.1, na.rm = T))
 quality.2.rank <- (quality.2 - min(quality.2, na.rm = T)) /
 (max(quality.2, na.rm = T) - min(quality.2, na.rm = T))
 quality.rank <- ifelse(!is.na(quality.2.rank), quality.2.rank, quality.1.rank)
 # rescale value risk factor
 value.1 <- risk.factors[n - 1, cluster.pos, "value.1"]
 value.2 <- risk.factors[n - 1, cluster.pos, "value.2"]
 value.1.rank <- (value.1 - min(value.1, na.rm = T)) /
 (max(value.1, na.rm = T) - min(value.1, na.rm = T))
 value.2.rank <- (value.2 - min(value.2, na.rm = T)) /
 (max(value.2, na.rm = T) - min(value.2, na.rm = T))
 value.rank <- ifelse(!is.na(value.2.rank), value.2.rank, value.1.rank)
 # rescale momentum risk factor
 mom <- risk.factors[n - 1, cluster.pos, "mom"]
 mom.rank <- (mom - min(mom, na.rm = T)) /
 (max(mom, na.rm = T) - min(mom, na.rm = T))
 # rescale beta risk factor
 beta <- risk.factors[n - 1, cluster.pos, "beta"] * -1
 beta.rank <- (beta - min(beta, na.rm = T)) /
 (max(beta, na.rm = T) - min(beta, na.rm = T))
 # rescale reversal risk factor
 reversal <- risk.factors[n - 1, cluster.pos, "reversal"] * -1
 reversal.rank <- (reversal - min(reversal, na.rm = T)) /
 (max(reversal, na.rm = T) - min(reversal, na.rm = T))
 # combine all normalised risk factor ranks into one matrix
 ranks <- cbind(quality.rank, value.rank, mom.rank, beta.rank)#, reversal.rank)
 if(sum(complete.cases(ranks)) < risk.allocation[g]) {
 col.obs <- apply(!is.na(ranks), 2, sum)
 col.comp <- which(col.obs > (cluster.size[g] / 2))
 comb.rank <- rank(apply(ranks[, col.comp], 1, mean), na.last = "keep")
 } else {
 comb.rank <- rank(apply(ranks, 1, mean), na.last = "keep")
 if(strategy == "long") {
 long.pos <- cluster.pos[which(comb.rank > max(comb.rank, na.rm = T) - risk.allocation[g])]
 cluster.info[[g]] <- data.frame(Ticker = tickers[long.pos],
 Ret = as.numeric(tr[n, long.pos]),
 stringsAsFactors = FALSE)
 if(strategy == "long-short") {
 long.pos <- cluster.pos[which(comb.rank > max(comb.rank, na.rm = T) - risk.allocation[g])]
 short.pos <- cluster.pos[which(comb.rank < risk.allocation[g] + 1)]
 long.data <- data.frame(Ticker = tickers[long.pos],
 Sign = rep("Long", risk.allocation[g]),
 Ret = as.numeric(tr[n, long.pos]),
 stringsAsFactors = FALSE)
 short.data <- data.frame(Ticker = tickers[short.pos],
 Sign = rep("Short", risk.allocation[g]),
 Ret = as.numeric(tr[n, short.pos]) * -1,
 stringsAsFactors = FALSE)
 cluster.info[[g]] <- rbind(long.data, short.data)
 # rbind data.frames across clusters and insert into strat list
 strat[[n]] <- do.call("rbind", cluster.info)
 # insert portfolio return
 if(n == cluster.window + 2) {
 port.ret[n, b] <- mean(strat[[n]][,"Ret"]) - tc * 2 / 100
 } else {
 # turnover in % (only selling)
 strat.turnover <- 1 - sum(!is.na(match(strat[[n]][, "Ticker"], strat[[n-1]][, "Ticker"]))) /
 length(strat[[n-1]][, "Ticker"])
 port.ret[n, b] <- mean(strat[[n]][,"Ret"]) - tc * strat.turnover * 2 / 100
 # insert random portfolio return
 rand.pos <- sample(indx.memb[which(!is.na(tr[n, indx.memb]))],
 size = no.pos, replace = T)
 if(n == cluster.window + 2) {
 rand.port.ret[n, b] <- mean(tr[n, rand.pos]) - tc * 2 / 100
 prev.rand.pos <- rand.pos
 } else {
 rand.turnover <- 1 - sum(!is.na(match(prev.rand.pos, rand.pos))) / length(prev.rand.pos)
 rand.port.ret[n, b] <- mean(tr[n, rand.pos]) - tc * rand.turnover * 2 / 100
 # update progress bar
 Sys.sleep(1 / 100)

Towards a better equity benchmark: random portfolios

Random portfolios deliver alpha relative to a buy-and-hold position in the S&P 500 index – even after allowing for trading costs. Random portfolios will serve as our benchmark for our future quantitative equity models.

The evaluation of quantitative equity portfolios typically involves a comparison with a relevant benchmark, routinely a broad index such as the S&P 500 index. This is an easy and straightforward approach, but also – we believe – sets the bar too low resulting in too many favorable research outcomes. However, we want to hold ourselves to a higher standard and as a bare minimum that must include being able to beat a random portfolio – the classic dart-throwing monkey portfolio.

A market capitalization-weighted index, such as the S&P 500 index, is inherently a size-based portfolio where those equities which have done well in the long run are given the highest weight. It is, in other words, dominated by equities with long-run momentum. Given that an index is nothing more than a size-based trading strategy, we should ask ourselves whether there exist other simple strategies that perform better. The answer to this is in the affirmative and one such strategy is to generate a portfolio purely from chance. Such a strategy will choose, say, 20 equities among the constituents of an index each month and invest an equal amount in each position. After a month the process is repeated, 20 new equities are randomly picked, and so forth.

The fact that random portfolios outperform their corresponding indices is nothing new. David Winton Harding, the founder of the hedge fund Winton Capital, even went on CNBC last year to explain the concept, but he is far from only in highlighting the out-performance of random portfolios (e.g. here and here).

To demonstrate the idea we have carried out the research ourselves. We use the S&P 500 index – without survival bias and accounting for corporate actions such as dividends – and focus on the period from January 2000 to November 2015. We limit ourselves in this post to this time span as it mostly covers the digitization period, but results from January 1990 show similar results. Starting on 31 December 1999 we randomly select X equities from the list of S&P 500 constituents that particular month, assign equal weights, and hold this portfolio in January 2000. We then repeat the process on the final day of January and hold in February and every subsequent month until November 2015.


In the chart above the annualized returns for 1000 portfolios containing 10, 20, and 50 equities are depicted (the orange line represents the S&P 500 index). A few things are readily apparent:

  • The mean of the annualized returns is practically unchanged across portfolio sizes
  • The standard deviation of the annualized returns narrows as the portfolio size increases
  • All three portfolio sizes beat the S&P 500 index
  • 6.5% of the 1000 random portfolios of size 10 have an annualized return which is lower than that of the S&P 500 index (4.2%). Of the portfolios with sizes 20 and 50 the percentages are 0.9% and 0%, respectively. In other words, not a single of the 1000 random portfolios of size 50 delivers a annualized return below 4.2%.

So far we have talked one or many random portfolios without being too specific, but for random portfolios to work we need a large number of samples (i.e. portfolios) so that performance statistics, such as the annualized return, tend toward stable values. The chart below shows the cumulative mean over the number of random portfolios (size = 10), which stabilizes as the number of random portfolios increases (the orange line represents the mean across all 1000 portfolios).


All three portfolios beat the S&P 500 index in terms of annualized return, but we must keep in mind that these portfolios’ turnovers are high and hence we need to allow for trading costs. The analysis is thus repeated below  for 1000 random portfolios with 50 positions with trade costs of 0%, 0.1% and 0.2% (round trip).


Unsurprisingly the mean annualized return declines as trading costs increase. Whereas not a single of the 1000 random portfolios of size 50 delivered an annualized return below 4.2% without trade costs , 2 and 40 portfolios have lower returns when trade costs of 0.1% and 0.2%, respectively, are added. Put differently, even with trade costs of 0.2% (round trip) every month a portfolio of 50 random stocks outperformed the S&P 500 index in terms of annualized return in 960 of 1000 instances.


Weighing by size is simple, and we like simple. But when it comes to equity portfolios we demand more. Put differently, if our upcoming quantitative equity portfolios cannot beat a randomly-generated portfolio what is the point? Therefore, going forward, we will refrain from using the S&P 500 index or any other appropriate index and instead compare our equity models to the results presented above. We want to beat not only the index, we want to beat random. We want to beat the dart-throwing monkey!

ADDENDUM: A couple of comments noted that we must use an an equal weight index to be consistent with the random portfolio approach. This can, for example, be achieved by investing in the Guggenheim S&P 500 Equal Weight ETF, which has yielded 9.6% annualized since inception in April, 2003 (with an expense ratio of 0.4%). The ETF has delivered a higher annualized return than that of the random portfolios (mean) when trading costs are added.

In other words, if you want to invest invest equally in the S&P 500 constituents, there is an easy way to do it. We will continue to use random portfolios as a benchmark as it is a simple approach, which our models must beat and we can choose the starting date as we please.

# #
# INPUT: #
# prices: (months x equities) matrix of close prices #
# prices_index: (months x 1) matrix of index close prices #
# tickers: (months x equities) binary matrix indicating #
# whether a stock was present in the index in that month #
# #

draws &lt;- 1000
start_time &lt;- &quot;1999-12-31&quot;
freq_cal &lt;- &quot;MONTHLY&quot;

N &lt;- NROW(prices) # Number of months
J &lt;- NCOL(prices) # Number of constituents in the S&amp;P 500 index

prices &lt;- as.matrix(prices)
prices_index &lt;- as.matrix(prices_index)
prices_ETF &lt;- as.matrix(prices_ETF)

# Narrow the window
#prices &lt;- prices[-1:-40, , drop = FALSE]
#prices_index &lt;- prices_index[-1:-40, , drop = FALSE]
#prices_ETF &lt;- prices_ETF[-1:-40, , drop = FALSE]

#N &lt;- NROW(prices)

# Combinations
sizes &lt;- c(10, 20, 50) ; nsizes &lt;- length(sizes) # Portfolio sizes
costs &lt;- c(0, 0.1, 0.2) ; ncosts &lt;- length(costs) # Trading costs (round trip)

# Array that stores performance statistics
perf &lt;- array(NA, dim = c(nsizes, ncosts, 3, draws),
dimnames = list(paste(&quot;Size&quot;, sizes, sep = &quot; &quot;), paste(&quot;Cost&quot;, costs, sep = &quot; &quot;),
c(&quot;Ret(ann)&quot;, &quot;SD(ann)&quot;, &quot;SR(ann)&quot;), NULL))

# Loop across portfolio sizes
for(m in 1:nsizes) {

# Storage array
ARR &lt;- array(NA, dim = c(N, sizes[m], draws))

# Loop across time (months)
for(n in 1:(N - 1)) {

# Which equities are available?
cols &lt;- which(tickers[n, ] == 1)

# Forward return for available equities
fwd_returns &lt;- prices[n + 1, cols]/prices[n, cols] - 1

# Are these equities also available at n + 1?
cols &lt;- which(is.na(fwd_returns) == FALSE)

# Forward return for available equities
fwd_returns &lt;- fwd_returns[cols]

# Loop across portfolios
for(i in 1:draws) {

# Sample a portfolio of size 'sizes[m]'
samp &lt;- sample(x = cols, size = sizes[m], replace = F)

# Store a vector of forward returns in ARR
ARR[n, , i] &lt;- fwd_returns[samp]

} # End of i loop

} # End of n loop

ARR[is.na(ARR)] &lt;- 0

# Loop across trading costs
for(m2 in 1:ncosts) {

# Performance calculations
returns_mean &lt;- apply(ARR, c(1, 3), mean) - costs[m2]/100
returns_cum &lt;- apply(returns_mean + 1, 2, cumprod)
returns_ann &lt;- tail(returns_cum, 1)^(percent_exponent/N) - 1

std_ann &lt;- exp(apply(log(1 + returns_mean), 2, StdDev.annualized, scale = percent_exponent)) - 1
sr_ann &lt;- returns_ann / std_ann

perf[m, m2, &quot;Ret(ann)&quot;, ] &lt;- returns_ann * 100
perf[m, m2, &quot;SD(ann)&quot;, ] &lt;- std_ann * 100
perf[m, m2, &quot;SR(ann)&quot;, ] &lt;- sr_ann

} # End of m2 loop

} # End of m loop

# Index and ETF returns
returns_index &lt;- prices_index[-1, 1]/prices_index[-N, 1] - 1
returns_ava_index &lt;- sum(!is.na(returns_index))
returns_index[is.na(returns_index)] &lt;- 0
returns_cum_index &lt;- c(1, cumprod(1 + returns_index))
returns_ann_index &lt;- tail(returns_cum_index, 1)^(percent_exponent/returns_ava_index) - 1

returns_ETF &lt;- prices_ETF[-1, 1]/prices_ETF[-N, 1] - 1
returns_ava_ETF &lt;- sum(!is.na(returns_ETF))
returns_ETF[is.na(returns_ETF)] &lt;- 0
returns_cum_ETF &lt;- c(1, cumprod(1 + returns_ETF))
returns_ann_ETF &lt;- tail(returns_cum_ETF, 1)^(percent_exponent/returns_ava_ETF) - 1

# Print medians to screen
STAT_MED &lt;- apply(perf, c(1, 2, 3), median, na.rm = TRUE)
rownames(STAT_MED) &lt;- paste(&quot;Size &quot;, sizes, sep = &quot;&quot;)
colnames(STAT_MED) &lt;- paste(&quot;Cost &quot;, costs, sep = &quot;&quot;)
print(round(STAT_MED, 2))