Skip to contents

The shared problem

Plain radf() assumes constant innovation variance. Real series rarely have that, and under time-varying volatility its standard critical values no longer control size. exuber has several fixes for this, each taking a structurally different approach. Four of the five below (radf_sign(), radf_sign_dm(), radf_kp(), radf_sbz()) keep the radf_obj class and now have full summary()/datestamp()/tidy()/autoplot() support, the same as plain radf() (see vignette("naming-and-analysis") for exactly how each one plugs into the pipeline and how that was validated); radf_sbz_union() doesn’t – it bundles radf_sbz()’s statistic and the classic supDF into one union-of-rejections call with its own class, not a radf_obj, so none of those four generics apply to it, only its own print()/autoplot(). radf_tt()’s time-deformation approach has its own dedicated vignette, vignette("radf-tt"); this one covers the rest.

All of them target non-stationary volatility – a permanent shift or trend in the unconditional innovation variance, not stationary GARCH-type conditional heteroskedasticity (whose variance profile is asymptotically flat, leaving plain radf() size-correct). So the running example is sim_psy1()’s bubble driven by sim_vol_break() innovations, whose standard deviation triples half-way through the sample – the case where plain radf() over-rejects 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) Transform to the cumulated sign of first differences – exactly invariant to any heteroskedasticity pattern, no bootstrap needed. _dm demeans first for level-shift robustness (Harvey, Leybourne, Tatlow & Zu 2025).
radf_kp() Harvey, Leybourne, Taylor & Zu (2024) Purge volatility: divide each first difference by a kernel spot-volatility estimate, cumulate, then run ordinary PSY on the purged series – null distribution is identical to the standard homoskedastic one.
radf_sbz() / radf_sbz_cv() Harvey, Leybourne & Zu (2019) A WLS-weighted recursive-DF statistic (supBZ), weighting rather than purging or transforming: same kernel spot-volatility estimator as radf_kp(), used as regression weights instead.
radf_sbz_union() Harvey, Leybourne & Zu (2019) supDF (classic) and supBZ (radf_sbz()’s), unioned via a jointly-sized wild bootstrap – catches whichever of the two has power on a given series.

Sign-based: radf_sign()

As of 2026-08-18, this also gets full pipeline support – radf_sign_cv() computes the time-varying badf_cv/bsadf_cv boundary now, not just the scalar critical values 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   FALSE

Kernel-purged: radf_kp()

Because radf_kp() purges volatility and then calls radf() unmodified, it gets full pipeline support too, the simplest way of all (no new critical-value machinery at all – radf_mc_cv() applies unmodified):

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.80

WLS + kernel volatility: radf_sbz()

As of 2026-08-22, radf_sbz() is split from the union test into its own statistic function, with radf_sbz_cv() computing the time-varying badf_cv/bsadf_cv boundary the same way radf_tt_cv()/radf_sign_cv() do (see vignette("naming-and-analysis") for the validation), so it gets 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.21

The kernel-volatility weighting that makes supBZ heteroskedasticity-robust also trades away some power relative to the other tests: sim_psy1()’s default, mild bubble (30 periods at rho = 1 + 200^-0.6, then a collapse) doesn’t clear supBZ’s 95% critical value here, even though every other test above rejects on the same series. A stronger, uncollapsed episode – rho = 1.03 from t = 120 to the sample end, on the same volatility break – does:

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    TRUE

That’s the same trade-off radf_sbz_union() below exists to hedge against by unioning 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.005

supDF is the classic PWY statistic, supBZ the WLS-weighted version (the same one radf_sbz() now returns on its own), and U their union – each with its own bootstrap p-value, so a series can be flagged by one without the other, as here: supDF rejects, supBZ doesn’t, and the union U follows supDF. U’s value is defined using a bootstrap-derived supDF/supBZ scaling ratio, and its size guarantee requires supDF/supBZ bootstrap draws paired from the same resampled series per replicate – both reasons radf_sbz_union() can’t be reconstructed from separately calling radf_sbz_cv() and plain radf_wb_cv(), and why it stays a single bundled call with its own class rather than a radf_obj.

Which to reach for

  • Want exact invariance to any heteroskedasticity pattern with no bootstrap at all: radf_sign() (or radf_sign_dm() if a level shift, not just volatility, is a concern). Also full pipeline support.
  • Want to stay closest to plain radf(), no new critical-value machinery at all: radf_kp().
  • Want the WLS efficiency gain with full datestamp()/autoplot() support: radf_sbz() + radf_sbz_cv().
  • Want to hedge between the classic and WLS-weighted statistics on the same series, and don’t need datestamp()/autoplot(): radf_sbz_union() – the one function in this group with no pipeline support, since U bundles both statistics and their (scalar-only) joint critical value in one call rather than returning a radf_obj.
  • Volatility is the whole story and a bootstrap-free, time-deformation approach is preferred: radf_tt(), see vignette("radf-tt").
  • Volatility is genuinely unknown/complex and a bootstrap is acceptable: plain radf_wb_cv() remains the general-purpose choice.