An Attempt At Replicating David Varadi’s Percentile Channels Strategy

This post will detail an attempt at replicating David Varadi’s percentile channels strategy. As I’m only able to obtain data back to mid 2006, the exact statistics will not be identical. However, of the performance I do have, it is similar (but not identical) to the corresponding performance presented by David Varadi.

First off, before beginning this post, I’d like to issue a small mea culpa regarding the last post. It turns out that Yahoo’s data, once it gets into single digit dollar prices, is of questionable accuracy, and thus, results from the late 90s on mutual funds with prices falling into those ranges are questionable, as a result. As I am an independent blogger, and also make it a policy of readers being able to replicate all of my analysis, I am constrained by free data sources, and sometimes, the questionable quality of that data may materially affect results. So, if it’s one of your strategies replicated on this blog, and you find contention with my results, I would be more than happy to work with the data used to generate the original results, corroborate the results, and be certain that any differences in results from using lower-quality, publicly-available data stem from that alone. Generally, I find it surprising that a company as large as Yahoo can have such gaping data quality issues in certain aspects, but I’m happy that I was able to replicate the general thrust of QTS very closely.

This replication of David Varadi’s strategy, however, is not one such case–mainly because the data for DBC does not extend back very far (it was in inception only in 2006, and the data used by David Varadi’s programmer was obtained from Bloomberg, which I have no access to), and furthermore, I’m not certain if my methods are absolutely identical. Nevertheless, the strategy in and of itself is solid.

The way the strategy works is like this (to my interpretation of David Varadi’s post and communication with his other programmer). Given four securities (LQD, DBC, VTI, ICF), and a cash security (SHY), do the following:

Find the running the n-day quantile of an upper and lower percentile. Anything above the upper percentile gets a score of 1, anything lower gets a score of -1. Leave the rest as NA (that is, anything between the bounds).

Subset these quantities on their monthly endpoints. Any value between channels (NA) takes the quantity of the last value. (In short, na.locf). Any initial NAs become zero.

Do this with a 60-day, 120-day, 180-day, and 252-day setting at 25th and 75th percentiles. Add these four tables up (their dimensions are the number of monthly endpoints by the number of securities) and divide by the number of parameter settings (in this case, 4 for 60, 120, 180, 252) to obtain a composite position.

Next, obtain a running 20-day standard deviation of the returns (not prices!), and subset it for the same indices as the composite positions. Take the inverse of these volatility scores, and multiply it by the composite positions to get an inverse volatility position. Take its absolute value (some positions may be negative, remember), and normalize. In the beginning, there may be some zero-across-all-assets positions, or other NAs due to lack of data (EG if a monthly endpoint occurs before enough data to compute a 20-day standard deviation, there will be a row of NAs), which will be dealt with. Keep all positions with a positive composite position (that is, scores of .5 or 1, discard all scores of zero or lower), and reinvest the remainder into the cash asset (SHY, in our case). Those are the final positions used to generate the returns.

This is how it looks like in code.

This is the code for obtaining the data (from Yahoo finance) and separating it into cash and non-cash data.

getSymbols(c("LQD", "DBC", "VTI", "ICF", "SHY"), from="1990-01-01")

prices <- cbind(Ad(LQD), Ad(DBC), Ad(VTI), Ad(ICF), Ad(SHY))
prices <- prices[![,2]),]
returns <- Return.calculate(prices)
cashPrices <- prices[, 5]
assetPrices <- prices[, -5]

This is the function for computing the percentile channel positions for a given parameter setting. Unfortunately, it is not instantaneous due to R’s rollapply function paying a price in speed for generality. While the package caTools has a runquantile function, as of the time of this writing, I have found differences between its output and runMedian in TTR, so I’ll have to get in touch with the package’s author.

pctChannelPosition <- function(prices, rebal_on=c("months", "quarters"),
                             dayLookback = 60, 
                             lowerPct = .25, upperPct = .75) {
  upperQ <- rollapply(prices, width=dayLookback, quantile, probs=upperPct)
  lowerQ <- rollapply(prices, width=dayLookback, quantile, probs=lowerPct)
  positions <- xts(matrix(nrow=nrow(prices), ncol=ncol(prices), NA),
  positions[prices > upperQ] <- 1
  positions[prices < lowerQ] <- -1
  ep <- endpoints(positions, on = rebal_on[1])
  positions <- positions[ep,]
  positions <- na.locf(positions)
  positions[] <- 0 
  colnames(positions) <- colnames(prices)

The way this function works is simple: computes a running quantile using rollapply, and then scores anything with price above its 75th percentile as 1, and anything below the 25th percentile as -1, in accordance with David Varadi’s post.

It then subsets these quantities on months (quarters is also possible–or for that matter, other values, but the spirit of the strategy seems to be months or quarters), and imputes any NAs with the last known observation, or zero, if it is an initial NA before any position is found. Something I have found over the course of writing this and the QTS strategy is that one need not bother implementing a looping mechanism to allocate positions monthly if there isn’t a correlation matrix based on daily data involved every month, and it makes the code more readable.

Next, we find our composite position.

#find our positions, add them up
d60 <- pctChannelPosition(assetPrices)
d120 <- pctChannelPosition(assetPrices, dayLookback = 120)
d180 <- pctChannelPosition(assetPrices, dayLookback = 180)
d252 <- pctChannelPosition(assetPrices, dayLookback = 252)
compositePosition <- (d60 + d120 + d180 + d252)/4

Next, find the running volatility for the assets, and subset them to the same time period (in this case months) as our composite position. In David Varadi’s example, the parameter is a 20-day lookback.

#find 20-day rolling standard deviations, subset them on identical indices
#to the percentile channel monthly positions
sd20 <- xts(sapply(returns[,-5], runSD, n=20),
monthlySDs <- sd20[index(compositePosition)]

Next, perform the following steps: find the inverse volatility of these quantities, multiply by the composite position score, take the absolute value, and keep any position for which the composite position is greater than zero (or technically speaking, has positive signage). Due to some initial NA rows due to a lack of data (either not enough days to compute a running volatility, or no positive positions yet), those will simply be imputed to zero. Reinvest the remainder in cash.

#compute inverse volatilities
inverseVols <- 1/monthlySDs

#multiply inverse volatilities by composite positions
invVolPos <- inverseVols*compositePosition

#take absolute values of inverse volatility multiplied by position
absInvVolPos <- abs(invVolPos)

#normalize the above quantities
normalizedAbsInvVols <- absInvVolPos/rowSums(absInvVolPos)

#keep only positions with positive composite positions (remove zeroes/negative)
nonCashPos <- normalizedAbsInvVols * sign(compositePosition > 0)
nonCashPos[] <- 0 #no positions before we have enough data

#add cash position which is complement of non-cash position
finalPos <- nonCashPos
finalPos$cashPos <- 1-rowSums(finalPos)

And finally, the punchline, how does this strategy perform?

#compute returns
stratRets <- Return.portfolio(R = returns, weights = finalPos)
stats <- rbind(table.AnnualizedReturns(stratRets), maxDrawdown(stratRets))
rownames(stats)[4] <- "Worst Drawdown"

Like this:

> stats
Annualized Return                0.10070000
Annualized Std Dev               0.06880000
Annualized Sharpe (Rf=0%)        1.46530000
Worst Drawdown                   0.07449537

With the following equity curve:

The statistics are visibly worse than David Varadi’s 10% vs. 11.1% CAGR, 6.9% annualized standard deviation vs. 5.72%, 7.45% max drawdown vs. 5.5%, and derived statistics (EG MAR). However, my data starts far later, and 1995-1996 seemed to be phenomenal for this strategy. Here are the cumulative returns for the data I have:

> apply.yearly(stratRets, Return.cumulative)
2006-12-29        0.11155069
2007-12-31        0.07574266
2008-12-31        0.16921233
2009-12-31        0.14600008
2010-12-31        0.12996371
2011-12-30        0.06092018
2012-12-31        0.07306617
2013-12-31        0.06303612
2014-12-31        0.05967415
2015-02-13        0.01715446

I see a major discrepancy between my returns and David’s returns in 2011, but beyond that, the results seem to be somewhere close in the pattern of yearly returns. Whether my methodology is incorrect (I think I followed the procedure to the best of my understanding, but of course, if someone sees a mistake in my code, please let me know), or whether it’s the result of using Yahoo’s questionable quality data, I am uncertain.

However, in my opinion, that doesn’t take away from the validity of the strategy as a whole. With a mid-1 Sharpe ratio on a monthly rebalancing scale, and steady new equity highs, I feel that this is a result worth sharing–even if not directly corroborated (yet, hopefully).

One last note–some of the readers on David Varadi’s blog have cried foul due to their inability to come close to his results. Since I’ve come close, I feel that the results are valid, and since I’m using different data, my results are not identical. However, if anyone has questions about my process, feel free to leave questions and/or comments.

Thanks for reading.

NOTE: I am a freelance consultant in quantitative analysis on topics related to this blog. If you have contract or full time roles available for proprietary research that could benefit from my skills, please contact me through my LinkedIn here.

20 thoughts on “An Attempt At Replicating David Varadi’s Percentile Channels Strategy

  1. I also use R, Python and Excel to test strategies (I’m not as skilled as you however) and I’m an independent full-time trader. I also have taken ideas from these blogs and tried to re-create them and I also find different results to those published. The rotation strategies using ETFs, currencies, commodities are examples – they just don’t work as well as the back-tests suggest. Sometimes its just a case of the strategy no longer working.

    Your work is really interesting and useful. Please keep it up – it’s appreciated.

  2. Pingback: The Whole Street’s Daily Wrap for 2/17/2015 | The Whole Street

  3. Great work Ilya, its a pleasure to follow your code (although I don’t have much experience with R,
    I understood almost everything).

    If you read the original post, you can see that Mr. Varadi mentioned that he runs 4 strategies, each one with a different lookback (60,120,180,252), each has 1/4 of the money.

    I followed the same idea, with the same rules, that is the only way I can keep the signals to be 1 or -1.

    if you aggregate the 4 strategies / lookback windows they might cancel each other,
    i.e, d60=-1, d120=-1, d180=1, d252=1,
    it is not clear what to do then – in your code the weight will be zero – so what then ?

    If I run 4 strategies, in the above scenario, 2 will hold SHY instead of the asset that has -1, and the other two will hold the asset with a kind of risk parity allocation.

    All in all, I got CAGR of 6.5% and maxDrawdown of 10%.

    Is it difficult it is to change the R script to work as separate strategies ?


    • Bob,

      This is a deliberate feature. If the strategy is unsure of a position (nets out to zero), then there’s simply no position in that security, and nor does it affect vol weighting.

      • Thanks for clarifying that Ilya,

        The score of each channel can be 1 or -1,
        If you average the scores of the 4 look-back periods, you can only get

        1 – all 1’s
        0.5 – 3 1’s and -1
        0 – 2 1’s and 2 -1’s
        -0.5 – 1 and 3 -1’s
        -1 – 4 -1’s

        Could you please explain what happens (what gets opened/closed) in the following scenarios :

        1. you have a position on XYZ with a average score of 1, and in the next re-balance, it’s score is 0.5

        2. you have a position on XYZ with a positive score, and in the next re-balance, it’s score is 0 have a position on XYZ with a average positive score, and in the next re-balance, it’s a negative score.

        4. you have a cash position instead of XYZ and in the next re-balance, its score is 0.

        5. you have a cash position instead of XYZ and in the next re-balance, its score positive.

        Thank you,

      • Bob,

        Each period’s positions are completely independent of the last period’s. The only thing that’s a dependency is that when you have a price between the two percentile channels, it takes the value of its last channel breakout.

  4. Ilya,

    I enjoy your work and how clear your R code is. For fun, I ran your code for some closed end funds going back to 1992 and got the following results.

    Annualized Return 0.1030000
    Annualized Std Dev 0.1267000
    Annualized Sharpe (Rf=0%) 0.8128000
    Worst Drawdown 0.2451781

    getSymbols(c(“ASA”, “BKT”, “EEA”, “PEO”, “EMF”, “PPR”, “MGF”), from=”1990-01-01″)
    prices <- cbind(Ad(ASA), Ad(BKT), Ad(EEA), Ad(PEO), Ad(EMF), Ad(PPR), Ad(MGF))

    #prices <- cbind(Ad(LQD), Ad(DBC), Ad(VTI), Ad(ICF), Ad(SHY))
    prices <- prices[![,6]),]
    returns <- Return.calculate(prices)
    cashPrices <- prices[, 7]
    assetPrices <- prices[, -7]


  5. I think I got you now Ilya, thanks,

    Just to clarify – are the following assumptions correct :

    if I have a position on XYZ and get an average of 0, (it’s weight will also be 0) ,
    you just close the position ?

    Only if you get a score < 0 then you can close the position and open a cash position instead ?


  6. Pingback: Conditional Percentile Channels | CSSA

  7. Once again, really nice work.

    Do you know the number of trades and average gain per trade? I’m trying to get a sense of how sensitive this method is to slippage and commissions.


  8. Pingback: A Closer Update To David Varadi’s Percentile Channels Strategy | QuantStrat TradeR

  9. Have you checked out the runPercentRank function in TTR? It should be quicker than the caTools method and still do what you want.

  10. Hi Ilya,

    Thanks a lot for your great work and sharing the source codes with us.

    I’d just like to let you know that I got the following results by slightly changing the function, pctChannelPosition. The performance in 2011 as well as other years look consistent with those of David Varadi. I may be wrong but hope this could add something. thanks.

    > apply.yearly(stratRets, Return.cumulative)
    2006-12-29 0.094903938
    2007-12-31 0.047484099
    2008-12-31 0.106487044
    2009-12-31 0.119231904
    2010-12-31 0.137398095
    2011-12-30 0.117431124
    2012-12-31 0.077141767
    2013-12-31 0.069028073
    2014-12-31 0.064279631
    2015-03-12 0.007884387

    1) Original
    positions <- positions[ep,]
    positions <- na.locf(positions)

    2) Modified (just change the order of the two lines)
    positions <- na.locf(positions)
    positions <- positions[ep,]


  11. Hi Ilya

    Big admirer of your work and I once again find myself revisiting this fantastic post. A few months back I attempted to replicate the strategy in Excel but found I ended up with some quite different allocation weights. Admittedly I am not a quant so my sheet may have contained a few mistakes and at the time I was manually inputting 20 day HVol from an external source.

    I think I followed what you outlined in the post pretty closely, so I was wondering whether you might be able to take a look and tell me where I am going wrong? Feel free to email me directly if you are free to help.

    Thanks in advance, and keep up the great work


Leave a Reply

Fill in your details below or click an icon to log in: Logo

You are commenting using your account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s