Update 5/17: As discussed in the comments, the reason the results are so exaggerated is because it is missing portfolio rebalancing to account for the changing hedge ratio. It would be interesting to try an adaptive hedge ratio that requires only weekly or monthly rebalancing to see how legitimately profitable this type of strategy could be.

Welcome back! This week’s post will backtest a basic mean reverting strategy on a cointegrated ETF pair time series constructed using the methods described in part I. Since the EWA (Australia) – EWC (Canada) pair was found to be more naturally cointegrated, I decided to run the rolling linear regression model (EWA chosen as the dependent variable) with a lookback window of 21 days on this pair to create the spread below.

With the adaptive hedge ratio, the spread looks well suited to backtest a mean reverting strategy on. Before that, we should check what the minimum capital required to trade this spread is. Though everyone has a different margin requirement, I thought it would be useful to walkthrough how you would calculate the capital required. In this example we assume our broker allows a margin of 50%. We first will compute the daily ratio between the pair, EWC/EWA. This ratio represents the amount of EWA shares for each share of EWC that must be owned to have an equal dollar move for every 1% move. The ratio fluctuates daily but has a mean of 1.43. This makes sense because EWC, on average, trades at higher price. We then multiply these ratios by the rolling beta. Then for reference, we can fix the held EWC shares to 100 and multiply the previous values (ratios*rolling beta) by 100 to determine the amount of EWA shares that would be held. The amount of capital required to hold this spread can then be calculated with the equation: margin*abs((EWC price * 100) + (EWA price * calculated shares)). This is plotted for our example below.

From this plot we can see that the series has a max value of $5,466 which is not a relatively large required capital. I hypothesize that the less cointegrated a pair is, the higher the minimum capital will be (try the EWZ-IGE pair).

We can now go ahead and backtest the figure 1 time series! A common mean reversal strategy uses Bollinger Bands, where we enter positions when the price deviates past a Z-score/standard deviation threshold from the mean. The exit signals can be determined from the half-life of its mean reversion or it can be based on the Z-score. To avoid look-ahead bias, I calculated the mean, standard deviation, and Z-score with a rolling 50-day window. Unfortunately, this window had to be chosen with data-snooping bias but was a reasonable choice. This backtest will also ignore transaction costs and other spread execution nuances but should still reasonably reflect the strategy’s potential performance. I decided on the following signals:

Enter Long/Close Short: Z-Score < -1

Close Long/Enter Short: Z-Score > 1

This is a standard Bollinger Bands strategy and results were encouraging.

Though it made a relatively small amount of trades over 13 years, it boasts an impressive 2.7 Sharpe Ratio with 97% positive trades. Below on the left we can see the strategy’s performance vs. SPY (using very minimal leverage) and on the right the positions/trades are shown.

Overall, this definitely supports the potential of trading cointegrated ETF pairs with Bollinger Bands. I think it would be interesting to explore a form of position sizing based on either market volatility or the correlation between the ETF pair and another symbol/ETF. This concludes my analysis of cointegrated ETF pairs for now.

Acknowledgments: Thank you to Brian Peterson and Ernest Chan for explaining how to calculate the minimum capital required to trade a spread. Additionally, all of my blog posts have been edited prior to being published by Karin Muggli, so a huge thank you to her!

Note: I’m currently looking for a full-time quantitative research/trading position beginning summer/fall 2017. I’m currently a senior at the University of Washington, majoring in Industrial and Systems Engineering and minoring in Applied Mathematics. I also have taken upper level computer science classes and am proficient in a variety of programming languages. Resume: https://www.pdf-archive.com/2017/01/31/coltonsmith-resume-g/. LinkedIn: https://www.linkedin.com/in/coltonfsmith. Please let me know of any open positions that would be a good fit for me. Thanks!

Full Code:

detach("package:dplyr", unload=TRUE) require(quantstrat) require(IKTrading) require(DSTrading) require(knitr) require(PerformanceAnalytics) require(quantstrat) require(tseries) require(roll) require(ggplot2) # Full test initDate="1990-01-01" from="2003-01-01" to="2015-12-31" ## Create "symbols" for Quanstrat ## adj1 = EWA (Australia), adj2 = EWC (Canada) ## Get data getSymbols("EWA", from=from, to=to) getSymbols("EWC", from=from, to=to) dates = index(EWA) adj1 = unclass(EWA$EWA.Adjusted) adj2 = unclass(EWC$EWC.Adjusted) ## Ratio (EWC/EWA) ratio = adj2/adj1 ## Rolling regression window = 21 lm = roll_lm(adj2,adj1,window) ## Plot beta rollingbeta <- fortify.zoo(lm$coefficients[,2],melt=TRUE) ggplot(rollingbeta, ylab="beta", xlab="time") + geom_line(aes(x=Index,y=Value)) + theme_bw() ## Calculate the spread sprd <- vector(length=3273-21) for (i in 21:3273) { sprd[i-21] = (adj1[i]-rollingbeta[i,3]*adj2[i]) + 98.86608 ## Make the mean 100 } plot(sprd, type="l", xlab="2003 to 2016", ylab="EWA-hedge*EWC") ## Find minimum capital hedgeRatio = ratio*rollingbeta$Value*100 spreadPrice = 0.5*abs(adj2*100+adj1*hedgeRatio) plot(spreadPrice, type="l", xlab="2003 to 2016", ylab="0.5*(abs(EWA*100+EWC*calculatedShares))") ## Combine columns and turn into xts close = sprd date = as.data.frame(dates[22:3273]) data = cbind(date, close) dfdata = as.data.frame(data) xtsData = xts(dfdata, order.by=as.Date(dfdata$date)) xtsData$close = as.numeric(xtsData$close) xtsData$dum = vector(length = 3252) xtsData$dum = NULL xtsData$dates.22.3273. = NULL ## Add SMA, moving stdev, and z-score rollz<-function(x,n){ avg=rollapply(x, n, mean) std=rollapply(x, n, sd) z=(x-avg)/std return(z) } ## Varying the lookback has a large affect on the data xtsData$zScore = rollz(xtsData,50) symbols = 'xtsData' ## Backtest currency('USD') Sys.setenv(TZ="UTC") stock(symbols, currency="USD", multiplier=1) #trade sizing and initial equity settings tradeSize <- 10000 initEq <- tradeSize strategy.st <- portfolio.st <- account.st <- "EWA_EWC" rm.strat(portfolio.st) rm.strat(strategy.st) initPortf(portfolio.st, symbols=symbols, initDate=initDate, currency='USD') initAcct(account.st, portfolios=portfolio.st, initDate=initDate, currency='USD',initEq=initEq) initOrders(portfolio.st, initDate=initDate) strategy(strategy.st, store=TRUE) #SIGNALS add.signal(strategy = strategy.st, name="sigFormula", arguments = list(label = "enterLong", formula = "zScore < -1", cross = TRUE), label = "enterLong") add.signal(strategy = strategy.st, name="sigFormula", arguments = list(label = "exitLong", formula = "zScore > 1", cross = TRUE), label = "exitLong") add.signal(strategy = strategy.st, name="sigFormula", arguments = list(label = "enterShort", formula = "zScore > 1", cross = TRUE), label = "enterShort") add.signal(strategy = strategy.st, name="sigFormula", arguments = list(label = "exitShort", formula = "zScore < -1", cross = TRUE), label = "exitShort") #RULES add.rule(strategy = strategy.st, name = "ruleSignal", arguments = list(sigcol = "enterLong", sigval = TRUE, orderqty = 15, ordertype = "market", orderside = "long", replace = FALSE, threshold = NULL), type = "enter") add.rule(strategy = strategy.st, name = "ruleSignal", arguments = list(sigcol = "exitLong", sigval = TRUE, orderqty = "all", ordertype = "market", orderside = "long", replace = FALSE, threshold = NULL), type = "exit") add.rule(strategy = strategy.st, name = "ruleSignal", arguments = list(sigcol = "enterShort", sigval = TRUE, orderqty = -15, ordertype = "market", orderside = "short", replace = FALSE, threshold = NULL), type = "enter") add.rule(strategy = strategy.st, name = "ruleSignal", arguments = list(sigcol = "exitShort", sigval = TRUE, orderqty = "all", ordertype = "market", orderside = "short", replace = FALSE, threshold = NULL), type = "exit") #apply strategy t1 <- Sys.time() out <- applyStrategy(strategy=strategy.st,portfolios=portfolio.st) t2 <- Sys.time() print(t2-t1) #set up analytics updatePortf(portfolio.st) dateRange <- time(getPortfolio(portfolio.st)$summary)[-1] updateAcct(portfolio.st,dateRange) updateEndEq(account.st) #Stats tStats <- tradeStats(Portfolios = portfolio.st, use="trades", inclZeroDays=FALSE) tStats[,4:ncol(tStats)] <- round(tStats[,4:ncol(tStats)], 2) print(data.frame(t(tStats[,-c(1,2)]))) #Averages (aggPF <- sum(tStats$Gross.Profits)/-sum(tStats$Gross.Losses)) (aggCorrect <- mean(tStats$Percent.Positive)) (numTrades <- sum(tStats$Num.Trades)) (meanAvgWLR <- mean(tStats$Avg.WinLoss.Ratio)) #portfolio cash PL portPL <- .blotter$portfolio.EWA_EWC$summary$Net.Trading.PL ## Sharpe Ratio (SharpeRatio.annualized(portPL, geometric=FALSE)) ## Performance vs. SPY instRets <- PortfReturns(account.st) portfRets <- xts(rowMeans(instRets)*ncol(instRets), order.by=index(instRets)) cumPortfRets <- cumprod(1+portfRets) firstNonZeroDay <- index(portfRets)[min(which(portfRets!=0))] getSymbols("SPY", from=firstNonZeroDay, to="2015-12-31") SPYrets <- diff(log(Cl(SPY)))[-1] cumSPYrets <- cumprod(1+SPYrets) comparison <- cbind(cumPortfRets, cumSPYrets) colnames(comparison) <- c("strategy", "SPY") chart.TimeSeries(comparison, legend.loc = "topleft", colorset = c("green","red")) ## Chart Position rets <- PortfReturns(Account = account.st) rownames(rets) <- NULL charts.PerformanceSummary(rets, colorset = bluefocus)