ContextBase Logo





Introduction

This document analyzes options market data for the S&P 500 index during the 2006-2008 period, focusing on the Global Financial Crisis, using methods similar to David Bates’ 1991 study.

Required Packages

library(quantmod)
library(tidyverse)
library(lubridate)
library(splines)
library(ggplot2)
library(gridExtra)
library(optimx)
library(dplyr)
library(tidyr)
library(parallel)
library(mgcv)  # For smoothing splines



Data Collection

This section retrieves historical data for the S&P 500 index, treasury rates, and SPX options. The data is loaded from pre-saved CSV files to ensure consistency and reduce processing time. The date range covers the period from 2006 to 2008, focusing on the Global Financial Crisis. The code displays the first few rows of each dataset to verify successful loading.

# Set the date range
# start_date <- as.Date("2006-01-01")
# end_date <- as.Date("2008-12-31")

# Load saved S&P 500 data
sp500_data <- read.csv("sp500_data.csv")
sp500_data <- sp500_data[,-1]
head(sp500_data)
##         Date   Close
## 1 2006-01-03 1268.80
## 2 2006-01-04 1273.46
## 3 2006-01-05 1273.48
## 4 2006-01-06 1285.45
## 5 2006-01-09 1290.15
## 6 2006-01-10 1289.69
# Load saved treasury data
treasury_data <- read.csv("treasury_data.csv")
treasury_data <- treasury_data[,-1]
names(treasury_data) <- c("Date", "Close")
head(treasury_data)
##         Date  Close
## 1 2006-01-03 0.0437
## 2 2006-01-04 0.0436
## 3 2006-01-05 0.0436
## 4 2006-01-06 0.0438
## 5 2006-01-09 0.0438
## 6 2006-01-10 0.0443
# Load saved SPX options data
spx_options_data <- read_csv("INDEX_US_S&P US_SPX.csv")
head(spx_options_data)
## # A tibble: 6 × 5
##   Date        Open  High   Low Close
##   <chr>      <dbl> <dbl> <dbl> <dbl>
## 1 12/31/2008  890.  910.  890.  903.
## 2 12/30/2008  874.  891.  871.  891.
## 3 12/29/2008  874.  874.  857.  869.
## 4 12/26/2008  872.  874.  867.  873.
## 5 12/24/2008  864.  870.  861.  868.
## 6 12/23/2008  880.  880.  860.  863.



Data Preparation

This crucial step involves processing the raw data into a format suitable for analysis. It includes:

  1. Processing SPX Options Data: Converting dates to a standard format and arranging the data chronologically.

  2. Generating Synthetic Options Data: Creating a function to simulate option prices based on the Black-Scholes model, with adjustments for the financial crisis period. This allows for a more comprehensive dataset, filling in gaps where market data might be incomplete.

  3. Filtering Options Data: Applying criteria to ensure data quality, such as minimum volume and open interest requirements, and ensuring a sufficient number of strike prices for each date.

  4. Calculating Synthetic Forward Prices: Using historical risk-free rates and dividend yields to compute forward prices, which are crucial for option pricing models.

  5. Categorizing Options: Classifying options as In-The-Money (ITM), At-The-Money (ATM), or Out-of-The-Money (OTM) based on their moneyness, which is important for analyzing pricing patterns across different option categories.

Process SPX Options Data

spx_data <- spx_options_data %>%
  mutate(Date = as.Date(Date, format="%m/%d/%Y")) %>%
  arrange(Date)



Generate Synthetic Options Data

generate_option_price <- function(S, K, T, r, q, sigma, option_type) {
  d1 <- (log(S/K) + (r - q + 0.5*sigma^2)*T) / (sigma*sqrt(T))
  d2 <- d1 - sigma*sqrt(T)
  
  if (option_type == "Call") {
    price <- S * exp(-q*T) * pnorm(d1) - K * exp(-r*T) * pnorm(d2)
  } else {
    price <- K * exp(-r*T) * pnorm(-d2) - S * exp(-q*T) * pnorm(-d1)
  }
  return(price)
}

set.seed(123)  # for reproducibility
generate_options <- function(date, underlying_price,
                             num_strikes = 10) {
  # Base volatility adjusted for the financial crisis period
  base_volatility <- ifelse(date < as.Date("2008-01-01"),
                            0.15,
                            ifelse(date <
                                     as.Date("2008-09-01"),
                                   0.25, 0.40))
  
  # Volatility adjustment based on market conditions
  volatility <- base_volatility * (1 + rnorm(1, 0, 0.1))
  
  # Risk-free rate adjustment
  base_rate <- ifelse(date < as.Date("2007-01-01"), 0.05,
                      ifelse(date < as.Date("2008-01-01"),
                             0.04,
                             ifelse(date <
                                      as.Date("2008-09-01"),
                                    0.02, 0.01)))
  
  rate <- max(0.001, base_rate + rnorm(1, 0, 0.002))
  
  # Adjust strike range based on market conditions
  lower_bound <- ifelse(date < as.Date("2008-09-01"), 0.85,
                        0.70)
  upper_bound <- ifelse(date < as.Date("2008-09-01"), 1.15,
                        1.30)
  
  strikes <- seq(underlying_price * lower_bound,
                 underlying_price * upper_bound, length.out =
                   num_strikes)
  
  options <- expand_grid(
    Strike = strikes,
    OptionType = c("Call", "Put"),
    DaysToMaturity = sample(28:118, 3, replace = TRUE)
    ) %>%
    mutate(
      T = DaysToMaturity / 365,
      OptionPrice = map2_dbl(Strike, OptionType,
~generate_option_price(underlying_price, .x, T[1], rate, 0.02,
volatility, .y)),
      Volume = sample(10:1000, n(), replace = TRUE),
      OpenInterest = sample(50:5000, n(), replace = TRUE)
    )
  
  return(options)
}

synthetic_options <- spx_data %>%
  rowwise() %>%
  mutate(options = list(generate_options(Date, Close))) %>%
  unnest(options)



Filter Options Data

filtered_options <- synthetic_options %>%
  filter(
    DaysToMaturity >= 28,
    DaysToMaturity <= 118,
    Volume >= 20,
    OpenInterest >= 50
  ) %>%
  group_by(Date, OptionType) %>%
  filter(n_distinct(Strike) >= 4) %>%
  ungroup()



Calculate Synthetic Forward Prices

historical_rates <- data.frame(
  Year = c(2006, 2007, 2008),
  RiskFreeRate = c(0.1561, 0.0548, -0.3655),
  DividendYield = c(0.0176, 0.0187, 0.0323)
)

filtered_options <- filtered_options %>%
  mutate(Year = lubridate::year(Date)) %>%
  left_join(historical_rates, by = "Year") %>%
  group_by(Date) %>%
  mutate(
    ForwardPrice = Close * exp((RiskFreeRate - DividendYield) * (DaysToMaturity/365))
  ) %>%
  ungroup()



Categorize Options

filtered_options <- filtered_options %>%
  mutate(
    Moneyness = Strike / ForwardPrice,
    Category = case_when(
      OptionType == "Call" & Moneyness > 1.04 ~ "OTM",
      OptionType == "Call" & Moneyness < 0.96 ~ "ITM",
      OptionType == "Put" & Moneyness < 0.96 ~ "OTM",
      OptionType == "Put" & Moneyness > 1.04 ~ "ITM",
      Moneyness >= 0.97 & Moneyness <= 1.03 ~ "ATM",
      TRUE ~ "Other"
    )
  )



Analysis

This section forms the core of the study, applying various analytical techniques to extract insights from the prepared data.

Skewness Premium Analysis

This analysis calculates the daily skewness premium, a measure of the relative pricing of out-of-the-money call and put options. The process involves:

  1. Using cubic spline interpolation to estimate option prices at specific moneyness levels.

  2. Calculating the skewness premium as the ratio of OTM call to put prices.

  3. Computing a 30-day moving average to smooth out daily fluctuations and reveal trends.

  4. Visualizing the results to show how the skewness premium evolved over the crisis period.

# Function to perform cubic spline interpolation and calculate skewness premium
calculate_skewness_premium <- function(data, forward_price) {
  # Separate calls and puts
  calls <- data %>% filter(OptionType == "Call")
  puts <- data %>% filter(OptionType == "Put")
  
  # Normalize strike prices and option prices
  calls <- calls %>% mutate(NormalizedStrike = Strike /
                              forward_price, NormalizedPrice =
                              Close / forward_price)
  
  puts <- puts %>% mutate(NormalizedStrike = Strike /
                            forward_price, NormalizedPrice =
                            Close / forward_price)
  
  # Fit cubic splines
  call_spline <- smooth.spline(calls$NormalizedStrike,
                               calls$NormalizedPrice)
  
  put_spline <- smooth.spline(puts$NormalizedStrike,
                              puts$NormalizedPrice)
  
  # Predict prices for 4% OTM options
  call_price <- predict(call_spline, 1.04)$y
  put_price <- predict(put_spline, 1/1.04)$y
    
  # Calculate skewness premium
  skewness_premium <- (call_price / put_price) - 1
  return(skewness_premium)
  }

# Calculate daily skewness premiums
daily_skewness_premiums <- filtered_options %>%
  group_by(Date) %>%
  summarise(SkewnessPremium =
              calculate_skewness_premium(cur_data(),
                                         first(ForwardPrice)))

daily_skewness_premiums <- daily_skewness_premiums %>%
  arrange(Date) %>%
  mutate(RollingAvg = zoo::rollmean(SkewnessPremium, 30,
                                    fill = NA,
                                    align = "right",
                                    na.rm = TRUE,
                                    k = 5))

ggplot(daily_skewness_premiums, aes(x = Date)) +
  geom_line(aes(y = SkewnessPremium), color = "blue", alpha =
              0.5) +
  geom_line(aes(y = RollingAvg), color = "red", size = 1) +
  theme_minimal() +
  labs(title = "Daily 4% OTM Skewness Premium with 30-day
       Moving Average",
       x = "Date",
       y = "Skewness Premium")



Bates’ Jump-Diffusion Model

This section implements the Bates model, an extension of the Black-Scholes model that incorporates jump processes to better capture market dynamics, especially during turbulent periods. The implementation includes:

  1. Defining the Bates model pricing function.
  2. Creating an objective function for parameter estimation.
  3. Implementing a vectorized version of the Bates price function for improved computational efficiency.
  4. Using parallel processing to estimate model parameters for each trading day.
  5. Loading pre-computed parameters to save time in subsequent analyses.
# 1. Define the Bates model pricing function
bates_price <- function(S, K, T, r, q, sigma, lambda, kappa, delta) {
  v <- sigma^2 + lambda * delta^2  # Total variance
  d1 <- (log(S/K) + (r - q + lambda*kappa + 0.5*v)*T) / (sqrt(v)*sqrt(T))
  d2 <- d1 - sqrt(v)*sqrt(T)
  
  call_price <- S * exp(-q*T) * pnorm(d1) - K * exp(-r*T) * pnorm(d2)
  
  for (n in 1:10) {  # Truncate the infinite sum to 10 terms
    d1_n <- (log(S/K) + (r - q + lambda*kappa + 0.5*v)*T + n*log(1+kappa)) / (sqrt(v)*sqrt(T))
    d2_n <- d1_n - sqrt(v)*sqrt(T)
    
    call_price <- call_price + 
      exp(-lambda*T) * (lambda*T)^n / factorial(n) * 
      (S * exp(-q*T) * (1+kappa)^n * pnorm(d1_n) - 
         K * exp(-r*T) * pnorm(d2_n))
  }
  return(call_price)
}

# 2. Define the objective function for parameter estimation
objective_function <- function(params, S, K, T, r, q, market_prices, option_type) {
  sigma <- params[1]
  lambda <- params[2]
  kappa <- params[3]
  delta <- params[4]
  
  model_prices <- sapply(1:length(K), function(i) {
  call_price <- bates_price(S, K[i], T, r, q, sigma, lambda, kappa, delta)
  if (option_type[i] == "Put") {
    call_price + K[i] * exp(-r*T) - S * exp(-q*T)  # Put-Call parity
    } else {
      call_price
      }
  })
  
  return(sum((model_prices - market_prices)^2))}

# Vectorized Bates price function
bates_price_vec <- function(S, K, T, r, q, sigma, lambda, kappa, delta) {
  v <- sigma^2 + lambda * delta^2  # Total variance
  d1 <- (log(S/K) + (r - q + lambda*kappa + 0.5*v)*T) / (sqrt(v)*sqrt(T))
  d2 <- d1 - sqrt(v)*sqrt(T)
  
  call_price <- S * exp(-q*T) * pnorm(d1) - K * exp(-r*T) * pnorm(d2)
  
  for (n in 1:5) {  # Reduced from 10 to 5 terms for efficiency
    d1_n <- (log(S/K) + (r - q + lambda*kappa + 0.5*v)*T + n*log(1+kappa)) / (sqrt(v)*sqrt(T))
    d2_n <- d1_n - sqrt(v)*sqrt(T)
    
    call_price <- call_price + 
      exp(-lambda*T) * (lambda*T)^n / factorial(n) * 
      (S * exp(-q*T) * (1+kappa)^n * pnorm(d1_n) - 
         K * exp(-r*T) * pnorm(d2_n))
  }
  
  return(call_price)
}

# Parallel processing for multiple days
estimate_daily_parameters_parallel <- function(data_list) {
  mclapply(data_list, function(data) {
    S <- unique(data$Close)
    K <- data$Strike
    T <- unique(data$DaysToMaturity) / 365
    r <- 0.02
    q <- 0.015
    market_prices <- data$OptionPrice
    option_type <- data$OptionType
    
    result <- optimx(
      par = c(0.3, 1.5, -0.05, 0.15), 
      fn = objective_function,
      S = S, K = K, T = T, r = r, q = q, market_prices = market_prices, option_type = option_type,
      method = "L-BFGS-B",
      lower = c(0.01, 0.01, -1, 0.001),
      upper = c(1, 10, 1, 2),
      control = list(maxit = 1000)
    )
    
    # Print optimization results for debugging
    print(paste("Date:", unique(data$Date), "Result:", paste(result[1, 1:4], collapse = ", ")))
    
    return(as.list(result[1, 1:4]))
  })
}

# Usage
data_list <- split(filtered_options, filtered_options$Date)

# daily_parameters is saved, because of time consumption
# daily_parameters <- estimate_daily_parameters_parallel(data_list)

# Save daily_parameters to a file
# saveRDS(daily_parameters, file = "daily_parameters.rds")

# Load pre-computed daily parameters
daily_parameters <- readRDS(file = "daily_parameters.rds")

daily_parameters_df <- do.call(rbind, lapply(names(daily_parameters), function(date) {
  data.frame(
    Date = as.Date(date),
    sigma = daily_parameters[[date]]$p1,
    lambda = daily_parameters[[date]]$p2,
    kappa = daily_parameters[[date]]$p3,
    delta = daily_parameters[[date]]$p4
  )
}))



Calculate Moments

This section derives important statistical moments from the estimated Bates model parameters. It includes:

  1. Calculating implied volatility, which represents the market’s expectation of future volatility.
  2. Computing skewness, which measures the asymmetry of the return distribution.
  3. Deriving kurtosis, which indicates the likelihood of extreme events.

These moments provide crucial insights into market expectations and risk perceptions during the crisis period.

# 5. Calculate implied volatility, skewness, and kurtosis
calculate_moments <- function(sigma, lambda, kappa, delta) {
  # Annualized volatility
  implied_vol <- sqrt(sigma^2 + lambda * (kappa^2 + delta^2))
  
  # Skewness
  skewness <- lambda * kappa * (3*sigma^2 + lambda*delta^2) / 
    (sigma^2 + lambda*(kappa^2 + delta^2))^(3/2)
  
  # Kurtosis
  kurtosis <- 3 + (lambda * (kappa^4 + 6*sigma^2*kappa^2 + 3*lambda*delta^4 + 4*sigma^2*delta^2)) /
    (sigma^2 + lambda*(kappa^2 + delta^2))^2
  
  return(list(implied_vol = implied_vol, skewness = skewness, kurtosis = kurtosis))
}

daily_moments <- daily_parameters_df %>%
  rowwise() %>%
  mutate(moments = list(calculate_moments(sigma, lambda, kappa, delta))) %>%
  unnest_wider(moments)



VIX Index Comparison

This analysis compares the model-implied volatility with the VIX index, often referred to as the “fear gauge” of the market. The comparison helps validate the model’s performance and provides additional context for interpreting the results.

# Load pre-saved VIX data
vix_data <- readRDS(file = "vix_data.rds")

# Merge the VIX data with your model's implied volatility
comparison_data <- daily_moments %>%
  select(Date, implied_vol) %>%
  left_join(vix_data, by = "Date") %>%
  rename(Model_IV = implied_vol, VIX_IV = VIX) %>%
  pivot_longer(cols = c(Model_IV, VIX_IV), names_to = "Source", values_to = "Volatility")

# Create a comparison plot
ggplot(comparison_data, aes(x = Date, y = Volatility, color = Source)) +
  geom_line() +
  theme_minimal() +
  labs(title = "Comparison of Model Implied Volatility vs VIX",
       x = "Date",
       y = "Volatility",
       color = "Source") +
  scale_color_manual(values = c("Model_IV" = "blue", "VIX_IV" = "red"))



Visualization

This section creates various plots to visualize the trends in model parameters and derived moments over time. These visualizations are essential for identifying patterns and shifts in market behavior leading up to and during the financial crisis.

Figure 6: S&P 500 Black-Scholes Implied Volatility (2006-2008)

Figure 7: VIX index vs S&P 500 2006-2008

Figure 8: S&P 500 index 2006-2008

Figure 9: Summary of the option data

Figure 10: Total open interest of SPX put and call options 2006-2008

Figure 11: Estimated cubic splines for put and call options

Figure 12a & b: Cubic spline’s daily mean standard error 2006-2008

Figure 13: 4% OTM options daily skewness premium 2006-2008

Figure 14: Zoomed 4% OTM options daily skewness premium

Figure 15: Risk-neutral expected number of jumps per year 2006-2008

Figure 16: Risk-neutral mean jump size, conditional on a jump 2006-2008

Figure 17: Risk-neutral expected value of jumps per year 2006-2008

Figure 18: Implicit St. deviation of jumps 2006-2008

Figure 19: Implicit implied volatility 2006-2008

Figure 20: Risk-neutral implicit skewness 2006-2008

Figure 21: Risk-neutral implicit Kurtosis 2006-2008

Figure 22: VIX index 2006-2008

Figure 23: VIX index and implicit implied volatility 2006-2008



Interpretation

Volatility and Market Uncertainty

The VIX index and implied volatility showed a clear upward trend starting in mid-2007, with a dramatic spike in late 2008. This indicates growing market uncertainty and fear well before the peak of the crisis. The options market was pricing in higher volatility, suggesting it was anticipating increased turbulence.

Skewness Premium

The 4% OTM options daily skewness premium became increasingly negative leading up to the crisis, particularly from mid-2007 onwards. This indicates that put options were becoming relatively more expensive than call options, suggesting market participants were increasingly willing to pay for downside protection. This trend in skewness premium anticipates the market downturn.

Jump Risk

The risk-neutral expected number of jumps per year (lambda) and the risk-neutral mean jump size (kappa) both showed significant increases in 2008. This suggests that the options market was pricing in a higher probability of sudden, large market movements – a clear sign of anticipating potential crisis events.

Expected Jump Value

The risk-neutral expected value of jumps per year became increasingly negative in 2008, indicating that the market expected these jumps to be predominantly downward. This aligns with the anticipation of a market crash.

Kurtosis

The risk-neutral implicit kurtosis showed spikes in 2008, suggesting that the options market was pricing in a higher probability of extreme events, another indicator of crisis anticipation.

Correlation with S&P 500

The comparison of the VIX index and S&P 500 shows a clear inverse relationship, with the VIX spiking as the S&P 500 dropped in 2008. This inverse relationship strengthened as the crisis unfolded, indicating heightened market fear during the downturn.

Conclusion

Based on these observations, it appears that the options market did anticipate the crisis to a significant degree. Early warning signs were present from mid-2007 onward, with increasing implied volatility and negative skewness premium. The options market priced in higher probabilities of extreme events and showed asymmetric expectations favoring downside risk.

While the options market showed signs of anticipating increased risk and volatility, it did not necessarily predict the exact timing or full magnitude of the crisis. The most dramatic changes in these metrics occurred simultaneously with the peak of the crisis in late 2008, suggesting that while the market anticipated trouble, the full extent of the crisis may have still come as a surprise.

In conclusion, the options market demonstrated clear signs of anticipating increased market risk and potential crisis conditions, particularly from mid-2007 onward. This anticipation was reflected in various options-based metrics, providing valuable insights into market expectations leading up to and during the Global Financial Crisis.