quant_rv part 8: a multi-vol approach

Sum up: by combining all the vols into one strategy and randomizing key parameters, we can generate useful signals that yield a decent return with some consistency. We’re not meeting all the quant_rv goals yet, but we’re making progress on all the fronts.

~ Links to earlier parts ~ Part 1: jumping in, Part 2: cleanup, Part 3: new goals, Part 4: heatmaps, Part 5: param exploration, Part 6: different vol measure, Part 7: all the vols

The goal I most treasure for quant_rv is about keeping myself honest. I’ve been fooled by strategy backtests many times, even when I’ve tried desperately to keep my eyes wide open. The goal is #2: “develops signals based on sensible, logical, statistically meaningful market observations (like realized volatility)”. The best practical way I know to do this, to keep this formally in front of us, is to take care to not cherry pick any important parameters, but rather to randomize them and show the range of backtests that result. So that’s what we do in this post, we produce an R script with a PQMC approach to backtesting a multiple-volatility (multi-vol) strategy. I’m not mathy enough to formally produce a Monte Carlo simulation, so we’ll use a simple loop and some randomizing to produce a Poor Quant’s Monte Carlo here. But let’s start with a little initial test based on the R scripts we’ve already used for quant_rv.

### quant_rv v1.3.0 by babbage9010 and friends
# fix 1: use SPY adjusted for signal
# fix 2: use stats::lag() instead of Lag() when calculating returns
# change 1: implement multi-volatility signal for Strategy 2
### released under MIT License

# Step 1: Load necessary libraries and data
library(quantmod)
library(PerformanceAnalytics)

date_start <- as.Date("2006-07-01")
date_end <- as.Date("2019-12-31")
symbol_bench1  <- "SPY"  # benchmark for comparison
symbol_signal1 <- "SPY"  # S&P 500 symbol (use SPY or ^GSPC)
symbol_trade1  <- "SPY"  # ETF to trade

data_spy <- getSymbols(symbol_bench1, src = "yahoo", from = date_start, to = date_end, auto.assign = FALSE)
prices_benchmark <- Ad(data_spy) #SPY ETF, Adjusted(Ad) for the benchmark
prices_signal1 <- Ad(data_spy) #SPY ETF, Adjusted(Ad) for the signal (realized vol)
prices_trade1 <- Op(data_spy) #SPY data, Open(Op) for our trading

# Step 2: Calculate ROC series
roc_signal1 <-   ROC(prices_signal1, n = 1, type = "discrete")
roc_benchmark <- ROC(prices_benchmark, n = 1, type = "discrete")
roc_trade1 <-    ROC(prices_trade1, n = 1, type = "discrete")

# Step 3: Develop the trading strategies
# Strategy 1: A benchmark strategy
lookback_period1 <- 20
threshold1 <- 0.15
label_strategy1 <- "Strategy 1: rv20d15"
volatility1 <- runSD(roc_signal1, n = lookback_period1) * sqrt(252)
signal_1 <- ifelse(volatility1 < threshold1, 1, 0)
signal_1[is.na(signal_1)] <- 0

# Strategy 2: The one plotted first, with Daily Returns shown
# we're using four measures of volatility with three lookback periods
label_strategy2 <- "Strategy 2: randomized multivol"

#lookback periods randomized
lookback_long <- floor(runif(4, min = 20, max = 25)) #20-25
lookback_med <- floor(runif(4, min = 12, max = 16)) #12-16
lookback_short <- floor(runif(4, min = 4, max = 8))   #4-8

#strategy volatility threshold randomized
vthresh <- runif(21, min = 0.12, max = 0.17)

#calculate all the volatility measures (12)
vol_cc_L <- volatility(data_spy, n = lookback_long[1], calc = "close")
vol_cc_M <- volatility(data_spy, n = lookback_med[1], calc = "close")
vol_cc_S <- volatility(data_spy, n = lookback_short[1], calc = "close")
vol_rs_L <- volatility(data_spy, n = lookback_long[2], calc = "rogers.satchell")
vol_rs_M <- volatility(data_spy, n = lookback_med[2], calc = "rogers.satchell")
vol_rs_S <- volatility(data_spy, n = lookback_short[2], calc = "rogers.satchell")
vol_p_L <- volatility(data_spy, n = lookback_long[3], calc = "parkinson")
vol_p_M <- volatility(data_spy, n = lookback_med[3], calc = "parkinson")
vol_p_S <- volatility(data_spy, n = lookback_short[3], calc = "parkinson")
vol_gkyz_L <- volatility(data_spy, n = lookback_long[4], calc = "gk.yz")
vol_gkyz_M <- volatility(data_spy, n = lookback_med[4], calc = "gk.yz")
vol_gkyz_S <- volatility(data_spy, n = lookback_short[4], calc = "gk.yz")

#calculate the signals
# note that volatility for Rogers-Satchell, Parkinson and GK-YZ
# all generate positive and negative values, so we signal a low vol zone for those
sig_cc_L <- ifelse(vol_cc_L < vthresh[1], 1, 0)
sig_cc_M <- ifelse(vol_cc_M < vthresh[2], 1, 0)
sig_cc_S <- ifelse(vol_cc_S < vthresh[3], 1, 0)
sig_rs_L <- ifelse(vol_rs_L > -vthresh[4] & vol_rs_L < vthresh[5], 1, 0)
sig_rs_M <- ifelse(vol_rs_M > -vthresh[6] & vol_rs_M < vthresh[7], 1, 0)
sig_rs_S <- ifelse(vol_rs_S > -vthresh[8] & vol_rs_S < vthresh[9], 1, 0)
sig_p_L <- ifelse(vol_p_L > -vthresh[10] & vol_p_L < vthresh[11], 1, 0)
sig_p_M <- ifelse(vol_p_M > -vthresh[12] & vol_p_M < vthresh[13], 1, 0)
sig_p_S <- ifelse(vol_p_S > -vthresh[14] & vol_p_S < vthresh[15], 1, 0)
sig_gkyz_L <- ifelse(vol_gkyz_L > -vthresh[16] & vol_gkyz_L < vthresh[17], 1, 0)
sig_gkyz_M <- ifelse(vol_gkyz_M > -vthresh[18] & vol_gkyz_M < vthresh[19], 1, 0)
sig_gkyz_S <- ifelse(vol_gkyz_S > -vthresh[20] & vol_gkyz_S < vthresh[21], 1, 0)

#add up the signals
totalvol <- (
  sig_cc_L
  + sig_cc_M
  + sig_cc_S
  + sig_rs_L 
  + sig_rs_M 
  + sig_rs_S  
  + sig_p_L 
  + sig_p_M 
  + sig_p_S 
  + sig_gkyz_L 
  + sig_gkyz_M 
  + sig_gkyz_S 
)

#look for any positive signal (or increase this threshold up to 12)
signal_2 <- ifelse(totalvol >= 1, 1, 0)
signal_2[is.na(signal_2)] <- 0

# Step 4: Backtest the strategies
returns_strategy1 <- roc_trade1 * stats::lag(signal_1, 2)
returns_strategy1 <- na.omit(returns_strategy1)
returns_strategy2 <- roc_trade1 * stats::lag(signal_2, 2)
returns_strategy2 <- na.omit(returns_strategy2)

# Calculate benchmark returns
returns_benchmark <- roc_benchmark 
returns_benchmark <- stats::lag(returns_benchmark, 2)
returns_benchmark <- na.omit(returns_benchmark)

# Step 5: Evaluate performance and risk metrics
# add an "exposure" metric (informative, not evaluative)
exposure <- function(vec){ sum(vec != 0) / length(vec) }
comparison <- cbind(returns_strategy2, returns_benchmark, returns_strategy1)
colnames(comparison) <- c(label_strategy2, "Benchmark SPY total return", label_strategy1)
stats_rv <- rbind(table.AnnualizedReturns(comparison), maxDrawdown(comparison))
charts.PerformanceSummary(comparison, main = "Realized Vol Strategies vs S&P 500 Benchmark")
exposure_s2 <- exposure(returns_strategy2)
exposure_s1 <- exposure(returns_strategy1)
print( paste("Exposure for Strategy 2:", exposure_s2) ) 
print( paste("Exposure for Strategy 1:", exposure_s1) ) 

That’s a new one (1.3.0) based on the old one (1.2.0) that shows how we calculate RV with all the vol measures we’ve discussed except we use lists of randomly generated key parameters that go into signal generation (lookback periods and volatility signal thresholds). They’re not wildly randomized… I’ve chosen to use three lookback periods (long, medium, short) which have ranges of 20-25 days, 12-16 days and 4-8 days respectively, and the randomized vol thresholds are floats between 0.12 and 0.17. Try others, play with it, I find these representative of the parameter space that seemed most interesting in the earlier parts of this series of articles, but I admit these ranges are not completely random nor demonstrably, statistically, scientifically, wisely chosen. They just are what they are, my attempt to approach this issue with more rigor than comes with cherry picking one set of parameters. I would welcome suggestions for more rigor than I’m providing.

Then we add up all the signals (12 vol measures – three lookback periods and four volatility formulae, and 12 signals using these vols and random vol thresholds), and go long if ANY of the signals flash positive… we’re only flat if all 12 signals indicate volatility above their threshold values.

You can change this of course, it’s on line 95 here: just make totalvol >= 2 if you want to have at least two signals flashing LOW VOL, or whatever.

signal_2 <- ifelse(totalvol >= 1, 1, 0)

For now, I like it this way. It maximizes market exposure, seems to give decent results. Here’s a representative backtest equity curve (representative in that the overall return is about in the middle of what you get with these particular randomized parameters). Strategy 1 (green) is the 20-day realized vol + 0.15 vol threshold strategy that was used in the very first post, and Strategy 2 (red) is our new randomized multi-volatility (multi-vol) strategy.

Looks ok, and here are the summary stats comparing all three:

                           Strategy1  Benchmark  Strategy2
Annualized Return          0.0411000  0.0933000  0.0715000
Annualized Std Dev         0.0865000  0.1901000  0.1210000
Annualized Sharpe (Rf=0%)  0.4747000  0.4911000  0.5905000
Worst Drawdown             0.1541298  0.5518944  0.2391871

Ok, nothing special or unexpected yet, so it’s time for the PQMC code. There’s nothing fun about punching the SOURCE button in R Studio over and over to get a different randomized equity curve. So we’ll let a loop do it for us. Here’s the code for quant_rv_1.3.1 (it’s just like 1.3.0 above except for the new loop at the bottom, labeled Step 6):

### quant_rv v1.3.1 by babbage9010 and friends
# change 1: change colors: Strategy 1 is red, Benchmark is black
### released under MIT License

# Step 1: Load necessary libraries and data
library(quantmod)
library(PerformanceAnalytics)

date_start <- as.Date("2006-07-01")
date_end <- as.Date("2019-12-31")
symbol_bench1  <- "SPY"  # benchmark for comparison
symbol_signal1 <- "SPY"  # S&P 500 symbol (use SPY or ^GSPC)
symbol_trade1  <- "SPY"  # ETF to trade

data_spy <- getSymbols(symbol_bench1, src = "yahoo", from = date_start, to = date_end, auto.assign = FALSE)
prices_benchmark <- Ad(data_spy) #SPY ETF, Adjusted(Ad) for the benchmark
prices_signal1 <- Ad(data_spy) #SPY ETF, Adjusted(Ad) for the signal (realized vol)
prices_trade1 <- Op(data_spy) #SPY data, Open(Op) for our trading

# Step 2: Calculate ROC series
roc_signal1 <-   ROC(prices_signal1, n = 1, type = "discrete")
roc_benchmark <- ROC(prices_benchmark, n = 1, type = "discrete")
roc_trade1 <-    ROC(prices_trade1, n = 1, type = "discrete")

# Step 3: Develop the trading strategies
# Strategy 1: A benchmark strategy
lookback_period1 <- 20
threshold1 <- 0.15
label_strategy1 <- "Strategy 1: rv20d15"
volatility1 <- runSD(roc_signal1, n = lookback_period1) * sqrt(252)
signal_1 <- ifelse(volatility1 < threshold1, 1, 0)
signal_1[is.na(signal_1)] <- 0

# Strategy 2: The one plotted first, with Daily Returns shown
# we're using four measures of volatility with three lookback periods
label_strategy2 <- "Strategy 2: randomized multivol"

#lookback periods randomized
lookback_long <- floor(runif(4, min = 20, max = 25)) #20-25
lookback_med <- floor(runif(4, min = 12, max = 16)) #12-16
lookback_short <- floor(runif(4, min = 4, max = 8))   #4-8

#strategy volatility threshold randomized
vthresh <- runif(21, min = 0.12, max = 0.17)

#calculate all the volatility measures (12)
vol_cc_L <- volatility(data_spy, n = lookback_long[1], calc = "close")
vol_cc_M <- volatility(data_spy, n = lookback_med[1], calc = "close")
vol_cc_S <- volatility(data_spy, n = lookback_short[1], calc = "close")
vol_rs_L <- volatility(data_spy, n = lookback_long[2], calc = "rogers.satchell")
vol_rs_M <- volatility(data_spy, n = lookback_med[2], calc = "rogers.satchell")
vol_rs_S <- volatility(data_spy, n = lookback_short[2], calc = "rogers.satchell")
vol_p_L <- volatility(data_spy, n = lookback_long[3], calc = "parkinson")
vol_p_M <- volatility(data_spy, n = lookback_med[3], calc = "parkinson")
vol_p_S <- volatility(data_spy, n = lookback_short[3], calc = "parkinson")
vol_gkyz_L <- volatility(data_spy, n = lookback_long[4], calc = "gk.yz")
vol_gkyz_M <- volatility(data_spy, n = lookback_med[4], calc = "gk.yz")
vol_gkyz_S <- volatility(data_spy, n = lookback_short[4], calc = "gk.yz")

#calculate the signals
# note that volatility for Rogers-Satchell, Parkinson and GK-YZ
# all generate positive and negative values, so we signal a low vol zone for those
sig_cc_L <- ifelse(vol_cc_L < vthresh[1], 1, 0)
sig_cc_M <- ifelse(vol_cc_M < vthresh[2], 1, 0)
sig_cc_S <- ifelse(vol_cc_S < vthresh[3], 1, 0)
sig_rs_L <- ifelse(vol_rs_L > -vthresh[4] & vol_rs_L < vthresh[5], 1, 0)
sig_rs_M <- ifelse(vol_rs_M > -vthresh[6] & vol_rs_M < vthresh[7], 1, 0)
sig_rs_S <- ifelse(vol_rs_S > -vthresh[8] & vol_rs_S < vthresh[9], 1, 0)
sig_p_L <- ifelse(vol_p_L > -vthresh[10] & vol_p_L < vthresh[11], 1, 0)
sig_p_M <- ifelse(vol_p_M > -vthresh[12] & vol_p_M < vthresh[13], 1, 0)
sig_p_S <- ifelse(vol_p_S > -vthresh[14] & vol_p_S < vthresh[15], 1, 0)
sig_gkyz_L <- ifelse(vol_gkyz_L > -vthresh[16] & vol_gkyz_L < vthresh[17], 1, 0)
sig_gkyz_M <- ifelse(vol_gkyz_M > -vthresh[18] & vol_gkyz_M < vthresh[19], 1, 0)
sig_gkyz_S <- ifelse(vol_gkyz_S > -vthresh[20] & vol_gkyz_S < vthresh[21], 1, 0)

#add up the signals
totalvol <- (
  sig_cc_L
  + sig_cc_M
  + sig_cc_S
  + sig_rs_L 
  + sig_rs_M 
  + sig_rs_S  
  + sig_p_L 
  + sig_p_M 
  + sig_p_S 
  + sig_gkyz_L 
  + sig_gkyz_M 
  + sig_gkyz_S 
)

#look for any positive signal (or increase this threshold up to 12)
signal_2 <- ifelse(totalvol >= 1, 1, 0)
signal_2[is.na(signal_2)] <- 0

# Step 4: Backtest the strategies
returns_strategy1 <- roc_trade1 * stats::lag(signal_1, 2)
returns_strategy1 <- na.omit(returns_strategy1)
returns_strategy2 <- roc_trade1 * stats::lag(signal_2, 2)
returns_strategy2 <- na.omit(returns_strategy2)

# Calculate benchmark returns
returns_benchmark <- roc_benchmark 
returns_benchmark <- stats::lag(returns_benchmark, 2)
returns_benchmark <- na.omit(returns_benchmark)

# Step 5: Evaluate performance and risk metrics
# add an "exposure" metric (informative, not evaluative)
exposure <- function(vec){ sum(vec != 0) / length(vec) }
comparison <- cbind(returns_benchmark, returns_strategy1,returns_strategy2)
colnames(comparison) <- c("Benchmark SPY total return", label_strategy1, label_strategy2)
stats_rv <- rbind(table.AnnualizedReturns(comparison), maxDrawdown(comparison))
charts.PerformanceSummary(comparison, main = "Realized Vol Strategies vs S&P 500 Benchmark")
exposure_s2 <- exposure(returns_strategy2)
exposure_s1 <- exposure(returns_strategy1)
print( paste("Exposure for Strategy 2:", exposure_s2) ) 
print( paste("Exposure for Strategy 1:", exposure_s1) ) 

#Step 6: random generation/plotting of strategies
#srs <- as.xts(returns_benchmark)
comparison3 <- as.xts(returns_benchmark)
for(s in 1:20){

  # Strategy 2: The one plotted first, with Daily Returns shown
  # we're using four measures of volatility with three lookback periods
  label_strategy2 <- "Strategy 2: randomized multivol"
  
  #lookback periods randomized
  lookback_long <- floor(runif(4, min = 20, max = 25)) #20-25
  lookback_med <- floor(runif(4, min = 12, max = 16)) #12-16
  lookback_short <- floor(runif(4, min = 4, max = 8))   #4-8
  
  #strategy volatility threshold randomized
  vthresh <- runif(21, min = 0.12, max = 0.17)
  
  #calculate all the volatility measures (12)
  vol_cc_L <- volatility(data_spy, n = lookback_long[1], calc = "close")
  vol_cc_M <- volatility(data_spy, n = lookback_med[1], calc = "close")
  vol_cc_S <- volatility(data_spy, n = lookback_short[1], calc = "close")
  vol_rs_L <- volatility(data_spy, n = lookback_long[2], calc = "rogers.satchell")
  vol_rs_M <- volatility(data_spy, n = lookback_med[2], calc = "rogers.satchell")
  vol_rs_S <- volatility(data_spy, n = lookback_short[2], calc = "rogers.satchell")
  vol_p_L <- volatility(data_spy, n = lookback_long[3], calc = "parkinson")
  vol_p_M <- volatility(data_spy, n = lookback_med[3], calc = "parkinson")
  vol_p_S <- volatility(data_spy, n = lookback_short[3], calc = "parkinson")
  vol_gkyz_L <- volatility(data_spy, n = lookback_long[4], calc = "gk.yz")
  vol_gkyz_M <- volatility(data_spy, n = lookback_med[4], calc = "gk.yz")
  vol_gkyz_S <- volatility(data_spy, n = lookback_short[4], calc = "gk.yz")
  
  #calculate the signals
  # note that volatility for Rogers-Satchell, Parkinson and GK-YZ
  # all generate positive and negative values, so we signal a low vol zone for those
  sig_cc_L <- ifelse(vol_cc_L < vthresh[1], 1, 0)
  sig_cc_M <- ifelse(vol_cc_M < vthresh[2], 1, 0)
  sig_cc_S <- ifelse(vol_cc_S < vthresh[3], 1, 0)
  sig_rs_L <- ifelse(vol_rs_L > -vthresh[4] & vol_rs_L < vthresh[5], 1, 0)
  sig_rs_M <- ifelse(vol_rs_M > -vthresh[6] & vol_rs_M < vthresh[7], 1, 0)
  sig_rs_S <- ifelse(vol_rs_S > -vthresh[8] & vol_rs_S < vthresh[9], 1, 0)
  sig_p_L <- ifelse(vol_p_L > -vthresh[10] & vol_p_L < vthresh[11], 1, 0)
  sig_p_M <- ifelse(vol_p_M > -vthresh[12] & vol_p_M < vthresh[13], 1, 0)
  sig_p_S <- ifelse(vol_p_S > -vthresh[14] & vol_p_S < vthresh[15], 1, 0)
  sig_gkyz_L <- ifelse(vol_gkyz_L > -vthresh[16] & vol_gkyz_L < vthresh[17], 1, 0)
  sig_gkyz_M <- ifelse(vol_gkyz_M > -vthresh[18] & vol_gkyz_M < vthresh[19], 1, 0)
  sig_gkyz_S <- ifelse(vol_gkyz_S > -vthresh[20] & vol_gkyz_S < vthresh[21], 1, 0)
  
  #add up the signals
  totalvol <- (
    sig_cc_L
    + sig_cc_M
    + sig_cc_S
    + sig_rs_L 
    + sig_rs_M 
    + sig_rs_S  
    + sig_p_L 
    + sig_p_M 
    + sig_p_S 
    + sig_gkyz_L 
    + sig_gkyz_M 
    + sig_gkyz_S 
  )
  
  #look for any positive signal (or increase this threshold up to 12)
  signal_2 <- ifelse(totalvol >= 1, 1, 0)
  signal_2[is.na(signal_2)] <- 0
  
  # Step 4: Backtest the strategies
  returns_strategy2 <- roc_trade1 * stats::lag(signal_2, 2)
  returns_strategy2 <- na.omit(returns_strategy2)
  
  rtns <- returns_strategy2
  comparison3 <- cbind(comparison3, rtns) 
  print( exposure(rtns)) 
  
}
charts.PerformanceSummary(comparison3, main = "Random RV Strategies vs S&P 500 Benchmark")
stats_rv5 <- rbind(table.AnnualizedReturns(comparison3), maxDrawdown(comparison3))

Sorry about repeating all that Strategy 2 code that’s up in the first part down in the loop at the bottom, but it’s mostly a straight copy/paste for clarity. Now let’s see what it looks like. Press SOURCE and…

So the first strategy here is the Benchmark (SPY, buy-and-hold, adjusted for dividends) and it’s in black. You can see it pretty well because it has the lowest drawdown in early 2009 and finishes at the top of the pile so it’s pretty easy to see for comparison. The other strategies are 20 sample randomized runs. You can change that number to whatever you want, but 20 doesn’t take long and gives you an idea of what’s cooking. If you’re running this in R Studio, it spits out the market exposure percentages for each of the 20 runs in the console window. And you can click the stats_rv5 object to view the CAGR, SD, Sharpe ratio, and max drawdown for each of the 20 runs.

What’s to like here? Well, for this time period, the market exposure of this strategy averages about 80-85%, and it returns about 7% annual, on average. That return matches the return for a SPY buy-and-hold strategy calculated with Open-to-Open (unadjusted for dividends) prices. So this is really pretty good! Our quant_rv Strat is gaining all the expected return from a buy-and-hold (sans divvies) strategy with only 85% market exposure, and with higher Sharpe and lower max drawdown. And because we’re not cherry picking parameters here, it looks logical and reasonable. We haven’t reached the real goal yet, of beating the SPY total return benchmark without counting our own dividends, not even close. But we’re doing well on the main front of crafting a logical, sensible strategy. It just looks like we need more than simple measures of historical volatility to get there. More on that front soon.



Leave a comment

Blog at WordPress.com.

Design a site like this with WordPress.com
Get started