
Volatility-Robust Alternatives to radf()
Source:vignettes/volatility-robust-radf.Rmd
volatility-robust-radf.RmdThe shared problem
Plain radf() assumes a constant innovation variance.
Real series rarely have one, and when volatility varies over time the
standard critical values no longer control the size of the test. exuber
offers several fixes, and each takes a structurally different approach.
Four of the five covered below (radf_sign(),
radf_sign_dm(), radf_kp() and
radf_sbz()) keep the radf_obj class and
support summary(), datestamp(),
tidy() and autoplot() in the same way as plain
radf(). vignette("naming-and-analysis") shows
how each plugs into the pipeline and how we validated it.
radf_sbz_union() is the exception. It bundles the statistic
of radf_sbz() and the classic supDF into one
union-of-rejections call and has its own class, not
radf_obj, so only its own print() and
autoplot() apply. The time-deformation approach of
radf_tt() has its own vignette,
vignette("radf-tt"), and this one covers the rest.
All of these functions target non-stationary volatility, meaning a
permanent shift or trend in the unconditional innovation variance. They
do not target stationary GARCH-type conditional heteroskedasticity,
whose variance profile is asymptotically flat and leaves plain
radf() with the correct size. The running example is
therefore the sim_psy1() bubble driven by
sim_vol_break() innovations, whose standard deviation
triples half-way through the sample. This is the case in which plain
radf() over-rejects the most:
y <- sim_psy1(n = 200, seed = 1, e = sim_vol_break(199))| Function | Paper | Approach |
|---|---|---|
radf_sign() / radf_sign_dm()
|
Harvey, Leybourne & Zu (2020) | Transforms the series to the cumulated sign of its first
differences, which is exactly invariant to any heteroskedasticity
pattern and needs no bootstrap. The _dm version demeans
first, which makes it robust to level shifts (Harvey, Leybourne, Tatlow
& Zu 2025). |
radf_kp() |
Harvey, Leybourne, Taylor & Zu (2024) | Purges volatility. It divides each first difference by a kernel spot-volatility estimate, cumulates the result and runs the ordinary PSY test on the purged series, whose null distribution is identical to the standard homoskedastic one. |
radf_sbz() / radf_sbz_cv()
|
Harvey, Leybourne & Zu (2019) | A WLS-weighted recursive Dickey-Fuller statistic
(supBZ). It weights the observations and does not purge or
transform the series. It uses the same kernel spot-volatility estimator
as radf_kp(), but as regression weights. |
radf_sbz_union() |
Harvey, Leybourne & Zu (2019) | Combines supDF (the classic statistic) and
supBZ (the statistic of radf_sbz()) as a union
of rejections, using a wild bootstrap sized jointly for both. It catches
whichever of the two has power on a given series. |
Sign-based: radf_sign()
radf_sign() has full pipeline support.
radf_sign_cv() computes the time-varying
badf_cv and bsadf_cv boundary and not only the
scalar critical values that summary() needs (see
vignette("naming-and-analysis") for the validation):
res <- radf_sign(y, minw = 20)
cv <- radf_sign_cv(n = 200, minw = 20)
summary(res, cv = cv)
#>
#> ── Summary (minw = 20, lag = 0) ──────────────── Sign-Based MC (nboot = 2000) ──
#>
#> series1 :
#> # A tibble: 3 × 5
#> stat tstat `90` `95` `99`
#> <fct> <dbl> <dbl> <dbl> <dbl>
#> 1 adf -0.293 0.855 1.32 2.06
#> 2 sadf 4.47 2.34 2.70 3.43
#> 3 gsadf 8.88 3.51 3.91 4.92
datestamp(res, cv = cv)
#>
#> ── Datestamp (min_duration = 0) ─────────────────────────────── Sign-Based MC ──
#>
#> series1 :
#> Start Peak End Duration Signal Ongoing
#> 1 84 84 85 1 positive FALSE
#> 2 87 103 122 35 positive FALSE
#> 3 123 123 124 1 positive FALSEKernel-purged: radf_kp()
radf_kp() purges volatility and then calls
radf() unmodified, so it has full pipeline support in the
simplest possible way. It needs no new critical-value code, and
radf_mc_cv() applies as it is:
res_kp <- radf_kp(y, minw = 20)
cv_kp <- radf_mc_cv(n = attr(res_kp, "n"), minw = 20)
summary(res_kp, cv = cv_kp)
#>
#> ── Summary (minw = 20, lag = 0) ────────────────── Monte Carlo (nboot = 1000) ──
#>
#> series1 :
#> # A tibble: 3 × 5
#> stat tstat `90` `95` `99`
#> <fct> <dbl> <dbl> <dbl> <dbl>
#> 1 adf -1.72 -0.470 -0.140 0.535
#> 2 sadf 1.50 1.07 1.37 1.90
#> 3 gsadf 2.63 1.98 2.25 2.80WLS + kernel volatility: radf_sbz()
radf_sbz() is a statistic function of its own, separate
from the union test. radf_sbz_cv() computes the
time-varying badf_cv and bsadf_cv boundary in
the same way as radf_tt_cv() and
radf_sign_cv() (see
vignette("naming-and-analysis") for the validation), so it
has full pipeline support:
res_sbz <- radf_sbz(y, minw = 20)
cv_sbz <- radf_sbz_cv(y, minw = 20, nboot = 200, seed = 1)
summary(res_sbz, cv = cv_sbz)
#>
#> ── Summary (minw = 20, lag = 0) ────────── Wild Bootstrap (SBZ) (nboot = 200) ──
#>
#> series1 :
#> # A tibble: 3 × 5
#> stat tstat `90` `95` `99`
#> <fct> <dbl> <dbl> <dbl> <dbl>
#> 1 adf -1.39 0.825 1.13 1.61
#> 2 sadf 1.67 1.58 2.11 3.36
#> 3 gsadf 1.91 3.37 5.06 6.21The kernel-volatility weighting that makes supBZ robust
to heteroskedasticity costs some power relative to the other tests. The
default bubble of sim_psy1() is mild (30 periods at
rho = 1 + 200^-0.6, then a collapse). It does not clear the
95% critical value of supBZ here, although every other test
above rejects on the same series. A stronger bubble that does not
collapse (rho = 1.03 from t = 120 to the end
of the sample, on the same volatility break) does clear it:
y_strong <- sim_psy1(n = 200, te = 120, tf = 200, c = 0.03, alpha = 0, seed = 1,
e = sim_vol_break(199))
res_sbz2 <- radf_sbz(y_strong, minw = 20)
cv_sbz2 <- radf_sbz_cv(y_strong, minw = 20, nboot = 200, seed = 1)
summary(res_sbz2, cv = cv_sbz2)
#>
#> ── Summary (minw = 20, lag = 0) ────────── Wild Bootstrap (SBZ) (nboot = 200) ──
#>
#> series1 :
#> # A tibble: 3 × 5
#> stat tstat `90` `95` `99`
#> <fct> <dbl> <dbl> <dbl> <dbl>
#> 1 adf 4.83 0.948 1.65 2.65
#> 2 sadf 4.83 2.24 2.49 3.26
#> 3 gsadf 5.29 2.77 3.00 3.58
datestamp(res_sbz2, cv = cv_sbz2)
#>
#> ── Datestamp (min_duration = 0) ──────────────────────── Wild Bootstrap (SBZ) ──
#>
#> series1 :
#> Start Peak End Duration Signal Ongoing
#> 1 129 129 130 1 positive FALSE
#> 2 132 132 133 1 positive FALSE
#> 3 134 134 135 1 positive FALSE
#> 4 172 200 200 29 positive TRUEThis is the same trade-off that radf_sbz_union(), below,
hedges against by combining supBZ with the classic
supDF.
Union-of-rejections: radf_sbz_union()
radf_sbz_union(y, nboot = 200, seed = 1)
#>
#> ── radf_sbz_union (minw = 27, nboot = 200) ─────────────────────────────────────
#>
#> series supDF supBZ U p_supDF p_supBZ p_U
#> series1 9.329 1.67 9.329 0 0.085 0.005supDF is the classic PWY statistic, and
supBZ is the WLS-weighted version that
radf_sbz() also returns on its own. U is their
union. Each statistic has its own bootstrap p-value, so one can flag a
series without the other. Here supDF rejects,
supBZ does not, and the union U follows
supDF. The value of U is defined with a
bootstrap-derived scaling ratio between supDF and
supBZ, and its size guarantee requires that the
supDF and supBZ bootstrap draws come from the
same resampled series in each replicate. For both reasons
radf_sbz_union() cannot be reconstructed by calling
radf_sbz_cv() and plain radf_wb_cv()
separately. It stays a single bundled call with its own class and is not
a radf_obj.
Which to reach for
- If you want exact invariance to any heteroskedasticity pattern with
no bootstrap, use
radf_sign(). Useradf_sign_dm()if a level shift, and not only volatility, is a concern. Both have full pipeline support. - If you want to stay closest to plain
radf()with no new critical-value code, useradf_kp(). - If you want the efficiency gain from WLS together with full
datestamp()andautoplot()support, useradf_sbz()withradf_sbz_cv(). - If you want to hedge between the classic and the WLS-weighted
statistics on the same series and do not need
datestamp()orautoplot(), useradf_sbz_union(). It is the only function in this group without pipeline support, becauseUbundles both statistics and their scalar joint critical value in one call and does not return aradf_obj. - If volatility is the main concern and you prefer a time-deformation
approach without a bootstrap, use
radf_tt()(seevignette("radf-tt")). - If the volatility is unknown or complex and a bootstrap is
acceptable,
radf_wb_cv()remains the general-purpose choice.