Using a simple moving average to time markets has been a successful strategy over a very long period of time. Nothing to brag home about, but it cuts the drawdown of a buy and hold by about a half, sacrificing less than 1% of the CAGR in the process. In two words, simple yet effective. Here are the important numbers (using the S&P 500 index from 1994 to 2013, inclusive):
Statitistic | SMA 200 | Buy and Hold |
---|---|---|
CAGR | 6.51% | 7.13% |
Sharpe Ratio | 0.56 | 0.37 |
Sortino Ratio | 0.05 | 0.04 |
Pct in Market | 70.14 | 99.94 |
Winning Pct | 54.61% | 53.84% |
Avg Drawdown | 2.13% | 2.48% |
Avg Annual Drawdown | 9.12% | 15.83% |
Max Drawdown | 28.28% | 56.78% |
Gain to Pain | 0.71 | 0.45 |
The moving average system is better by far. First, we can leverage it 2:1 (thus, bringing CAGR up to approximately 13%) and still, our maximum drawdown is going to be comparable to the buy and hold. Furthermore, it’s in the market only 70% – less risk and probably the returns can be further boosted by putting the money to work in alternative assets.
One problem with these type of systems are whipsaws. The system works well when the price stays away from the moving average. However, in close proximity, one may have to enter/exit in succession losing money on the way.
One way to address this issue is to use an alternative “line” to trigger the exits (or the entries, for that matter). It could be a percentage band, but that’s hardly universal. Better to bring volatility into picture.
Let’s do something more robust as an illustration.
require(quantmod) require(btutils) # For construct.indicator # Download data gspc = getSymbols("^GSPC",from="1900-01-01",auto.assign=F) # Compute the returns rets = ROC(Cl(gspc),type="discrete") # Compute the adjusted returns adj.rets = rets/sqrt(runSum(rets*rets,10)/9) # The moving average sma = runMean(adj.rets,n=200) # The standard deviation stddev = sqrt(runSum(adj.rets*adj.rets,200)/199) # Long trades are entered when the average turns positive upper.band = 0.0 # Long trades are closed when the average return goes below -0.05 standard deviations lower.band = -0.05*stddev # For the "uncushioned" version use # lower.band = 0 uu = ifelse(sma>upper.band,1,0) dd = ifelse(sma<lower.band,-1,0) long.entries = (uu == 1 & lag(uu) == 0) long.exits = (dd == -1 & lag(dd) == 0) short.entries = long.entries short.exits = long.entries short.entries[] = FALSE short.exits[] = FALSE # Given entries and exits (both for long and short positions), this function builds # the corresponding indicator. It's pure C++, so it's fast too. ind = btutils::construct.indicator(long.entries, long.exits, short.entries, short.exits)
First we apply the moving average not to the prices, but to the returns (an interpretation of David Varadi’s Error Adjusted Momentum). The “cushioned” system goes long when the average becomes positive, but to exit, it leaves some cushion below the moving average. What do we gain?
Statitistic | Cushioned EA | EA | SMA 200 | Buy and Hold |
---|---|---|---|---|
CAGR | 10.93% | 9.11% | 6.51% | 7.13% |
Sharpe Ratio | 0.80 | 0.69 | 0.56 | 0.37 |
Sortino Ratio | 0.07 | 0.06 | 0.05 | 0.04 |
Pct in Market | 80.28% | 76.29% | 70.14% | 99.94% |
Winning Pct | 55.21% | 55.00% | 54.61% | 53.84% |
Avg Drawdown | 1.88% | 2.05% | 2.13% | 2.48% |
Avg Annual Drawdown | 8.90% | 9.26% | 9.12% | 15.83% |
Max Drawdown | 19.39% | 19.57% | 28.28% | 56.78% |
Gain to Pain | 1.23 | 0.98 | 0.71 | 0.45 |
To compare apples to apples, we added two systems. The first one (dubbed EA) uses error adjusted returns to compute the SMA, but enters and exits when the SMA crosses the 0 line. The “cushioned” version is the system implemented by the above code.
Even after taking into account that the new strategies stays longer in the market, there seems to be slight improvement. But that’s not the point. The “cushioned” approach did 4 trades in total – it exited for the two bear markets. That’s about 4 trades. The non-cushioned approach had 80 trades. Mission accomplished in other words.
Hi, could you please explain the logic behind the adjusted returns formula adj.rets = sqrt(runSum(rets*rets,10)/9) ? also where does the 10/9 weight come from? Why not 10/10?
Thanks
I made a mistake – it should be “adj.rets = rets / sqrt(runSum(rets*rets,10)/9)
SD computed over a sample (that’s why 9, which is “10-1”). Instead of using the sample mean, I used 0 – which is the long term mean for the returns.
btutils? Does it work with stop-losses? Also, how does it deal with the data starting in the middle of a trend?
Regarding rets, you could do Return.calculate(Cl(gspc)) from the PerformanceAnalytics package, and for the standard deviation, runSD from TTR.
Interesting take, nevertheless. Thanks ^_^
-Ilya
Hi Ilya, sometimes I use runSD, however, it uses the running mean to compute the SD. My formula is actually the SD but using the population mean, which is 0 for the returns.
As for btutils: I started it as a fast way to backtest path dependent strategies. Thus, it does have stop loss and profit targets. However, I moved to other languages (Java currently) afterwards and haven’t used it for backtesting in a while. In other words, it can be used, but it may have bugs.
Thanks for the ideas.
Your comment says -0.5 sd, but the line below, #23, says -0.05*stddev . Is the comment or the code wrong?
The comment.
adj.rets = sqrt(runSum(rets*rets,10)/9)
wont this be always positive? (also, it looks like a std deviation formula)
Yes, that was a “bug” in the copy/paste. It should have been: “adj.rets = rets/sqrt(runSum(rets*rets,10)/9), which is the returns normalized by a short term SD, but for the SD, I am using 0 for the mean.
# Long trades are closed when the average return goes below -0.5 standard deviations
lower.band = -0.05*stddev
-0.5 std dev or -0.05 std dev?
I must be missing something in your code: rets are positives and negatives, but adj.rets are all positives (you squared the rets), hence sma is always positive and therefore there is never a sell signal. Your adj.rets goes higher as the returns get smaller (ie, peak adj.rets toward the end of the bear markets).
Did I just miss a sign somewhere? Not running R, but implementing this as a test in my own system and not getting what I was expecting.
ps. I’d never heard of using a simple SMA 200 like this (probably because of all the whipsaws), so would have expected you to test a death cross / golden cross (SMA 50/200) instead. Gold/Death cross is very nearly comparable to your reported EA results (for both CAGR and max drawdown) by my testing… but I’d like to get your EA working to see for myself.
Ah. I think perhaps you meant to put “rets” in the sma calculation instead of “adj.rets”. It’s not clear from this R code what the period is for your rets (ie, a 1-day return? 1-month return?), but if I use a 21-bar return with daily data, I get an EA that is comparable to yours (CAGR 9.5%, exposure 75%, max DD 19.3%). But so far cannot get Cushioned EA above 10% CAGR, so will work further to see if I’m properly duplicating your algorithm.
Well, i had a bug in the code – see the comments, and the code. My apologies. Yes, one can use the returns directly, but from my experience it’s better to normalize them for volatility. One way to do so, is to take a short SD (or SD using 0 for the mean, as I do) and divide the return by that value.
Interesting comparison. One question though, what happens if transaction cost is taken into consideration? Can it still beat Buy-and-hold?
Nothing really, with so little trades, the transaction costs can be ignored.
I am fairly new to R but I was able to run the code. How would one show the signal dates on the screen?
Something along the following lines maybe?
mm = merge(Cl(gspc),ind,all=F)[“1994/2013”]
index(mm)[which(as.numeric(mm[,2]) != as.numeric(lag.xts(mm[,2])),arr.ind=T)]
Nice results. Not being an R programmer, I’m struggling with the C1() function. What is that doing?
OK, I’m guessing the 1 day return, close to close – 1
It’s Cl – comes from quantmod, and returns the closing prices for a series.
How do I backtest this and get a consolidated signal without using btutils (not supported in the version of R i am using) ? I was thinking of something like this but I have not been able to get it working …
short.exits[] = FALSE
signal=0.0
x=merge.xts(sma, uu, dd, long.entries, long.exits, signal)
if (isTRUE(lag(x$long.entries))){x$signal=1}
if (lag(x$signal)==1 & isTRUE(lag(x$long.exits))){x$signal=0}
if (!isTRUE(lag(x$long.entries)) & !isTRUE(lag(x$long.exits))
What is the version of R you are using? I have been planning to compile this one function somehow for easier use, but haven’t figured out a way yet.
To create the indicator vector, from the entry/exit vectors, I usually use a loop. The goal is to set all days between an entry and the next exit to the position indicated by the entry.
Using 3.3.2 in rollApp.com