---
title: "Path-Dependent Volatility: Guyon & Lekeufack (2023)"
subtitle: "Summary, In-Sample Regression, and Out-of-Sample Forecasting in R"
author: "Gabriel Kaiser"
date: today
format:
html:
toc: true
toc-depth: 3
code-fold: true
code-tools: true
theme: cosmo
highlight-style: github
self-contained: true
pdf:
toc: true
number-sections: true
highlight-style: github
execute:
echo: true
warning: false
message: false
cache: false
---
```{r setup}
#| include: false
library(dplyr)
library(tidyr)
library(ggplot2)
library(pracma)
library(patchwork)
theme_set(
theme_minimal(base_size = 12) +
theme(
plot.title = element_text(face = "bold"),
plot.subtitle = element_text(colour = "grey40", size = 9),
legend.position = "top"
)
)
```
# Model Summary
## Overview
Guyon & Lekeufack (2023) — *"Volatility Is (Mostly) Path-Dependent"* — show that
roughly **90% of S&P 500 implied volatility dynamics** can be explained by the
price path alone, without any news or macro inputs.
The model regresses current volatility on two path-derived features:
$$
\text{Vol}_t = \beta_0 + \beta_1 R_{1,t} + \beta_2 \sqrt{R_{2,t}}
$$
where:
$$
R_{1,t} = \sum_{s=1}^{K} K_1(s)\, r_{t-s} \qquad \text{(trend / leverage)}
$$
$$
R_{2,t} = \sum_{s=1}^{K} K_2(s)\, r_{t-s}^2 \qquad \text{(activity / clustering)}
$$
- **$\beta_1 < 0$** — drawdowns push volatility up (leverage effect)
- **$\beta_2 > 0$** — elevated past squared returns raise current vol (clustering)
## Kernel Choice
The paper evaluates two kernel families:
| Kernel | Formula | Parameters |
|--------|---------|------------|
| Exponential | $K(\tau) = \lambda e^{-\lambda \tau}$ | $\lambda > 0$ (decay rate) |
| Time-shifted power law (TSPL) | $K(\tau) = Z^{-1}(\tau + \delta)^{-\alpha}$ | $\alpha > 1$, $\delta > 0$ |
TSPL kernels produce **heavier-tailed memory** and better fit empirical volatility.
## Roughness Claim
Rough volatility models posit a fractional Brownian motion with $H \approx 0.1$.
Guyon & Lekeufack show their model — with plain exponential kernels — produces a
signal that **looks equally rough**, suggesting the roughness may be a spurious
artifact of path-dependency, not a true fractional process.
---
# Helper Functions
```{r helpers}
# ----------------------------------------------------------------
# Exponential kernel: K(tau) = lambda * exp(-lambda * tau)
# ----------------------------------------------------------------
exp_kernel <- function(lambda, K) {
tau <- seq_len(K)
w <- lambda * exp(-lambda * tau)
w / sum(w)
}
# ----------------------------------------------------------------
# Time-shifted power law: K(tau) = (tau + delta)^{-alpha}
# ----------------------------------------------------------------
tspl_kernel <- function(alpha, delta, K) {
tau <- seq_len(K)
w <- (tau + delta)^(-alpha)
w / sum(w)
}
# ----------------------------------------------------------------
# Compute R1 and R2 using a given kernel function
# Uses stats::filter() for speed
# ----------------------------------------------------------------
compute_features <- function(r, kernel_fn, K = 252) {
w <- kernel_fn(K)
r2 <- r^2
R1 <- as.numeric(stats::filter(r, w, sides = 1))
R2 <- as.numeric(stats::filter(r2, w, sides = 1))
data.frame(
t = seq_along(r),
R1 = R1,
R2 = R2,
Sigma = sqrt(pmax(R2, 0))
)
}
# ----------------------------------------------------------------
# 21-day rolling realized volatility (annualized)
# ----------------------------------------------------------------
roll_vol <- function(r, window = 21, ann = 252) {
n <- length(r)
rv <- rep(NA_real_, n)
for (i in (window + 1):n) {
rv[i] <- sd(r[(i - window):(i - 1)]) * sqrt(ann)
}
rv
}
# ----------------------------------------------------------------
# Hurst exponent via R/S analysis (pracma)
# ----------------------------------------------------------------
compute_hurst <- function(x) {
x <- x[!is.na(x)]
h <- pracma::hurstexp(x, display = FALSE)
list(Hs = h$Hs, Hrs = h$Hrs, He = h$He)
}
```
---
# In-Sample Regression (Paper Application)
## Synthetic Data
```{r simulate}
set.seed(42)
T_sim <- 2500
dates <- seq.Date(as.Date("2015-01-05"), by = "day", length.out = T_sim)
# GARCH(1,1): omega=1e-6, alpha=0.08, beta=0.90
sigma2 <- numeric(T_sim)
r_sim <- numeric(T_sim)
sigma2 <- 1e-6 / (1 - 0.08 - 0.90)[1]
r_sim <- rnorm(1, 0, sqrt(sigma2))[1]
for (t in 2:T_sim) {
sigma2[t] <- 1e-6 + 0.08 * r_sim[t - 1]^2 + 0.90 * sigma2[t - 1]
r_sim[t] <- rnorm(1, 0, sqrt(sigma2[t]))
}
true_vol <- sqrt(sigma2) * sqrt(252)
realized_vol <- roll_vol(r_sim, window = 21)
cat(sprintf("T = %d | mean RV = %.4f | sd RV = %.4f\n", T_sim, mean(realized_vol, na.rm = TRUE), sd(realized_vol, na.rm = TRUE)))
```
## Exponential Kernels
```{r insample-exp}
BURN <- 30
K <- 252
lambda1 <- 0.05
lambda2 <- 0.20
feats_exp <- compute_features(
r_sim,
kernel_fn = function(K) exp_kernel(lambda1, K),
K = K
)
df_exp <- data.frame(vol = realized_vol, R1 = feats_exp$R1, Sigma = feats_exp$Sigma, date = dates) |>
slice(-(1:BURN)) |>
na.omit()
fit_exp <- lm(vol ~ R1 + Sigma, data = df_exp)
df_exp$fitted <- fitted(fit_exp)
cat("=== Exponential Kernels ===\n")
print(summary(fit_exp)$coefficients)
cat(sprintf("\nR² = %.4f\n", summary(fit_exp)$r.squared))
```
## TSPL Kernels (Paper Specification)
```{r insample-tspl}
alpha1 <- 1.06
delta1 <- 0.020
alpha2 <- 1.60
delta2 <- 0.052
feats_tspl <- compute_features(
r_sim,
kernel_fn = function(K) tspl_kernel(alpha1, delta1, K),
K = K
)
Sigma_tspl <- sqrt(pmax(
as.numeric(stats::filter(r_sim^2, tspl_kernel(alpha2, delta2, K), sides = 1)),
0
))
df_tspl <- data.frame(vol = realized_vol, R1 = feats_tspl$R1, Sigma = Sigma_tspl, date = dates) |>
slice(-(1:BURN)) |>
na.omit()
fit_tspl <- lm(vol ~ R1 + Sigma, data = df_tspl)
df_tspl$fitted <- fitted(fit_tspl)
cat("=== TSPL Kernels (paper VIX params) ===\n")
print(summary(fit_tspl)$coefficients)
cat(sprintf("\nR² = %.4f\n", summary(fit_tspl)$r.squared))
```
## Model Comparison Table
```{r compare-table}
betas = coef(fit_exp)
betasFit = coef(fit_tspl)
tibble::tibble(
Kernel = c("Exponential", "TSPL (paper)"),
beta0 = c(betas[1], betasFit[1]),
beta1 = c(betas[2], betasFit[2]),
beta2 = c(betas[3], betasFit[3]),
R2 = c(summary(fit_exp)$r.squared, summary(fit_tspl)$r.squared)
) |>
mutate(across(where(is.numeric), round, 5)) |>
knitr::kable(caption = "In-sample regression summary")
```
## In-Sample Fit Plot
```{r plot-insample, fig.width=11, fig.height=4.5}
p_exp <- ggplot(df_exp, aes(x = date)) +
geom_line(aes(y = vol, colour = "Realized Vol"), linewidth = 0.5, alpha = 0.8) +
geom_line(aes(y = fitted, colour = "PDV (exp)"), linewidth = 0.8) +
scale_colour_manual(values = c("Realized Vol" = "#4f98a3", "PDV (exp)" = "#bb653b")) +
labs(
title = "Exponential Kernels",
subtitle = sprintf("R² = %.4f | λ₁=%.2f, λ₂=%.2f", summary(fit_exp)$r.squared, lambda1, lambda2),
x = NULL,
y = "Ann. Vol",
colour = NULL
)
p_tspl <- ggplot(df_tspl, aes(x = date)) +
geom_line(aes(y = vol, colour = "Realized Vol"), linewidth = 0.5, alpha = 0.8) +
geom_line(aes(y = fitted, colour = "PDV (TSPL)"), linewidth = 0.8) +
scale_colour_manual(values = c("Realized Vol" = "#4f98a3", "PDV (TSPL)" = "#437a22")) +
labs(
title = "TSPL Kernels",
subtitle = sprintf("R² = %.4f | α₁=%.2f δ₁=%.3f, α₂=%.2f δ₂=%.3f", summary(fit_tspl)$r.squared, alpha1, delta1, alpha2, delta2),
x = NULL,
y = "Ann. Vol",
colour = NULL
)
p_exp + p_tspl
```
## Roughness Check (Hurst Exponent)
```{r hurst}
h_fitted <- compute_hurst(df_tspl$fitted)
h_true <- compute_hurst(true_vol)
tibble::tibble(
Series = c("PDV fitted σ̂ (TSPL)", "True GARCH σ"),
`H (simple R/S)` = c(h_fitted$Hs, h_true$Hs),
`H (corrected R/S)` = c(h_fitted$Hrs, h_true$Hrs)
) |>
mutate(across(where(is.numeric), \(x) round(x, 4))) |>
knitr::kable(caption = "H < 0.5 = rough/anti-persistent. Paper reports H ~ 0.1.")
```
---
# Out-of-Sample Forecasting
```{r oos-functions}
oos_forecast <- function(r, vol, train_end, kernel1_fn, kernel2_fn, K = 252, burn_in = 30, refit_every = Inf) {
n <- length(r)
w1 <- kernel1_fn(K)
w2 <- kernel2_fn(K)
wdot <- function(x, w) {
m <- min(length(x), length(w))
if (m == 0) {
return(NA_real_)
}
sum(w[1:m] * rev(tail(x, m)))
}
make_df <- function(idx) {
R1 <- vapply(idx, \(t) wdot(r[1:(t - 1)], w1), numeric(1))
Sigma <- vapply(idx, \(t) sqrt(max(0, wdot(r[1:(t - 1)]^2, w2))), numeric(1))
data.frame(t = idx, vol = vol[idx], R1 = R1, Sigma = Sigma)
}
train_idx <- seq(burn_in + 1L, train_end)
train_df <- make_df(train_idx) |> na.omit()
fit <- lm(vol ~ R1 + Sigma, data = train_df)
test_idx <- seq(train_end + 1L, n)
pred <- rep(NA_real_, n)
for (i in seq_along(test_idx)) {
t <- test_idx[i]
if (is.finite(refit_every) && i > 1 && (i - 1) %% refit_every == 0) {
refit_df <- make_df(seq(burn_in + 1L, t - 1L)) |> na.omit()
if (nrow(refit_df) > 5) fit <- lm(vol ~ R1 + Sigma, data = refit_df)
}
R1_t <- wdot(r[1:(t - 1)], w1)
Sigma_t <- sqrt(max(0, wdot(r[1:(t - 1)]^2, w2)))
pred[t] <- predict(fit, newdata = data.frame(R1 = R1_t, Sigma = Sigma_t))
}
oos_df <- data.frame(
t = test_idx,
date = dates[test_idx],
vol_actual = vol[test_idx],
vol_pred = pred[test_idx]
) |>
na.omit()
oos_df$error <- oos_df$vol_actual - oos_df$vol_pred
ss_res <- sum(oos_df$error^2)
ss_tot <- sum((oos_df$vol_actual - mean(train_df$vol, na.rm = TRUE))^2)
list(
fit_is = fit,
oos = oos_df,
rmse = sqrt(mean(oos_df$error^2)),
mae = mean(abs(oos_df$error)),
r2_oos = 1 - ss_res / ss_tot,
r2_is = summary(fit)$r.squared
)
}
```
## Run OOS (80/20 Split)
```{r run-oos}
train_end <- floor(0.8 * T_sim)
res_exp <- oos_forecast(
r = r_sim,
vol = realized_vol,
train_end = train_end,
kernel1_fn = function(K) exp_kernel(0.05, K),
kernel2_fn = function(K) exp_kernel(0.20, K),
K = K,
burn_in = BURN,
refit_every = Inf
)
res_exp_roll <- oos_forecast(
r = r_sim,
vol = realized_vol,
train_end = train_end,
kernel1_fn = function(K) exp_kernel(0.05, K),
kernel2_fn = function(K) exp_kernel(0.20, K),
K = K,
burn_in = BURN,
refit_every = 20
)
res_tspl <- oos_forecast(
r = r_sim,
vol = realized_vol,
train_end = train_end,
kernel1_fn = function(K) tspl_kernel(1.06, 0.020, K),
kernel2_fn = function(K) tspl_kernel(1.60, 0.052, K),
K = K,
burn_in = BURN,
refit_every = Inf
)
tibble::tibble(
Model = c("Exp (static)", "Exp (rolling 20d)", "TSPL (static)"),
`IS R²` = c(res_exp$r2_is, res_exp_roll$r2_is, res_tspl$r2_is),
`OOS R²` = c(res_exp$r2_oos, res_exp_roll$r2_oos, res_tspl$r2_oos),
`OOS RMSE` = c(res_exp$rmse, res_exp_roll$rmse, res_tspl$rmse),
`OOS MAE` = c(res_exp$mae, res_exp_roll$mae, res_tspl$mae)
) |>
mutate(across(where(is.numeric), \(x) round(x, 5))) |>
knitr::kable(caption = "Out-of-sample performance summary")
```
## OOS Forecast Plots
```{r plot-oos, fig.width=11, fig.height=8}
make_oos_plot <- function(res, label, colour) {
ggplot(res$oos, aes(x = date)) +
geom_line(aes(y = vol_actual, colour = "Actual"), linewidth = 0.5, alpha = 0.8) +
geom_line(aes(y = vol_pred, colour = label), linewidth = 0.8) +
scale_colour_manual(values = c("Actual" = "#4f98a3", setNames(colour, label))) +
labs(
title = label,
subtitle = sprintf("OOS R² = %.4f | RMSE = %.5f | MAE = %.5f", res$r2_oos, res$rmse, res$mae),
x = NULL,
y = "Ann. Vol",
colour = NULL
)
}
make_oos_plot(res_exp, "Exp (static)", "#bb653b") /
make_oos_plot(res_exp_roll, "Exp (rolling)", "#7a39bb") /
make_oos_plot(res_tspl, "TSPL (static)", "#437a22")
```
## Error Distribution
```{r plot-errors, fig.width=10, fig.height=3.5}
bind_rows(
res_exp$oos |> mutate(model = "Exp (static)"),
res_exp_roll$oos |> mutate(model = "Exp (rolling)"),
res_tspl$oos |> mutate(model = "TSPL (static)")
) |>
ggplot(aes(x = error, fill = model)) +
geom_histogram(bins = 60, alpha = 0.7, position = "identity") +
facet_wrap(~model, ncol = 3) +
scale_fill_manual(values = c("#bb653b", "#7a39bb", "#437a22")) +
labs(title = "OOS Forecast Error Distribution", x = "Actual − Predicted", y = "Count") +
theme(legend.position = "none")
```
## ACF of OOS Residuals
```{r plot-acf, fig.width=10, fig.height=3.5}
acf_plot <- function(res, label, colour) {
ac <- acf(res$oos$error, lag.max = 40, plot = FALSE)
ci <- 1.96 / sqrt(nrow(res$oos))
data.frame(lag = ac$lag[-1], acf = ac$acf[-1]) |>
ggplot(aes(x = lag, y = acf)) +
geom_col(fill = colour, width = 0.6) +
geom_hline(yintercept = c(-ci, ci), linetype = "dashed", colour = "grey50") +
labs(title = label, x = "Lag", y = "ACF")
}
acf_plot(res_exp, "Exp (static)", "#bb653b") +
acf_plot(res_exp_roll, "Exp (rolling)", "#7a39bb") +
acf_plot(res_tspl, "TSPL (static)", "#437a22")
```
---
# Plug In Real Data
```{r real-data, eval=TRUE}
library(quantmod)
getSymbols("^GSPC", from = "2005-01-01")
r_real <- as.numeric(dailyReturn(Cl(GSPC), type = "log"))
dates <- index(GSPC)
# Target 1: future realized vol
rv_target <- roll_vol(r_real, window = 21)
# Target 2: VIX (paper's primary target)
getSymbols("^VIX", from = "2005-01-01")
vix_target <- as.numeric(Cl(VIX)) / 100
n <- min(length(r_real), length(vix_target))
r_real <- r_real[1:n]
vix_target <- vix_target[1:n]
feats_real <- compute_features(
r = r_real,
kernel_fn = function(K) tspl_kernel(1.06, 0.020, K),
K = 252
)
Sigma_real <- sqrt(pmax(
as.numeric(stats::filter(r_real^2, tspl_kernel(1.60, 0.052, 252), sides = 1)),
0
))
df_real <- data.frame(vol = vix_target, R1 = feats_real$R1, Sigma = Sigma_real) |>
slice(-(1:30)) |>
na.omit()
fit_real <- lm(vol ~ R1 + Sigma, data = df_real)
summary(fit_real) # expect R² ~ 0.90 for VIX target
```