Trading Moving Averages with Less Whipsaws

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.

Comments

  1. fusionquant says:

    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

    1. quintuitive says:

      I made a mistake – it should be “adj.rets = rets / sqrt(runSum(rets*rets,10)/9)

    2. quintuitive says:

      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.

  2. Ilya Kipnis says:

    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

    1. quintuitive says:

      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.

  3. Beliavsky says:

    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?

  4. adj.rets = sqrt(runSum(rets*rets,10)/9)
    wont this be always positive? (also, it looks like a std deviation formula)

    1. quintuitive says:

      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.

  5. # 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?

  6. bitfool says:

    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.

  7. bitfool says:

    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.

    1. quintuitive says:

      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.

  8. Donglei Du says:

    Interesting comparison. One question though, what happens if transaction cost is taken into consideration? Can it still beat Buy-and-hold?

    1. quintuitive says:

      Nothing really, with so little trades, the transaction costs can be ignored.

  9. Todd says:

    I am fairly new to R but I was able to run the code. How would one show the signal dates on the screen?

    1. quintuitive says:

      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)]

  10. cmrakes says:

    Nice results. Not being an R programmer, I’m struggling with the C1() function. What is that doing?

    1. cmrakes says:

      OK, I’m guessing the 1 day return, close to close – 1

    2. quintuitive says:

      It’s Cl – comes from quantmod, and returns the closing prices for a series.

  11. ericstrom says:

    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))

    1. quintuitive says:

      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.

  12. ericstrom says:

    Using 3.3.2 in rollApp.com

Leave a Reply