Realized Volatility and the VIX

by Jonathan Regenstein

Today we’ll explore the relationship between the VIX and the past, realized volatility of the S&P 500.

The VIX is a measure of the expected future volatility of the S&P500 and it has been quite low recently. As a volatility nerd, I came across an interesting piece from AQR on the meaning of the VIX. As a reproducibility and R nerd, I decided to reproduce some of the findings using R. Obviously, the substance and ideas here are 100% attributable to AQR. My goal is add to our R volatility toolbox and engage with that interesting AQR post - for me, an effective way to understand new research is to reproduce it.

With that said, let’s get started.

First we’ll need the price histories of the S&P500 and the VIX, and we’ll convert S&P500 prices to returns.

symbols <- c("^GSPC", "^VIX")

prices<- 
  getSymbols(symbols, src = 'yahoo', from = "1990-01-01", 
             auto.assign = TRUE, warnings = FALSE) %>% 
  map(~Cl(get(.)))
  
# get daily, cont compounded returns
sp500_returns <- na.omit(ROC(GSPC$GSPC.Close, 1, type = "continuous"))

Now we need to calculate the 20-day and 60-day trailing volatility of the S&P500 returns and annualize that volatility. We will use rollapply and the StdDev() function for the initial calculation, and then will annualize assuming 252 trading days in a year.

sp500_rolling_sd_20 <- rollapply(sp500_returns,
                             20,
                             function(x) StdDev(x))

sp500_rolling_sd_60 <- rollapply(sp500_returns,
                             60,
                             function(x) StdDev(x))
  
sp500_rolling_sd_annualized_20 <- (round((sqrt(252) * sp500_rolling_sd_20 * 100), 2))
sp500_rolling_sd_annualized_60 <- (round((sqrt(252) * sp500_rolling_sd_60 * 100), 2))

Now we have the VIX price history and the rolling 20-day and 60-day volatility of S&P500 returns, annualized. Let’s merge them to one xts object using merge.xts().

vol_vix_xts <- merge.xts(VIX$VIX.Close, sp500_rolling_sd_annualized_20, sp500_rolling_sd_annualized_60)

If we were going to use highcharter for our visualizations, we could stop here but now seems like a good time to explore ggplot2 so we will convert that xts object to a tibble. We’ll use the as_tibble() function from the tidyquant package and set preserve_row_names = TRUE.

vol_vix_df <- 
  vol_vix_xts %>% 
  as_tibble(preserve_row_names = TRUE) %>% 
  mutate(date = ymd(row.names)) %>% 
  select(date, everything(), -row.names) %>% 
  rename(vix = VIX.Close, realized_vol_20 = GSPC.Close, realized_vol_60 = GSPC.Close.1) 

Let’s start with a scatterplot to show 20-day trailing volatility on the x-axis and the VIX on the y-axis. Now that our data is in a tibble, it’s a straightforward ggplot2 call, though we’ll add some aesthetic cleanup of axis labels to keep things interesting.

ggplot_trailing20 <- 
  ggplot(vol_vix_df, aes(realized_vol_20, vix)) + 
  geom_point(colour = "light blue") +
  geom_smooth(method='lm', se = FALSE, color = "pink", size = .5) +
  ggtitle("Vix versus 20-Day Realized Vol") +
  xlab("Realized vol preceding 20 trading days") +
  ylab("Vix") +
  # Add a '%' sign to the axes without having to rescale.
  scale_y_continuous(labels = function(x){ paste0(x, "%") }) +
  scale_x_continuous(labels = function(x){ paste0(x, "%") }) +
  theme(plot.title = element_text(hjust = 0.5))

ggplot_trailing20

The scatterplot seems to be showing that the VIX is reflective of recent realized market volatility, and perhaps not telling us much more than that. That may or may not sound earth shattering but it emphasizes that when people talk about the VIX being very low, they are saying recent volatility has been very low.
Again, a deeper look at the substance can found in the original post by AQR and if any VIX experts disagree with the inferences being drawn here, I am happy to be enlightened.

Let’s take a look at a scatter with trailing 60-day volatility on the x-axis.

ggplot_trailing60 <- 
  ggplot(vol_vix_df, aes(realized_vol_60, vix)) + 
  geom_point(colour = "blue") +
  geom_smooth(method='lm', se = FALSE, color = "red", size = .5) +
  ggtitle("Vix versus 60-Day Realized Vol") +
  xlab("Realized vol preceding 60 trading days") +
  ylab("Vix") +
  scale_y_continuous(labels = function(x){ paste0(x, "%") }) +
  scale_x_continuous(labels = function(x){ paste0(x, "%") }) +
  theme(plot.title = element_text(hjust = 0.5))

ggplot_trailing60

Those two scatterplots look similar, implying that trailing 20-day and trailing 60-day realized volatility are both good explainers of the VIX. Instead of relying on our eyeballs, though, let’s reproduce AQR’s statistics with some linear modeling.

First, we’ll regress the VIX on 20-day trailing volatility and peek at the results.

vix_rv20_mod <- lm(vix ~ realized_vol_20, vol_vix_df)
tidy(vix_rv20_mod)
##              term  estimate   std.error statistic p.value
## 1     (Intercept) 7.9218589 0.084910380  93.29671       0
## 2 realized_vol_20 0.7567883 0.004765415 158.80846       0
glance(vix_rv20_mod) %>% select(r.squared, adj.r.squared)
##   r.squared adj.r.squared
## 1 0.7839854     0.7839544

We can see a coefficient of .75 and an R-squared of .78, which seems to confirm our intuition from the scatterplot.

If we regress the VIX on just trailing 60-day realized volatility, we get the below:

vix_rv60_mod <- lm(vix ~ realized_vol_60, vol_vix_df)
tidy(vix_rv60_mod)
##              term  estimate   std.error statistic p.value
## 1     (Intercept) 6.7487961 0.096837791  69.69176       0
## 2 realized_vol_60 0.8130887 0.005448011 149.24506       0
glance(vix_rv60_mod) %>%  select(r.squared, adj.r.squared)
##   r.squared adj.r.squared
## 1 0.7632533      0.763219

Similar results as before as we find a coefficient of .81 and R-Squared of .76.

Finally, if we regress the VIX on both 20 and 60-day trailing volatility, we get the following:

vix_rv2060_mod <- lm(vix ~ realized_vol_60 + realized_vol_20, vol_vix_df)
tidy(vix_rv2060_mod)
##              term  estimate   std.error statistic       p.value
## 1     (Intercept) 6.6175548 0.083438536  79.31053  0.000000e+00
## 2 realized_vol_60 0.3872809 0.009864896  39.25849 1.771072e-304
## 3 realized_vol_20 0.4444221 0.009057107  49.06888  0.000000e+00
glance(vix_rv2060_mod) %>% select(r.squared, adj.r.squared)
##   r.squared adj.r.squared
## 1  0.824443     0.8243922

Our R-squared has increased to .82 - the same findings as in the AQR post.

That’s all for today and I hope this was useful as we didn’t cover any new substance. I find that after grinding through a reproducibility exercise like this, I have a firmer grasp of the original research and at the very least there are some new tools in our R repertoire.

Thanks for reading and next time we will examine the VIX and future volatility.

Share Comments