The Invariance Quest & Horizon Projection — the R engine¶
The independent R implementation, in base R only (acf, Box.test, fft). It reproduces the two foundational steps: find the i.i.d. invariant, then project it to the horizon by convolution — and confirms that the naive "square-root-of-time" VaR is too optimistic. Self-contained; no prior reading required.
The rule: model the i.i.d. increment (return, yield change), never the persistent level; then the horizon quantity is the sum of $T$ independent copies, whose distribution is the $T$-fold self-convolution.
options(repr.plot.width=11, repr.plot.height=4.6)
BLUE<-"#2b6cb0"; ORANGE<-"#dd6b20"; GREEN<-"#2f855a"; RED<-"#c53030"; GREY<-"#718096"
D <- read.csv("market_levels_daily.csv", row.names=1)
spy <- D$SPY; tnx <- D$TNX; vix <- D$VIX
ret <- diff(log(spy))*100 # SPY daily log-return (the invariant), %
cat(sprintf("Daily market levels: %d days, columns %s\n", nrow(D), paste(colnames(D), collapse=", ")))
Daily market levels: 3771 days, columns SPY, TNX, VIX
The dataset¶
Daily levels of three markets, 2010–2024 (3,771 days): SPY (equity price → invariant = log-return), TNX (10-yr yield → invariant = yield change), VIX (implied vol → invariant = log-change). Levels are persistent; the increments are i.i.d.
1. The invariance quest¶
An invariant is i.i.d.: near-zero autocorrelation and a distribution stable over time. The lag-1 autocorrelation is the quick test — $\approx 1$ for a persistent level, $\approx 0$ for the invariant.
ac1 <- function(x) acf(x, lag.max=1, plot=FALSE)$acf[2]
series <- list("SPY: log-price"=log(spy), "SPY: log-return"=ret,
"TNX: yield level"=tnx, "TNX: yield change"=diff(tnx),
"VIX: level"=vix, "VIX: log-change"=diff(log(vix))*100)
cat("Lag-1 autocorrelation (level ~1 persistent; increment ~0 invariant):\n\n")
for (nm in names(series)) {
a <- ac1(series[[nm]]); tag <- if (abs(a) < 0.2) " <- INVARIANT" else " (not invariant)"
cat(sprintf(" %-20s ac(1) = %+.3f%s\n", nm, a, tag))
}
# visual: price acf (persistent) vs return acf (flat)
par(mfrow=c(1,2))
plot(0:20, acf(log(spy), lag.max=20, plot=FALSE)$acf, type="h", lwd=3, col=RED, ylim=c(-.3,1.05),
xlab="lag", ylab="autocorrelation", main="SPY log-PRICE (persistent)"); abline(h=0)
plot(0:20, acf(ret, lag.max=20, plot=FALSE)$acf, type="h", lwd=3, col=BLUE, ylim=c(-.3,1.05),
xlab="lag", ylab="autocorrelation", main="SPY log-RETURN (invariant)"); abline(h=0)
par(mfrow=c(1,1))
lb <- Box.test(ret, lag=20, type="Ljung-Box")
# Box.test computes 1 - pchisq(Q, df), which cancels to exactly 0 for tails this small;
# take the upper tail directly so the p-value survives in double precision.
lb_p <- pchisq(lb$statistic, 20, lower.tail=FALSE)
cat(sprintf("\nLjung-Box(20) on returns: Q = %.0f on 20 df, p = %.2g -- Q is NOT tiny: returns show mild\n",
lb$statistic, lb_p))
cat("volatility clustering, the honest caveat that motivates GARCH; returns are only APPROXIMATELY i.i.d.\n")
Lag-1 autocorrelation (level ~1 persistent; increment ~0 invariant):
SPY: log-price ac(1) = +0.999 (not invariant) SPY: log-return ac(1) = -0.099 <- INVARIANT TNX: yield level ac(1) = +0.997 (not invariant) TNX: yield change ac(1) = -0.015 <- INVARIANT VIX: level ac(1) = +0.965 (not invariant) VIX: log-change ac(1) = -0.071 <- INVARIANT
Ljung-Box(20) on returns: Q = 217 on 20 df, p = 4.3e-35 -- Q is NOT tiny: returns show mild
volatility clustering, the honest caveat that motivates GARCH; returns are only APPROXIMATELY i.i.d.
2. Horizon projection by convolution¶
Since the invariant is i.i.d., the $T$-day return is the sum of $T$ copies, whose density is the $T$-fold self-convolution — computed exactly with the FFT: $\mathcal F^{-1}[\mathcal F[p]^T]$. We check it against simulation, and watch the distribution drift toward normal (the central limit theorem).
moms <- function(x){ m<-mean(x); s<-sd(x); z<-(x-m)/s
list(mean=m, sd=s, skew=mean(z^3), exkurt=mean(z^4)-3) }
project_fft <- function(x, T, bins=1500){
lo<-min(x); hi<-max(x); dx<-(hi-lo)/(bins-1)
h<-hist(x, breaks=seq(lo-dx/2, hi+dx/2, length.out=bins+1), plot=FALSE)$counts
p<-h/sum(h); L<-T*(bins-1)+1
ph<-fft(c(p, rep(0, L-length(p))))
dens<-pmax(Re(fft(ph^T, inverse=TRUE))/L, 0)
grid<-T*lo + (0:(L-1))*dx; dens<-dens/(sum(dens)*dx)
list(grid=grid, dens=dens)
}
sim_h <- function(x, T, n) rowSums(matrix(sample(x, n*T, replace=TRUE), n, T))
m <- moms(ret)
cat(sprintf("Daily invariant: mean %.3f sd %.3f skew %.2f excess-kurtosis %.1f\n\n", m$mean, m$sd, m$skew, m$exkurt))
set.seed(1); par(mfrow=c(1,3))
for (T in c(1, 21, 252)) {
f <- project_fft(ret, T); s <- sim_h(ret, T, 100000)
muT <- T*m$mean; sdT <- sqrt(T)*m$sd
hist(s, breaks=100, freq=FALSE, col=rgb(.87,.42,.13,.35), border=NA, xlim=c(muT-4*sdT, muT+4*sdT),
main=sprintf("horizon = %d day(s)", T), xlab="cumulative return (%)")
lines(f$grid, f$dens, col=BLUE, lwd=2)
curve(dnorm(x, muT, sdT), add=TRUE, col=GREY, lwd=2, lty=2)
}
par(mfrow=c(1,1))
cat("FFT convolution (blue) matches simulation (bars); the normal (dashed) fits only at long horizons.\n")
Daily invariant: mean 0.051 sd 1.078 skew -0.72 excess-kurtosis 11.5
FFT convolution (blue) matches simulation (bars); the normal (dashed) fits only at long horizons.
3. The central-limit drift and square-root-of-time VaR¶
Skewness decays like $1/\sqrt T$ and excess kurtosis like $1/T$: the distribution becomes normal, slowly. The square-root-of-time rule assumes normality immediately, so it understates the fat left tail — and hence the VaR — at short-to-medium horizons.
cf_z <- function(a, sk, ek){ z<-qnorm(a); z + (z^2-1)*sk/6 + (z^3-3*z)*ek/24 - (2*z^3-5*z)*sk^2/36 }
var_sqrt <- function(T, a=0.01) T*m$mean + qnorm(a)*sqrt(T)*m$sd
var_fft <- function(T, a=0.01){ f<-project_fft(ret,T); cdf<-cumsum(f$dens)*(f$grid[2]-f$grid[1])
suppressWarnings(approx(cdf, f$grid, a)$y) }
Ts <- c(1,2,5,10,21,42,63,126,252)
vs <- sapply(Ts, var_sqrt); vf <- sapply(Ts, var_fft)
par(mfrow=c(1,2))
plot(Ts, m$skew/sqrt(Ts), type="b", pch=19, col=BLUE, log="x", xlab="horizon T (days)", ylab="moment",
main="Non-normality decays (CLT drift)", ylim=range(c(m$skew/sqrt(Ts), m$exkurt/Ts)))
lines(Ts, m$exkurt/Ts, type="b", pch=15, col=RED); abline(h=0, col=GREY)
legend("topright", c("skewness ~1/sqrt(T)","excess kurtosis ~1/T"), col=c(BLUE,RED), pch=c(19,15), bty="n")
plot(Ts, vs, type="b", pch=15, col=GREY, log="x", xlab="horizon T (days)", ylab="1% VaR (cumulative %)",
main="Square-root rule too optimistic", ylim=range(c(vs,vf)))
lines(Ts, vf, type="b", pch=19, col=BLUE)
legend("bottomleft", c("square-root-of-time","exact projection"), col=c(GREY,BLUE), pch=c(15,19), bty="n")
par(mfrow=c(1,1))
for (T in c(5,21,63)) cat(sprintf(" T=%3dd: sqrt-rule VaR %.2f%% exact %.2f%% -> understated by %.2f%%\n",
T, var_sqrt(T), var_fft(T), var_fft(T)-var_sqrt(T)))
T= 5d: sqrt-rule VaR -5.35% exact -6.23% -> understated by -0.88% T= 21d: sqrt-rule VaR -10.42% exact -11.46% -> understated by -1.04% T= 63d: sqrt-rule VaR -16.69% exact -17.57% -> understated by -0.88%
4. Summary¶
- The invariance quest, in base R: levels are persistent (autocorrelation $\approx1$), increments are invariant (autocorrelation $\approx0$) — across equity, fixed income, and volatility.
- Returns are only approximately i.i.d. (mild volatility clustering, non-trivial Ljung-Box) — the honest caveat behind GARCH/SV.
- Horizon projection by FFT convolution matches simulation; non-normality decays (skew $\sim1/\sqrt T$, kurtosis $\sim1/T$), so the square-root-of-time VaR understates risk at short-to-medium horizons.
Identical conclusions to the Python and PyMC engines — the foundational two-step recipe (find the invariant, project to the horizon) that underpins the entire Risk and Asset Allocation arc.