How You Measure Months Matters — A Lot. A Look At Two Implementations of KDA
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
This post will detail a rather important finding I found while implementing a generalized framework for momentum asset allocation backtests. Namely, that when computing momentum (and other financial measures for use in asset allocation, such as volatility and correlations), measuring formal months, from start to end, has a large effect on strategy performance.
So, first off, I am in the job market, and am actively looking for a full-time role (preferably in New York City, or remotely), or a long-term contract. Here is my resume, and here is my LinkedIn profile. Furthermore, I’ve been iterating on my volatility strategy, and given that I’ve seen other services with large drawdowns, or less favorable risk/reward profiles charge $50/month, I think following my trades can be a reasonable portfolio diversification tool. Read about it and subscribe here. I believe that my body of work on this blog speaks to the viability of employing me, though I am also learning Python to try and port over my R skills over there, as everyone seems to want Python, and R much less so, hence the difficulty transferring between opportunities.
Anyhow, one thing I am working on is a generalized framework for tactical asset allocation (TAA) backtests. Namely, those that take the form of “sort universe by momentum, apply diversification weighting scheme”–namely, the kinds of strategies that the folks over at AllocateSmartly deal in. I am also working on this framework and am happy to announce that as of the time of this writing, I will happily work with individuals that want more customized TAA backtests, as the AllocateSmartly FAQs state that AllocateSmartly themselves do not do custom backtests. The framework I am currently in the process of implementing is designed to do just that. However, after going through some painstaking efforts to compare apples to apples, I came across a very important artifact. Namely, that there is a fairly large gulf in performance between measuring months from their formal endpoints, as opposed to simply approximating months with 21-day chunks (E.G. 21 days for 1 month, 63 for 3, and so on).
Here’s the code I’ve been developing recently–the long story short, is that the default options essentially default to Adaptive Asset Allocation, but depending on the parameters one inputs, it’s possible to get to something as simple as dual momentum (3 assets, invest in top 1), or as complex as KDA, with options to fine-tune it even further, such as to account for the luck-based timing that Corey Hoffstein at Newfound Research loves to write about (speaking of whom, he and the awesome folks at ReSolve Asset Management have launched a new ETF called ROMO–Robust Momentum–I recently bought a bunch in my IRA because a buy-it-and-forget-it TAA ETF is pretty fantastic as far as buy-and-hold investments go). Again, I set a bunch of defaults in the parameters so that most of them can be ignored.
require(PerformanceAnalytics) require(quantmod) require(tseries) stratStats <- function(rets) { stats <- rbind(table.AnnualizedReturns(rets), maxDrawdown(rets)) stats[5,] <- stats[1,]/stats[4,] stats[6,] <- stats[1,]/UlcerIndex(rets) rownames(stats)[4] <- "Worst Drawdown" rownames(stats)[5] <- "Calmar Ratio" rownames(stats)[6] <- "Ulcer Performance Index" return(stats) } getYahooReturns <- function(symbols, return_column = "Ad") { returns <- list() for(symbol in symbols) { getSymbols(symbol, from = '1990-01-01', adjustOHLC = TRUE) if(return_column == "Ad") { return <- Return.calculate(Ad(get(symbol))) colnames(return) <- gsub("\\.Adjusted", "", colnames(return)) } else { return <- Return.calculate(Op(get(symbol))) colnames(return) <- gsub("\\.Open", "", colnames(return)) } returns[[symbol]] <- return } returns <- na.omit(do.call(cbind, returns)) return(returns) } symbols <- c("SPY", "VGK", "EWJ", "EEM", "VNQ", "RWX", "IEF", "TLT", "DBC", "GLD") returns <- getYahooReturns(symbols) canary <- getYahooReturns(c("VWO", "BND")) # offsets endpoints by a certain amount of days (I.E. 1-21) dailyOffset <- function(ep, offset = 0) { ep <- ep + offset ep[ep < 1] <- 1 ep[ep > nrow(returns)] <- nrow(returns) ep <- unique(ep) epDiff <- diff(ep) if(last(epDiff)==1) { # if the last period only has one observation, remove it ep <- ep[-length(ep)] } return(ep) } # computes total weighted momentum and penalizes new assets (if desired) compute_total_momentum <- function(yearly_subset, momentum_lookbacks, momentum_weights, old_weights, new_asset_mom_penalty) { empty_vec <- data.frame(t(rep(0, ncol(yearly_subset)))) colnames(empty_vec) <- colnames(yearly_subset) total_momentum <- empty_vec for(j in 1:length(momentum_lookbacks)) { momentum_subset <- tail(yearly_subset, momentum_lookbacks[j]) total_momentum <- total_momentum + Return.cumulative(momentum_subset) * momentum_weights[j] } # if asset returns are negative, penalize by *increasing* negative momentum # this algorithm assumes we go long only total_momentum[old_weights == 0] <- total_momentum[old_weights==0] * (1-new_asset_mom_penalty * sign(total_momentum[old_weights==0])) return(total_momentum) } # compute weighted correlation matrix compute_total_correlation <- function(data, cor_lookbacks, cor_weights) { # compute total correlation matrix total_cor <- matrix(nrow=ncol(data), ncol=ncol(data), 0) rownames(total_cor) <- colnames(total_cor) <- colnames(data) for(j in 1:length(cor_lookbacks)) { total_cor = total_cor + cor(tail(data, cor_lookbacks[j])) * cor_weights[j] } return(total_cor) } # computes total weighted volatility compute_total_volatility <- function(data, vol_lookbacks, vol_weights) { empty_vec <- data.frame(t(rep(0, ncol(data)))) colnames(empty_vec) <- colnames(data) # normalize weights if not already normalized if(sum(vol_weights) != 1) { vol_weights <- vol_weights/sum(vol_weights) } # compute total volrelation matrix total_vol <- empty_vec for(j in 1:length(vol_lookbacks)) { total_vol = total_vol + StdDev.annualized(tail(data, vol_lookbacks[j])) * vol_weights[j] } return(total_vol) } check_valid_parameters() { if(length(mom_weights) != length(mom_lookbacks)) { stop("Momentum weight length must be equal to momentum lookback length.") } if(length(cor_weights) != length(cor_lookbacks)) { stop("Correlation weight length must be equal to correlation lookback length.") } if(length(vol_weights) != length(vol_lookbacks)) { stop("Volatility weight length must be equal to volatility lookback length.") } } # computes weights as a function proportional to the inverse of total variance invVar <- function(returns, lookbacks, lookback_weights) { var <- compute_total_volatility(returns, lookbacks, lookback_weights)^2 invVar <- 1/var return(invVar/sum(invVar)) } # computes weights as a function proportional to the inverse of total volatility invVol <- function(returns, lookbacks, lookback_weights) { vol <- compute_total_volatility(returns, lookbacks, lookback_weights) invVol <- 1/vol return(invVol/sum(invVol)) } # computes equal weight portfolio ew <- function(returns) { return(StdDev(returns)/(StdDev(returns)*ncol(returns))) } # computes minimum minVol <- function(returns, cor_lookbacks, cor_weights, vol_lookbacks, vol_weights) { vols <- compute_total_volatility(returns, vol_lookbacks, vol_weights) cors <- compute_total_correlation(returns, cor_lookbacks, cor_weights) covs <- t(vols) %*% as.numeric(vols) * cors min_vol_rets <- t(matrix(rep(1, ncol(covs)))) min_vol_wt <- portfolio.optim(x=min_vol_rets, covmat = covs)$pw names(min_vol_wt) <- rownames(covs) return(min_vol_wt) } asset_allocator <- function(returns, canary_returns = NULL, # canary assets for KDA algorithm and similar mom_threshold = 0, # threshold momentum must exceed mom_lookbacks = 126, # momentum lookbacks for custom weights (EG 1-3-6-12) # weights on various momentum lookbacks (EG 12/19, 4/19, 2/19, 1/19) mom_weights = rep(1/length(mom_lookbacks), length(mom_lookbacks)), # repeat for correlation weights cor_lookbacks = mom_lookbacks, # correlation lookback cor_weights = rep(1/length(mom_lookbacks), length(mom_lookbacks)), vol_lookbacks = 20, # volatility lookback vol_weights = rep(1/length(vol_lookbacks), length(vol_lookbacks)), # number of assets to hold (if all above threshold) top_n = floor(ncol(returns)/2), # diversification weight scheme (ew, invVol, invVar, minVol, etc.) weight_scheme = "minVol", # how often holdings rebalance rebalance_on = "months", # how many days to offset rebalance period from end of month/quarter/year offset = 0, # penalize new asset mom to reduce turnover new_asset_mom_penalty = 0, # run Return.Portfolio, or just return weights? # for use in robust momentum type portfolios compute_portfolio_returns = TRUE, verbose = FALSE, # crash protection asset crash_asset = NULL, ... ) { # normalize weights mom_weights <- mom_weights/sum(mom_weights) cor_weights <- cor_weights/sum(cor_weights) vol_weights <- vol_weights/sum(vol_weights) # if we have canary returns (I.E. KDA strat), align both time periods if(!is.null(canary_returns)) { smush <- na.omit(cbind(returns, canary_returns)) returns <- smush[,1:ncol(returns)] canary_returns <- smush[,-c(1:ncol(returns))] empty_canary_vec <- data.frame(t(rep(0, ncol(canary_returns)))) colnames(empty_canary_vec) <- colnames(canary_returns) } # get endpoints and offset them ep <- endpoints(returns, on = rebalance_on) ep <- dailyOffset(ep, offset = offset) # initialize vector holding zeroes for assets empty_vec <- data.frame(t(rep(0, ncol(returns)))) colnames(empty_vec) <- colnames(returns) weights <- empty_vec # initialize list to hold all our weights all_weights <- list() # get number of periods per year switch(rebalance_on, "months" = { yearly_periods = 12}, "quarters" = { yearly_periods = 4}, "years" = { yearly_periods = 1}) for(i in 1:(length(ep) - yearly_periods)) { # remember old weights for the purposes of penalizing momentum of new assets old_weights <- weights # subset one year of returns, leave off first day return_subset <- returns[c((ep[i]+1):ep[(i+yearly_periods)]),] # compute total weighted momentum, penalize potential new assets if desired momentums <- compute_total_momentum(return_subset, momentum_lookbacks = mom_lookbacks, momentum_weights = mom_weights, old_weights = old_weights, new_asset_mom_penalty = new_asset_mom_penalty) # rank negative momentum so that best asset is ranked 1 and so on momentum_ranks <- rank(-momentums) selected_assets <- momentum_ranks <= top_n & momentums > mom_threshold selected_subset <- return_subset[, selected_assets] # case of 0 valid assets if(sum(selected_assets)==0) { weights <- empty_vec } else if (sum(selected_assets)==1) { # case of only 1 valid asset -- invest everything into it weights <- empty_vec + selected_assets } else { # apply a user-selected weighting algorithm # modify this portion to select more weighting schemes if (weight_scheme == "ew") { weights <- ew(selected_subset) } else if (weight_scheme == "invVol") { weights <- invVol(selected_subset, vol_lookbacks, vol_weights) } else if (weight_scheme == "invVar"){ weights <- invVar(selected_subset, vol_lookbacks, vol_weights) } else if (weight_scheme == "minVol") { weights <- minVol(selected_subset, cor_lookbacks, cor_weights, vol_lookbacks, vol_weights) } } # include all assets wt_names <- names(weights) if(is.null(wt_names)){wt_names <- colnames(weights)} zero_weights <- empty_vec zero_weights[wt_names] <- weights weights <- zero_weights weights <- xts(weights, order.by=last(index(return_subset))) # if there's a canary universe, modify weights by fraction with positive momentum # if there's a safety asset, allocate the crash protection modifier to it. if(!is.null(canary_returns)) { canary_subset <- canary_returns[c(ep[i]:ep[(i+yearly_periods)]),] canary_subset <- canary_subset[-1,] canary_mom <- compute_total_momentum(canary_subset, mom_lookbacks, mom_weights, empty_canary_vec, 0) canary_mod <- mean(canary_mom > 0) weights <- weights * canary_mod if(!is.null(crash_asset)) { if(momentums[crash_asset] > mom_threshold) { weights[,crash_asset] <- weights[,crash_asset] + (1-canary_mod) } } } all_weights[[i]] <- weights } # combine weights all_weights <- do.call(rbind, all_weights) if(compute_portfolio_returns) { strategy_returns <- Return.portfolio(R = returns, weights = all_weights, verbose = verbose) return(list(all_weights, strategy_returns)) } return(all_weights) } #out <- asset_allocator(returns, offset = 0) kda <- asset_allocator(returns = returns, canary_returns = canary, mom_lookbacks = c(21, 63, 126, 252), mom_weights = c(12, 4, 2, 1), cor_lookbacks = c(21, 63, 126, 252), cor_weights = c(12, 4, 2, 1), vol_lookbacks = 21, weight_scheme = "minVol", crash_asset = "IEF")
The one thing that I’d like to focus on, however, are the lookback parameters. Essentially, assuming daily data, they’re set using a *daily lookback*, as that’s what AllocateSmartly did when testing my own KDA Asset Allocation algorithm. Namely, the salient line is this:
“For all assets across all three universes, at the close on the last trading day of the month, calculate a “momentum score” as follows:(12 * (p0 / p21 – 1)) + (4 * (p0 / p63 – 1)) + (2 * (p0 / p126 – 1)) + (p0 / p252 – 1)Where p0 = the asset’s price at today’s close, p1 = the asset’s price at the close of the previous trading day and so on. 21, 63, 126 and 252 days correspond to 1, 3, 6 and 12 months.”
So, to make sure I had apples to apples when trying to generalize KDA asset allocation, I compared the output of my new algorithm, asset_allocator (or should I call it allocate_smartly ?=] ) to my formal KDA asset allocation algorithm.
Here are the results:
KDA_algo KDA_approximated_months Annualized Return 0.10190000 0.08640000 Annualized Std Dev 0.09030000 0.09040000 Annualized Sharpe (Rf=0%) 1.12790000 0.95520000 Worst Drawdown 0.07920336 0.09774612 Calmar Ratio 1.28656163 0.88392257 Ulcer Performance Index 3.78648873 2.62691398
Essentially, the long and short of it is that I modified my original KDA algorithm until I got identical output to my asset_allocator algorithm, then went back to the original KDA algorithm. The salient difference is this part:
# computes total weighted momentum and penalizes new assets (if desired) compute_total_momentum <- function(yearly_subset, momentum_lookbacks, momentum_weights, old_weights, new_asset_mom_penalty) { empty_vec <- data.frame(t(rep(0, ncol(yearly_subset)))) colnames(empty_vec) <- colnames(yearly_subset) total_momentum <- empty_vec for(j in 1:length(momentum_lookbacks)) { momentum_subset <- tail(yearly_subset, momentum_lookbacks[j]) total_momentum <- total_momentum + Return.cumulative(momentum_subset) * momentum_weights[j] } # if asset returns are negative, penalize by *increasing* negative momentum # this algorithm assumes we go long only total_momentum[old_weights == 0] <- total_momentum[old_weights==0] * (1-new_asset_mom_penalty * sign(total_momentum[old_weights==0])) return(total_momentum) }
Namely, the part that further subsets the yearly subset by the lookback period, in terms of days, rather than monthly endpoints. Essentially, the difference in the exact measurement of momentum–that is, the measurement that explicitly selects *which* instruments the algorithm will allocate to in a particular period, unsurprisingly, has a large impact on the performance of the algorithm.
And lest anyone think that this phenomena no longer applies, here’s a yearly performance comparison.
KDA_algo KDA_approximated_months 2008-12-31 0.1578348930 0.062776766 2009-12-31 0.1816957178 0.166017499 2010-12-31 0.1779839604 0.160781537 2011-12-30 0.1722014474 0.149143148 2012-12-31 0.1303019332 0.103579674 2013-12-31 0.1269207487 0.134197066 2014-12-31 0.0402888320 0.071784979 2015-12-31 -0.0119459453 -0.028090873 2016-12-30 0.0125302658 0.002996917 2017-12-29 0.1507895287 0.133514924 2018-12-31 0.0747520266 0.062544709 2019-11-27 0.0002062636 0.008798310
Of note: the variant that formally measures momentum from monthly endpoints consistently outperforms the one using synthetic monthly measurements.
So, that will do it for this post. I hope to have a more thorough walk-through of the asset_allocator function in the very near future before moving onto Python-related matters (hopefully), but I thought that this artifact, and just how much it affects outcomes, was too important not to share.
An iteration of the algorithm capable of measuring momentum with proper monthly endpoints should be available in the near future.
Thanks for reading.
R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.