Skip to content
exuber

Replication

Volatility-robust tests

Tests robust to time-varying innovation variance: time-transformed, kernel-purged, WLS, sign-based, and stochastic-coefficient routes.

This page is the technical record behind the methods. To learn how to run them, start with Volatility-robustness tests in the guide.

The GSADF test of Phillips, Wu & Yu (2011) and Phillips, Shi & Yu (2015) assumes that the innovation variance is constant. When the variance moves over time, whether deterministically or stochastically, the test over-rejects. Every method in this file is a different remedy for that problem. The status column uses these labels. done means the method is implemented and checked against a published number. evaluated means we have read the source and not implemented the method.

MethodPaperStatus
Time-transformed test (STADF/GSTADF)Kurozumi, Skrobotov & Tsarev (2024)done
Kernel-purge testHarvey, Leybourne, Taylor & Zu (2024/2025)done (with-intercept variant)
SBZ (WLS + kernel volatility)Harvey, Leybourne & Zu (2019)done
Sieve bootstrap (autocorrelated innovations)Pedersen & Montes Schütte (2020)done
Skewness-corrected wild bootstrapHafner (2020)done
Sign-based sGSADFHarvey, Leybourne & Zu (2020); level shifts: Harvey, Leybourne, Tatlow & Zu (2025)done
Stochastic explosive-coefficient testKurozumi & Nishi (2025)done (ssu_test(), cusum_test())
SV-ADFSarkar & Wells (2026, preprint)done (datestamp(option = "svadf"); preprint, not peer-reviewed)

All papers are listed in references.md.


Time-transformed test (STADF / GSTADF)

Status: done. The test is in exuber/R/radf_tt.R (radf_tt(), radf_tt_cv()) and is checked in exuber/tests/testthat/test-tt.R.

Source

Kurozumi, E., Skrobotov, A. & Tsarev, A. (2024). Time-Transformed Test for Bubbles under Non-stationary Volatility. Journal of Financial Econometrics, doi:10.1093/jjfinec/nbae026. We worked from the arXiv version (2012.13937v2, 15 Nov 2021), and equation and theorem numbers below refer to it.

Idea

If the variance path were known, we could stretch calendar time so that the rescaled series has constant variance, and then run an ordinary GLS-demeaned SADF or GSADF test on it. Theorem 1 shows that the null limit of the statistic computed on the time-transformed series coincides with the homoskedastic GLS-demeaned SADF/GSADF distribution of Whitehouse (2019), whatever the volatility path. Theorem 2 shows that this still holds when the variance profile is estimated from the data. The null distribution is therefore pivotal, and radf_tt_cv() simulates critical values once from a plain random walk, with no bootstrap and no dependence on the dataset.

The paper reports one anchor value. Footnote 4 gives, for r0=0.1r_0 = 0.1, critical values 2.3192.319, 2.6262.626 and 3.2233.223 at the 10%, 5% and 1% levels. These belong to STADF, the single-supremum statistic sup⁡r2ADF0r2\sup_{r_2} \mathrm{ADF}^{r_2}_0, and not to the double-supremum GSTADF. Whitehouse (2019) studies the GLS-demeaned version of the PWY (2011) test, which is the single-supremum one. GSTADF critical values are obtained with radf_tt_cv(), because the paper gives no published value for them.

Formulas

Let yˇt=yt−y0\check y_t = y_t - y_0 be the GLS-demeaned series. We subtract the first observation and fit no intercept. This differs from radf(), which always fits an intercept.

The recursive statistic of eq. (9) is the tt-statistic on β\beta in the no-intercept regression Δyˇt=β yˇt−1+et\Delta \check y_t = \beta\, \check y_{t-1} + e_t over the window [r1,r2][r_1, r_2]:

ADFr1r2=∑yˇt−1 Δyˇtσ^2(r1,r2)∑yˇt−12.\mathrm{ADF}^{r_2}_{r_1} = \frac{\sum \check y_{t-1}\,\Delta \check y_t}{\sqrt{\hat\sigma^2(r_1, r_2) \sum \check y_{t-1}^2}} .

gls_dfstat_grid() computes it over the whole (r1,r2)(r_1, r_2) grid from cumulative sums, so no per-window regression is run.

The variance profile is estimated in three steps.

  • Eq. (18) is a local kernel (Nadaraya–Watson type) estimate δ^t\hat\delta_t of the time-varying AR(1) coefficient. The default kernel is uniform, as in the paper’s Monte Carlo.
  • Eq. (19) defines the variance profile η^(s)\hat\eta(s) as a normalized cumulative sum of squares of the truncated local-regression residuals. It is piecewise linear on the observation grid, so we invert it exactly by linear interpolation to get g^(s)\hat g(s), which is used to resample and time-transform the series.
  • Footnote 6 sets the truncation threshold to ψT=cˉ T1/7\psi_T = \bar c\, T^{1/7}, where cˉ\bar c is the largest residual standard deviation over rolling windows of 10% of the sample.

Choices that differ from the paper

  • Bandwidth. The paper selects h∈[T−0.5,T−0.3]h \in [T^{-0.5}, T^{-0.3}] by leave-one-out cross-validation. The default here is the plug-in h=T−2/5h = T^{-2/5}, the midpoint of that range on a log scale, and h is a user argument. Cross-validation would repeat the O(T⋅Th)O(T \cdot Th) kernel fit about ten times. It is the first thing to add if empirical size control turns out to matter.
  • Lags. The paper’s Monte Carlo uses no augmentation lags, and radf_tt() likewise supports only p=0p = 0.

Why the statistic is not computed in exubercore

The recursive statistic is a closed-form ratio of cumulative sums, so gls_dfstat_grid() is fully vectorized in R. The C++ radf() always fits an intercept when lag == 0 (see exubercore/src/radf.cpp), so it could not compute this no-intercept statistic without modification. A port becomes worthwhile only if the O(T2)O(T^2) grid is a bottleneck for TT in the thousands. Runs up to T≈2000T \approx 2000 showed no such problem.

Validation

  1. Formula. gls_dfstat_grid() matches a brute-force per-window lm(dy ~ ylag - 1) fit on a series of length 65 with minw = 15, with a maximum absolute difference of 6.7×10−166.7 \times 10^{-16}.

  2. Critical values. A Monte Carlo with n=300n = 300, minw = 30 and 4000 replications gives the STADF critical values below. The deviation from the published triple has the same direction and size as in the package test, which uses a tolerance of 0.15. It is consistent with Monte Carlo noise around a T→∞T \to \infty target. radf_tt_cv(n = 300, minw = 30, nrep = 4000) gives (2.399,2.737,3.346)(2.399, 2.737, 3.346) on another seed.

    10%5%1%
    Published (Whitehouse 2019)2.3192.6263.223
    Simulated2.3552.7153.435
    Absolute difference0.0360.0890.212

    For comparison, the GSTADF (double-supremum) statistic under the same setup has critical values (3.157,3.436,4.302)(3.157, 3.436, 4.302).

  3. Power. On a series that follows a unit root, turns explosive (ρ=1.04\rho = 1.04) and then collapses, radf_tt() gives a GSADF statistic of 5.781, well above every critical value in the table.

Replication script: replication/volatility-robustness/radf_tt_validation.R.

datestamp() and autoplot() support (done)

radf_tt_cv() returns the scalar critical values adf_cv, sadf_cv and gsadf_cv, and also the time-varying boundaries badf_cv and bsadf_cv that datestamp() and autoplot() need. gls_dfstat_grid() already returns the supremum over all window starts at each end point, so the boundaries are the per-time-point quantiles of badf and bsadf across replications. This is the same construction as in radf_mc_cv().

Checks:

  1. The last row of badf_cv is identical to adf_cv, as it must be, since adf is the last point of badf in each replication.
  2. Under a pure random walk (n=100n = 100, minw = 20, 2000 critical-value replications, 300 test replications) the false-alarm rate is 3.3% at a nominal 5%, for both option = "gsadf" and option = "sadf".
  3. On a short, moderate bubble (60 pre-bubble observations, 30 explosive observations at ρ=1.03\rho = 1.03, 10 post-collapse observations, 50 replications), datestamp() on radf_tt() detects the bubble in 18% of runs, against 16% for the radf() and radf_mc_cv() pipeline on the same series.
  4. On the bundled psy2 series, datestamp() finds two episodes (Start 21 / Peak 27 / End 35, and Start 55 / Peak 55 / End 73).

radf_sign_cv() and radf_sign_dm_cv() share the construction and are described in Sign-based sGSADF.

Open items


Kernel-purge test

Status: done (with-intercept variant). The test is in exuber/R/radf_kp.R (radf_kp()) and is checked in exuber/tests/testthat/test-kp.R.

Source

Harvey, D. I., Leybourne, S. J., Taylor, A. M. R. & Zu, Y. (2024). A new heteroskedasticity-robust test for explosive bubbles. Journal of Time Series Analysis, doi:10.1111/jtsa.12784. Open access.

Idea

Instead of resampling (radf_wb_cv()) or deforming time (radf_tt()), the test removes the unconditional heteroskedasticity directly. It estimates the spot volatility σ^t\hat\sigma_t with a Gaussian kernel (eq. 4), divides each first difference by it, and cumulates the result (eq. 5) into a volatility-standardized series xtx_t. The unmodified PSY/GSADF test is then run on xtx_t.

Theorem 1 and Remark 3.2 show that the null limit of the purged statistic equals the standard homoskedastic GSADF limit. The Monte Carlo critical values of radf_mc_cv() therefore apply directly, and tidy(), autoplot() and datestamp() work on the result. radf_kp() calls kernel_spot_vol(), which is shared with SBZ, and then the unmodified radf() on the purged series.

The paper also proposes a without-intercept variant PSYσ∗\mathrm{PSY}^*_\sigma and a union-of-rejections test UPSYσ\mathrm{UPSY}_\sigma that combines the two. Neither is implemented. The without-intercept variant needs a no-intercept regression on non-demeaned data, which differs slightly from the GLS-demeaned gls_dfstat_grid(). The union scaling has the same form as the union in radf_sbz_cv().

Published critical values

Table I gives critical values for a minimum window of 0.1, a Gaussian kernel, h=0.1 T−0.25h = 0.1\,T^{-0.25} and 2000 replications.

TTPSYσ\mathrm{PSY}_\sigma (10% / 5% / 1%)PSYσ∗\mathrm{PSY}^*_\sigma (10% / 5% / 1%)UPSYσ\mathrm{UPSY}_\sigma (10% / 5% / 1%)
1001.629 / 1.828 / 2.3923.637 / 4.158 / 5.5533.950 / 4.527 / 6.129
2001.608 / 1.789 / 2.1403.226 / 3.595 / 4.3303.468 / 3.804 / 4.589
4001.712 / 1.935 / 2.2963.167 / 3.446 / 4.0073.361 / 3.598 / 4.145
∞\infty1.875 / 2.094 / 2.4862.978 / 3.296 / 3.8593.186 / 3.486 / 3.951

The implemented statistic is the PSYσ\mathrm{PSY}_\sigma column. By Remark 3.2 its T=∞T = \infty row should equal the asymptotic GSADF critical values of PSY (2015) at the same minimum window.

Validation

test-kp.R simulates the null GSADF distribution of radf_kp() at n=300n = 300 and compares it with radf_mc_cv(300) and with the published T=400T = 400 row. One run gave (1.685,1.897,2.337)(1.685, 1.897, 2.337) for radf_kp, against (1.873,2.121,2.429)(1.873, 2.121, 2.429) for radf_mc_cv(300) and (1.712,1.935,2.296)(1.712, 1.935, 2.296) in the table. Dividing by an estimated volatility path, and not the true one, plausibly shrinks the effective variance a little in finite samples.

A run at n=400n = 400 (the table row, so no interpolation), 800 replications, gives:

10%5%1%
Published (Table I, T=400T = 400)1.7121.9352.296
Simulated (n=400n = 400, 800 replications)1.7521.9442.324
Absolute difference0.0400.0090.028

The core regression is the one radf() already computes, applied to transformed data, so the check concentrates on the transform kernel_purge().

Replication script: replication/volatility-robustness/radf_kp_validation.R.


SBZ (WLS + kernel volatility)

Status: done. The test is in exuber/R/radf_sbz.R (radf_sbz_cv(), radf_sbz_union()) and is checked in exuber/tests/testthat/test-sbz.R.

Source

Harvey, D. I., Leybourne, S. J. & Zu, Y. (2019). Testing explosive bubbles with time-varying volatility. Econometric Reviews, 38(10), 1131–1151. We used the open working paper, Granger Centre Discussion Paper 18/05, University of Nottingham.

Idea

SBZ is a weighted-least-squares variant of the PWY/PSY sup-ADF test. The weights come from a nonparametric kernel estimate of the time-varying volatility (eq. 6: Gaussian kernel, leave-one-out cross-validated bandwidth with h∈[1/(2T),1/6]h \in [1/(2T), 1/6], footnote 2). The null distribution still depends on the volatility path, so size is controlled with a wild bootstrap, run jointly for sup⁡DF\sup \mathrm{DF} (the classic PWY/PSY test) and sup⁡BZ\sup \mathrm{BZ} (the WLS test).

The paper’s second contribution is a union of rejections that combines the two tests. We reject if

U=max⁡ ⁣(sup⁡DF, qDFqBZsup⁡BZ)>qDF,U = \max\!\left(\sup \mathrm{DF},\ \frac{q_{\mathrm{DF}}}{q_{\mathrm{BZ}}} \sup \mathrm{BZ}\right) > q_{\mathrm{DF}},

where qDFq_{\mathrm{DF}} and qBZq_{\mathrm{BZ}} are bootstrap critical values at the chosen level. The scaling constant makes the union asymptotically correctly sized (Theorem 3).

Published numbers

Table 1 reports bootstrap pp-values (M=499M = 499) for FTSE (December 1985 to December 1999) and S&P 500 (January 1980 to March 2000).

Seriessup⁡DF\sup\mathrm{DF}sup⁡BZ\sup\mathrm{BZ}UU
FTSE Daily0.2880.0160.046
FTSE Weekly0.2750.1460.201
FTSE Monthly0.4770.2790.315
SP500 Daily0.2670.0000.003
SP500 Weekly0.1700.0020.011
SP500 Monthly0.1530.0440.071

These agree with the paper’s text: sup⁡DF\sup\mathrm{DF} rejects for no series, sup⁡BZ\sup\mathrm{BZ} rejects at 5% for daily FTSE and all S&P 500 frequencies, and UU keeps those rejections at a slightly weaker level for monthly S&P 500.

The paper publishes no table of fixed critical values, because every pp-value comes from a wild bootstrap on the authors’ own series. Table 1 therefore cannot be reproduced bit for bit without their data and random draws. The check we can run is the empirical size under the null.

Implementation

Three pieces are needed.

  1. A Gaussian-kernel volatility estimator with leave-one-out bandwidth selection.
  2. A WLS recursive Dickey–Fuller statistic with an intercept and heteroskedasticity weights, computed per window. It differs from radf() (OLS) and from radf_tt() (no intercept).
  3. The wild bootstrap of radf_wb.R, extended to run sup⁡DF\sup\mathrm{DF} and sup⁡BZ\sup\mathrm{BZ} on the same bootstrap draws, as the union requires, and to compute the scaling constant.

Validation

Size under the null: random walks with n=150n = 150, 150 replications, 199 bootstrap draws, nominal 5%.

StatisticRejection rate
sup⁡DF\sup\mathrm{DF}0.033
sup⁡BZ\sup\mathrm{BZ}0.060
UU0.053

All three rates are within Monte Carlo noise of the nominal level.

Replication script: replication/volatility-robustness/radf_sbz_validation.R.


Pedersen & Schütte sieve bootstrap

Status: done. The sieve bootstrap of exuber (radf_sb_cv()) covers the method, and the lag selection of Pedersen & Schütte is available through its type argument.

Source

Pedersen, T. Q. & Montes Schütte, E. C. (2020). Testing for explosive bubbles in the presence of autocorrelated innovations. Journal of Empirical Finance, 58, 207–225. We used the open working paper, CREATES Research Paper 2017-9.

What is implemented

exuber’s sieve bootstrap (R/radf_sb.R) follows the construction that Pedersen & Schütte propose for autocorrelated innovations. It fits an AR(pp) sieve to the first differences, resamples the residuals and rebuilds bootstrap paths with stats::filter(..., "rec").

Their contribution is to show that a fixed lag order distorts size under autocorrelated innovations, and that BIC lag selection with a maximum lag kmax repairs it (Section 4, for example Table 4.8). radf_sb_cv(type = "aic" / "bic", max_lag = ...) selects the lag for each series by AIC or BIC with the lag_select() routine shared with radf_wb_ps_cv(), and uses the maximum across the panel. The lag is common to the whole panel, in line with the single lag argument of radf(). type = "fixed" keeps the original behaviour.

Validation

type = "bic" picks a nonzero lag on AR(2)-autocorrelated data. On pure random-walk data the modal selection is 0 across 8 independent draws. A single draw is not asserted on, because BIC can pick a nonzero lag by chance in any one finite sample.

Replication script: replication/volatility-robustness/radf_sb_cv_aic_bic_validation.R.


Hafner skewness-corrected wild bootstrap

Status: done, as radf_wb_cv(..., dist_skew = TRUE) and radf_wb_distr(..., dist_skew = TRUE).

Source

Hafner, C. M. (2020). Testing for bubbles in cryptocurrencies with time-varying volatility. Journal of Financial Econometrics, 18(2), 233–249. We used the IRTG 1792 Discussion Paper 2018-005 (Humboldt University Berlin).

Idea

Hafner changes the multiplier distribution of the wild bootstrap of Harvey et al. (2016), so that the bootstrap approximates the PWY null distribution better when returns have time-varying volatility and are right-skewed, as cryptocurrency returns are. The multiplier is

wt=ut2+vt2−12,ut,vt∼iidN(0,1) independent,w_t = \frac{u_t}{\sqrt 2} + \frac{v_t^2 - 1}{2}, \qquad u_t, v_t \overset{\text{iid}}{\sim} N(0, 1) \text{ independent},

which has E[wt]=0E[w_t] = 0, E[wt2]=1E[w_t^2] = 1 and E[wt3]=1E[w_t^3] = 1. It is a fixed right-skewed multiplier, not matched to the skewness of each series, and it replaces the N(0,1)N(0,1) or Rademacher multiplier in the otherwise unchanged wild bootstrap yt∗=wte^ty^*_t = w_t \hat e_t.

Implementation

A dist_skew argument passes through the wild-bootstrap code in exuber/R/radf_wb.R. radf_wb_dgp_hlst(y, dist_rad, dist_skew = FALSE) has a third multiplier branch, next to the default N(0,1)N(0,1) branch and the Rademacher branch. radf_wb_hlst(), radf_wb_cv() and radf_wb_distr() pass the argument along (default FALSE) and reject dist_rad = TRUE together with dist_skew = TRUE.

Validation

  1. Moments. A simulation of wtw_t with more than 500,000 draws gives E[w]≈0E[w] \approx 0, E[w2]≈1E[w^2] \approx 1 and E[w3]≈1E[w^3] \approx 1.
  2. Default path. With dist_skew = FALSE the bootstrap DGP is identical, draw for draw, to the one without the option.
  3. Power. Under a mildly explosive alternative with ordinary innovations, dist_skew = TRUE rejects 86.7% of the time (30 replications).
  4. Size. With the paper’s own innovation distribution, negative log-χ2(1)\chi^2(1) (footnote 1), the rejection rate at a nominal 5% is 3.3% without added heteroskedasticity (60 replications) and 0% with a deterministic volatility pattern added (80 replications). The test is conservative in small samples, which agrees with the paper’s finding that it is undersized and that the effect grows with global heteroskedasticity.
  5. Heavy tails. Combining log-χ2\chi^2 innovations with a mild explosive alternative (ρ=1.06\rho = 1.06) gives no power in short samples. The distribution of −log⁡Z2-\log Z^2 produces occasional extreme single-step outliers, and in a short series one of them can dominate the sum of squared residuals in both the observed and the bootstrap statistics. The same alternative with normal innovations has strong power (item 3), so this is a property of the noise process and not of dist_skew.

Replication scripts: replication/volatility-robustness/hafner_dist_skew_moments_and_regression.R, hafner_dist_skew_power_and_size.R.


Sign-based sGSADF

Status: done, as radf_sign() / radf_sign_cv() (Harvey, Leybourne & Zu 2020) and radf_sign_dm() / radf_sign_dm_cv() (the recursively demeaned variant of Harvey, Leybourne, Tatlow & Zu 2025).

Source

Harvey, D. I., Leybourne, S. J. & Zu, Y. (2020). Sign-based unit root tests for explosive financial bubbles in the presence of deterministically time-varying volatility. Econometric Theory, 36(1), 122–169, doi:10.1017/S0266466619000057.

Harvey, D. I., Leybourne, S. J., Tatlow, D. & Zu, Y. (2025). Unit root tests for explosive financial bubbles in the presence of deterministic level shifts. Oxford Bulletin of Economics and Statistics, 87(5), 879–901, doi:10.1111/obes.12668.

Idea

The sign of Δyt\Delta y_t depends only on which side of zero the innovation falls, so it does not depend on the innovation volatility. A test built on cumulated signs is therefore exactly invariant to any volatility pattern and needs no bootstrap. Let

Ct=∑i≤tsign⁡(Δyi).C_t = \sum_{i \le t} \operatorname{sign}(\Delta y_i).

The statistic sPSYs\mathrm{PSY}, and its single-supremum case sPWYs\mathrm{PWY} (eq. 4), is the double-supremum recursive Dickey–Fuller construction of PSY applied to CtC_t instead of yty_t, fitted without an intercept: Ct=ρ(r1,r2) Ct−1+etC_t = \rho(r_1, r_2)\, C_{t-1} + e_t. Theorem 2 shows that its null limit does not depend on the volatility process σ(s)\sigma(s). Remark 1 adds that this follows from excluding the intercept and using only signs. A no-intercept version of PSY on the raw series would be invariant only asymptotically, whereas sPSYs\mathrm{PSY} is invariant in finite samples.

Implementation

The code is in exuber/R/radf_sign.R, modelled on radf_tt.R.

  • sign_transform(y) returns c(0, cumsum(sign(diff(y)))).
  • radf_sign(data, minw) transforms each series and calls gls_dfstat_grid(), the no-intercept recursive Dickey–Fuller routine of STADF. It returns a radf_sign_obj that inherits from radf_obj, so the existing S3 methods apply.
  • radf_sign_cv(n, minw, nrep, seed) simulates critical values from a random walk. The null distribution is pivotal (Theorem 2), so this is done once and not per dataset.

The unroot() and rls_gsadf() route of radf() cannot be reused. Despite the column names of unroot(y, lag = 0), it fits a regression with an intercept, which is the wrong form here.

Not implemented: the paper’s union of rejections with the standard PSY/PWY test through a wild bootstrap (Section 4), which has the same structure as the union in SBZ, and the sign-based dating extension of Section 6.

Validation

  1. Invariance. A series and the same series multiplied by a strongly time-varying volatility pattern (0.10.1, 1010 and 11 over three segments) give identical sadf and gsadf statistics, bit for bit.
  2. Published critical values. At T=200T = 200 and minw/T=0.1\mathrm{minw}/T = 0.1 the simulated critical values for sPSYs\mathrm{PSY} (gsadf_cv) are (3.482,3.900,4.925)(3.482, 3.900, 4.925), against (3.469,3.901,4.957)(3.469, 3.901, 4.957) in Table 1 for 10%, 5% and 1%. For sPWYs\mathrm{PWY} (sadf_cv) they are (2.337,2.665,3.403)(2.337, 2.665, 3.403), against (2.405,2.735,3.434)(2.405, 2.735, 3.434). At T=100T = 100 the match is looser, and the published 1% value for sPSYs\mathrm{PSY} (13.056) reflects the extreme-value behaviour that the paper notes at small samples and high quantiles.
  3. Finite-TT comparison. The comparison has to use the matching finite TT. The finite-sample critical values of sPSYs\mathrm{PSY} converge to their asymptotic limit slowly (the paper’s text says convergence is “fairly slow (particularly for sPSYs\mathrm{PSY})”), and a T=300T = 300 simulation lies between the paper’s T=200T = 200 and T=400T = 400 rows.
  4. Power. The rejection rate is 96.7% (30 replications) under a mildly explosive alternative, with the simulated 95% critical value of radf_sign_cv().

Replication scripts: replication/volatility-robustness/sign_based_invariance_and_power.R, sign_based_finite_T_crosscheck.R.

Level-shift robustness (HLTZ 2025)

Status: done. radf_sign() and radf_sign_cv() carry a documentation section on level shifts, and radf_sign_dm() and radf_sign_dm_cv() implement the demeaned variant.

The 2025 paper evaluates the same sign-based statistics under a different departure from the null, deterministic level shifts in the series, with nT=O(Tαn)n_T = O(T^{\alpha_n}) shifts. Theorem 1 gives the null limit of the standard PSY statistic. Theorem 2 covers the cumulated-sign sPWYs\mathrm{PWY}/sPSYs\mathrm{PSY}, which is radf_sign(). Theorem 3 covers the recursively demeaned analogue sˉPWY\bar s\mathrm{PWY}/sˉPSY\bar s\mathrm{PSY}.

PSY needs a joint restriction on the number and the size of the shifts (Assumption 3). In the paper’s Table 1, PSY is essentially never correctly sized once the number of shifts grows at rate T\sqrt T (Case 1), with empirical size up to 0.425 at a nominal 0.05. Both sign-based statistics need only a restriction on the number of shifts (Assumption 4, αn<1/2\alpha_n < 1/2) and none on their magnitude. At the boundary rate αn=1/2\alpha_n = 1/2 they pick up a term that depends on the shifts, but the resulting over-sizing is bounded independently of the shift magnitude (Remark 4).

The demeaned series of Theorem 3 uses an expanding-window mean:

C~t=∑i=2t{sign⁡(Δyi)−1i−1∑j=2isign⁡(Δyj)}.\tilde C_t = \sum_{i=2}^{t} \left\{ \operatorname{sign}(\Delta y_i) - \frac{1}{i-1} \sum_{j=2}^{i} \operatorname{sign}(\Delta y_j) \right\}.

The inner sum is the running sum CiC_i that radf_sign() already computes, so the demeaning is a one-time O(T)O(T) transform, sign_demean_transform(), which feeds the same gls_dfstat_grid().

Not implemented: the serial-correlation correction of Remark 6, which augments regression (4) with lagged ΔCt\Delta C_t. The no-shift implementation does not handle serial correlation either.

radf_sign_cv(), radf_sign_dm_cv() and radf_tt_cv() carry the class tag "mc_cv", so their print() and summary() methods fall through to the existing tidy_radf_cv.mc_cv.

Validation

  1. Formula. sign_demean_transform() matches a brute-force loop that recomputes the recursive mean for each ii, to about 1.8×10−151.8 \times 10^{-15}.
  2. Table 1 of the paper. For Case 1 (αn=0.5\alpha_n = 0.5) with k=2k = 2, μ=5\mu = 5, p=0.8p = 0.8 and T=400T = 400, the published empirical size at a nominal 5% is 0.337 for PSY, 0.121 for sPSYs\mathrm{PSY} and 0.050 for sˉPSY\bar s\mathrm{PSY}. Our replication with 500 replications, critical values simulated under the no-shift null, the paper’s trimming minw=⌊0.1T⌋\mathrm{minw} = \lfloor 0.1T \rfloor and the same construction of shift count, size and location gives 0.302, 0.090 and 0.036. The ordering and magnitudes agree. PSY is badly oversized, sPSYs\mathrm{PSY} mildly so, and sˉPSY\bar s\mathrm{PSY} is closest to nominal and slightly conservative. The differences are within Monte Carlo noise at 500 replications against the paper’s 2000.
  3. Invariance. radf_sign_dm() keeps the exact invariance to volatility rescaling.
  4. Power. radf_sign_dm() rejects a mildly explosive alternative with its own simulated critical value.

Replication script: replication/volatility-robustness/radf_sign_dm_levelshift_validation.R.

datestamp() and autoplot() support (done)

radf_sign_cv() and radf_sign_dm_cv() return the time-varying boundaries badf_cv and bsadf_cv, built in the same way as for STADF/GSTADF: gls_dfstat_grid() on the transformed series already returns the supremum over window starts at each end point.

Checks:

  1. The last row of badf_cv equals adf_cv for both functions.
  2. Under a pure random walk (n=100n = 100, minw = 20, 2000 critical-value replications, 200 test replications) the false-alarm rate is 5.5% for radf_sign and 3.5% for radf_sign_dm, at a nominal 5%.
  3. On the synthetic bubble used for STADF/GSTADF (the radf() baseline detects 16%), radf_sign detects 20% and radf_sign_dm detects 8%. The lower rate for the demeaned variant reflects the trade-off between invariance and power in this family.
  4. datestamp() (option = "gsadf" and "sadf") and autoplot() run on radf_sign(sim_data) and radf_sign_dm(sim_data).

Stochastic explosive-coefficient test

Status: done. SSU is ssu_test(). GSSU, the UR/GUR union and the four CUSUM-type statistics are ssu_test(type = "gssu", union = TRUE) and cusum_test().

Source

Kurozumi, E. & Nishi, M. (2025). Testing for a bubble with a stochastically varying explosive coefficient. Journal of Time Series Analysis, 46(5), 945–965. Open access.

Idea

The paper treats the explosive AR(1) coefficient itself as random:

ρt=1+c1T+a utT,(2)\rho_t = 1 + \frac{c_1}{T} + \frac{a\, u_t}{\sqrt T}, \qquad (2)

where utu_t is i.i.d. with mean zero and unit variance, independent of the innovations. Every other method in this project assumes the deterministic form 1+c/Tα1 + c/T^\alpha. The motivation (Figure 1, rolling AR(1) estimates on Japanese daily stock prices) is that the speed of explosion looks unstable within a bubble episode. The innovation variance is constant (Assumption 1a), so this is not a volatility fix. It sits in this file because it appeared in the same JTSA special issue as the other heteroskedasticity papers.

The paper proposes three kinds of statistic.

  1. SSU/GSSU (eq. 7–8) is a stochastic-unit-root test in the style of Lee (1998) and Nagakura (2009). It regresses squared differences on squared lagged levels, (Δyt)2=μ2+ω yt−12+ηt(\Delta y_t)^2 = \mu^2 + \omega\, y_{t-1}^2 + \eta_t. The raw tt-statistic is not pivotal, and a bias-correction term ρ^(r1,r2)\hat\rho(r_1, r_2), a recursively estimated cross-moment between the residuals of the two regressions, gives the corrected statistic tr1,r2ct^{c}_{r_1, r_2} (following Nishi & Kurozumi 2024).
  2. CUSUM and CUSUM-SQ, the parameter-constancy tests of Brown et al. (1975), applied to the same detection problem.
  3. A union of rejections of SADF/GSADF with SSU/GSSU, which the paper recommends in practice because neither family dominates. SSU/GSSU is better when the coefficient is stochastic (a≠0a \ne 0) and SADF/GSADF when it is deterministic (a=0a = 0).

SSU

ssu_test(data, minw = NULL, level = 0.95) is in exuber/R/ssu_test.R. The regression of eq. 7 is a two-variable OLS over a window, so the closed-form window sums of hls_prefix_sums() and hls_segment_coef() apply, with x=yt−12x = y_{t-1}^2 and z=(Δyt)2z = (\Delta y_t)^2. Expanding the residual cross-moment ∑ε^tη^t\sum \hat\varepsilon_t \hat\eta_t gives a bilinear combination of window sums of twelve per-observation products. ssu_prefix_sums() builds the twelve cumulative sums and ssu_stat_path() evaluates the bias-corrected statistic t0,r2ω,ct^{\omega,c}_{0, r_2} at every end point in O(1)O(1) per window. The statistic is the running maximum. Page 6 of the paper divides all three moments (σε2\sigma^2_\varepsilon, ση2\sigma^2_\eta, σεη\sigma_{\varepsilon\eta}) by the window count minus 2, and the code does the same.

Table I of the paper gives the asymptotic critical values 2.902.90, 3.303.30 and 4.204.20 at the 10%, 5% and 1% levels. They are scalars, because SSU fixes r1=0r_1 = 0. The default minimum window is the r0=0.01+1.8/Tr_0 = 0.01 + 1.8/\sqrt T that the paper recommends, which is the existing psy_minw().

GSSU, union and CUSUM-type statistics

GSSU (ssu_test(type = "gssu")). ssu_stat_path() takes a window start as well as an end, so the same twelve prefix sums give any window (lo,hi](\mathrm{lo}, \mathrm{hi}] in O(1)O(1). The statistic is the maximum over end points of the supremum over starts. The minimum window is the paper’s r0=−0.004+2.24/Tr_0 = -0.004 + 2.24/\sqrt T, because the Table I note says that the psy_minw() formula oversizes GSSU. The critical values are 4.834.83, 5.375.37 and 6.816.81.

Union (ssu_test(union = TRUE)). The UR statistic is

UR=max⁡ ⁣(SADFcvSADF, SSUcvSSU)\mathrm{UR} = \max\!\left( \frac{\mathrm{SADF}}{cv_{\mathrm{SADF}}},\ \frac{\mathrm{SSU}}{cv_{\mathrm{SSU}}} \right)

and is compared with 1.161.16, 1.131.13 and 1.091.09. GUR uses GSADF and GSSU and is compared with 1.111.11, 1.101.10 and 1.081.08. A scaling constant is valid only at the level it was built for. The SADF/GSADF side is radf(x, lag = 0), compared with the precomputed critical values at the sample size of the data.

CUSUM-type statistics (cusum_test(type = "cs" | "gcs" | "cssq" | "gcssq"), page 7). With σ2=mean⁡((Δy)2)\sigma^2 = \operatorname{mean}((\Delta y)^2), not demeaned,

Sk=∑t≤kΔytσT.S_k = \frac{\sum_{t \le k} \Delta y_t}{\sigma \sqrt T}.

CS is max⁡kSk\max_k S_k and GCS is max⁡j<k(Sk−Sj)\max_{j<k}(S_k - S_j), a running-minimum drawup. For CSSQ, with ση2=mean⁡((Δy)4)−σ4\sigma_\eta^2 = \operatorname{mean}((\Delta y)^4) - \sigma^4,

Dk=∑t≤k(Δyt)2−kT∑t≤T(Δyt)2σηT.D_k = \frac{\sum_{t \le k} (\Delta y_t)^2 - \tfrac{k}{T} \sum_{t \le T} (\Delta y_t)^2}{\sigma_\eta \sqrt T}.

CSSQ is max⁡kDk\max_k D_k and min⁡kDk\min_k D_k, and GCSSQ is the drawup and drawdown of DD. The CUSUM-SQ tests are two-sided with each tail at α/2\alpha/2. The CSSQ columns of Table I sit at the Brownian-bridge supremum quantiles for α/2\alpha/2 (for example 1.321.32 at 5%, against −log⁡(0.025)/2=1.36\sqrt{-\log(0.025)/2} = 1.36 before discretization), so a “level α\alpha” row is already the two-sided test. All of these statistics are sups or infs of a partial-sum process and take O(T)O(T) through a running max or min. Table I also gives the union constants, so no joint simulation is needed.

Validation

  • Exact checks. ssu_stat_path() matches a brute-force computation (two separate lm() fits and a manual cross-moment) to 2.4×10−152.4 \times 10^{-15} for windows with lo>0\mathrm{lo} > 0, and to 6.1×10−156.1 \times 10^{-15} for the GSSU path. All four CUSUM-type statistics, sup and inf, match a brute-force double loop to 8.9×10−168.9 \times 10^{-16}. test-ssu.R checks every Table I column value by value. The default minw of ssu_test() equals psy_minw() exactly.
  • Size at 5%, n=200n = 200, 300 replications: SSU 0.067, GSSU 0.060, UR 0.050, GUR 0.057, CS 0.043, GCS 0.047, CSSQ 0.023, GCSSQ 0.037. A separate SSU run gives 12.0%12.0\%, 9.0%9.0\% and 2.7%2.7\% at the 10%, 5% and 1% levels, so SSU is mildly oversized.
  • Power at 5%, n=200n = 200, 100 replications, with the bubble over the second half. With a stochastic coefficient (c1=3c_1 = 3, a=4a = 4): SSU 0.90, GSSU 0.97, UR 0.87, GUR 0.96, CS 0.01, GCS 0.01, CSSQ 0.85, GCSSQ 0.78. This reproduces the paper’s main finding (Theorem 2, Figure 2): CUSUM-type tests lose essentially all power once a≠0a \ne 0, SSU and CUSUM-SQ keep it, and the union stays close to the better of its two parts. With a deterministic coefficient (c1=10c_1 = 10, a=0a = 0), every SSU, union and CUSUM-SQ statistic reaches 1.00, and CS and GCS reach 0.53.
  • Alternatives. On a stochastic-coefficient DGP (the alternative of eq. 2, 60 replications) SSU has 85.0% power. On a deterministic explosive DGP it has 80.0%, against 90.0% for SADF, which is the trade-off of Theorem 2.

Tests are in test-ssu.R and test-cusum-test.R. The functions are also in pyexuber (ssu_test(type=, union=), cusum_test()) and agree with the R values on a shared input.

Replication script: replication/volatility-robustness/radf_ssu_validation.R.


SV-ADF

Status: done, as datestamp(option = "svadf"). The source is a preprint and has not been peer-reviewed, which is a lower bar than for the other sources in this project.

Source

Sarkar, A. & Wells, M. T. (2026). Is There an AI Bubble? Robust Date-Stamping for Periods of Exuberance. arXiv:2604.12062. The theory is in the same authors’ Double Local-to-Unity: Inference under Nearly Nonstationary Volatility, arXiv:2512.06823.

Idea

SV-ADF extends the recursive right-tailed ADF test to highly persistent stochastic volatility, for example

log⁡σt2=ϕnlog⁡σt−12+ηt,ϕn→1 at an iterated-logarithmic rate.\log \sigma_t^2 = \phi_n \log \sigma_{t-1}^2 + \eta_t, \qquad \phi_n \to 1 \text{ at an iterated-logarithmic rate.}

The other methods in this file need σ(s)\sigma(s) to be a fixed, non-stochastic function of calendar time. SV-ADF allows the volatility itself to be near-unit-root persistent, as in a GARCH with α+β\alpha + \beta close to 1.

The feasible statistics SV-ADFr\mathrm{SV\text{-}ADF}_r and SV-ADFrt\mathrm{SV\text{-}ADF}_{rt} (eq. 3–4) are built from the same recursive OLS estimator and within-window residual variance τ^2=τ−1∑e^2\hat\tau^2 = \tau^{-1} \sum \hat e^2 as the recursive ADF statistic of radf() (eq. A.13–A.14). The contribution of Theorem 3.1 is an asymptotic justification under much weaker volatility conditions, with the limit coinciding with the homoskedastic one (Phillips & Yu 2009) once normalized.

What differs in practice is the threshold. Section 5.1 gives the calibration. For origination, the authors simulate the statistic under H0H_0 at n∈{100,200,…,1000}n \in \{100, 200, \dots, 1000\} (1000 replications each) and find that the 90th percentiles “are well approximated by log⁡(n)/10\log(n)/10”, which they adopt as the origination threshold. For collapse, they average the 10th-percentile threshold over random nuisance-parameter configurations and find it “most closely approximated by log⁡(n)/2\log(n)/2”. Both are closed-form in the sample size and need no estimated nuisance parameter. Origination and collapse use different thresholds (Remark 1).

Implementation

datestamp(data, option = "svadf", min_duration = NULL) (helper datestamp_svadf() in exuber/R/svadf.R) reuses the badf sequence of radf(). min_duration defaults to psy_ds(n), the existing log⁡(n)\log(n) rule of exuber, in place of the paper’s data-frequency requirement of two consecutive months or one month. Origination is dated at the first run of at least min_duration consecutive points with badf above log⁡(t)/10\log(t)/10. Collapse is dated at the first run of at least min_duration consecutive points with badf below log⁡(t)/2\log(t)/2, searched only after the origination date.

Validation

  • The badf field of datestamp(option = "svadf") equals a direct radf() call, bit for bit, and the thresholds equal log⁡(t)/10\log(t)/10 and log⁡(t)/2\log(t)/2 exactly.
  • Collapse is never dated before origination, which holds by construction and was confirmed over 20 replications.
  • On a synthetic bubble and collapse (large-base bubble DGP) the detection rate is 100% over 20 replications. The mean absolute origination-date error is 4.95 periods and the mean absolute collapse-date error is 20.25 periods. Collapse is dated less precisely because the expanding badf window dilutes a post-collapse downward signal more than the upward signal at origination, a general property of single-recursion statistics.
  • Under H0H_0 (60 replications, pure random walk) the false-alarm rate is 13.3%. This counts any origination crossing in a 150-period path, which is a much larger compound opportunity than a single-point 10% test.

test-datestamp-svadf.R has 7 tests. Replication script: replication/volatility-robustness/datestamp_svadf_validation.R.

Replication scripts

22 standalone scripts support the claims above, and none depends on another. The R scripts run from the exuber/ package root. The 10 Python scripts check that pyexuber reproduces the same numbers.

datestamp_svadf_validation.R 72 lines
# Validation of datestamp(option = "svadf"), the SV-ADF asymmetric-threshold
# bubble dating of Sarkar & Wells (2026, preprint). See
# docs/volatility-robustness.md, "SV-ADF".
#
# Run from the exuber-project/ root.

devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Structural: badf reused bit-for-bit from radf() ===\n")
set.seed(1)
y <- cumsum(rnorm(150))
r <- radf(y, lag = 0)
r2 <- radf(y, minw = attr(r, "minw"), lag = 0)
cat("max|badf diff|:", max(abs(r$badf[, 1] - r2$badf[, 1])), "\n")

cat("\n=== 2. Threshold formulas ===\n")
cat("svadf_threshold(100,'origination'):", exuber:::svadf_threshold(100, "origination"),
  " expect log(100)/10=", log(100) / 10, "\n")
cat("svadf_threshold(100,'collapse'):", exuber:::svadf_threshold(100, "collapse"),
  " expect log(100)/2=", log(100) / 2, "\n")

cat("\n=== 3. Collapse never dated before origination (20 reps) ===\n")
set.seed(3)
ok <- TRUE
for (i in 1:20) {
  yy <- cumsum(rnorm(150))
  rr <- radf(yy, lag = 0)
  out <- datestamp(rr, option = "svadf", min_duration = psy_ds(150))
  if (length(out) > 0 && out[[1]]$End[1] <= out[[1]]$Start[1]) ok <- FALSE
}
cat("collapse always after origination when detected:", ok, "\n")

cat("\n=== 4. Dating accuracy on a synthetic bubble+collapse episode (20 reps) ===\n")
orig_err <- coll_err <- c()
detected <- 0
nrep <- 20
for (i in 1:nrep) {
  set.seed(100 + i)
  n1 <- 60
  yy1 <- 100 + cumsum(rnorm(n1))
  n2 <- 40
  bubble <- yy1[n1] * 1.04^(1:n2) + cumsum(rnorm(n2, sd = 1))
  n3 <- 40
  coll <- bubble[n2] - cumsum(abs(rnorm(n3, mean = 3, sd = 1)))
  yy <- c(yy1, bubble, coll)
  rr <- radf(yy, lag = 0)
  out <- datestamp(rr, option = "svadf", min_duration = psy_ds(length(yy)))
  if (length(out) > 0) {
    detected <- detected + 1
    orig_err <- c(orig_err, abs(out[[1]]$Start[1] - n1))
    if (!isTRUE(out[[1]]$Ongoing[1])) coll_err <- c(coll_err, abs(out[[1]]$End[1] - (n1 + n2)))
  }
}
cat("detection rate:", detected / nrep, "\n")
cat("mean |origination error|:", mean(orig_err), "\n")
cat("mean |collapse error|:", mean(coll_err), "\n")

cat("\n=== 5. False-alarm rate under H0 (pure random walk, 60 reps) ===\n")
set.seed(5)
fa <- 0
nrep2 <- 60
for (i in 1:nrep2) {
  set.seed(1000 + i)
  yy <- cumsum(rnorm(150))
  rr <- radf(yy, lag = 0)
  out <- datestamp(rr, option = "svadf", min_duration = psy_ds(150))
  if (length(out) > 0) fa <- fa + 1
}
cat("false origination-alarm rate:", fa / nrep2, "\n")

cat("\ndone\n")
datestamp_svadf_validation.py 104 lines
"""Python counterpart of datestamp_svadf_validation.R -- cross-checks
pyexuber's port of `datestamp(option="svadf")` (Sarkar & Wells 2026's
SV-ADF asymmetric-threshold dating, R/svadf.R + R/radf-methods.R's
datestamp_svadf()) in exuber.datestamp.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/datestamp_svadf_validation.py

This is a preprint (not peer-reviewed) -- flagged explicitly, same bar as
the R implementation and the port's own runtime warning.

Checks 1-2 (threshold formulas, structural collapse-never-before
-origination) need no exuber._core. Checks 3-4 (dating accuracy on a
synthetic bubble, false-alarm rate under H0) call radf() to build the
badf sequence datestamp() dates against, so they only run where the
compiled extension is available (CI) -- see radf_kp_validation.py's own
module docstring for the same caveat.
"""

import numpy as np

from exuber.datestamp import datestamp, svadf_threshold
from exuber.radf import psy_ds, radf


def check_threshold_formulas() -> None:
    t = np.array([100.0])
    orig = svadf_threshold(t, "origination")
    coll = svadf_threshold(t, "collapse")
    print(f"svadf_threshold(100, 'origination') = {orig[0]:.6f}  expect log(100)/10 = {np.log(100) / 10:.6f}")
    print(f"svadf_threshold(100, 'collapse')     = {coll[0]:.6f}  expect log(100)/2  = {np.log(100) / 2:.6f}")
    np.testing.assert_allclose(orig, np.log(100) / 10)
    np.testing.assert_allclose(coll, np.log(100) / 2)


def check_collapse_never_before_origination() -> None:
    """Needs exuber._core (radf() call). Structural check: since the
    collapse search only starts after the origination row, End can never
    precede Start when both are found -- across 20 reps."""
    rng = np.random.default_rng(3)
    ok = True
    for _ in range(20):
        yy = np.cumsum(rng.normal(size=150))
        rr = radf(yy, lag=0)
        out = datestamp(rr, option="svadf", min_duration=psy_ds(150))
        if out:
            ep = next(iter(out.values()))[0]
            if not ep.ongoing and ep.end is not None and ep.end <= ep.start:
                ok = False
    print(f"collapse always after origination when detected: {ok}")
    assert ok


def check_dating_accuracy_on_synthetic_bubble() -> None:
    """Needs exuber._core. Same synthetic bubble+collapse construction as
    the R validation script (large base bubble DGP)."""
    orig_err, coll_err = [], []
    detected = 0
    nrep = 20
    for i in range(nrep):
        r = np.random.default_rng(100 + i)
        n1 = 60
        yy1 = 100 + np.cumsum(r.normal(size=n1))
        n2 = 40
        bubble = yy1[-1] * 1.04 ** np.arange(1, n2 + 1) + np.cumsum(r.normal(scale=1, size=n2))
        n3 = 40
        coll = bubble[-1] - np.cumsum(np.abs(r.normal(loc=3, scale=1, size=n3)))
        yy = np.concatenate([yy1, bubble, coll])
        rr = radf(yy, lag=0)
        out = datestamp(rr, option="svadf", min_duration=psy_ds(len(yy)))
        if out:
            detected += 1
            ep = next(iter(out.values()))[0]
            orig_err.append(abs(ep.start - n1))
            if not ep.ongoing and ep.end is not None:
                coll_err.append(abs(ep.end - (n1 + n2)))
    print(f"detection rate: {detected / nrep:.3f}")
    if orig_err:
        print(f"mean |origination error|: {np.mean(orig_err):.3f}")
    if coll_err:
        print(f"mean |collapse error|: {np.mean(coll_err):.3f}")


def check_false_alarm_rate_under_h0() -> None:
    """Needs exuber._core. Pure random walk, 60 reps."""
    fa = 0
    nrep = 60
    for i in range(nrep):
        r = np.random.default_rng(1000 + i)
        yy = np.cumsum(r.normal(size=150))
        rr = radf(yy, lag=0)
        out = datestamp(rr, option="svadf", min_duration=psy_ds(150))
        if out:
            fa += 1
    print(f"false origination-alarm rate: {fa / nrep:.3f}")


if __name__ == "__main__":
    check_threshold_formulas()
    check_collapse_never_before_origination()
    check_dating_accuracy_on_synthetic_bubble()
    check_false_alarm_rate_under_h0()
    print("done")
hafner_dist_skew_moments_and_regression.R 49 lines
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Moment check: w = u/sqrt(2) + (v^2-1)/2 should have E[w]=0, E[w^2]=1, E[w^3]=1 ===\n")
set.seed(1)
n <- 2000000
u <- rnorm(n); v <- rnorm(n)
w <- u / sqrt(2) + (v^2 - 1) / 2
cat(sprintf("E[w]=%.4f (want 0)  E[w^2]=%.4f (want 1)  E[w^3]=%.4f (want 1)\n\n",
            mean(w), mean(w^2), mean(w^3)))

cat("=== 2. Regression check: dist_rad=FALSE, dist_skew=FALSE unchanged (compare to git-committed behavior) ===\n")
y <- cumsum(rnorm(60))
set.seed(5)
r1 <- exuber:::radf_wb_dgp_hlst(y, dist_rad = FALSE)
set.seed(5)
r2 <- exuber:::radf_wb_dgp_hlst(y, dist_rad = FALSE, dist_skew = FALSE)
stopifnot(identical(r1, r2))
cat("OK: identical to pre-change default behavior\n\n")

cat("=== 3. Error when both dist_rad and dist_skew are TRUE ===\n")
tryCatch({
  exuber:::radf_wb_hlst(cumsum(rnorm(40)), minw = 10, nboot = 5, dist_rad = TRUE, dist_skew = TRUE)
  cat("FAIL: expected an error\n")
}, error = function(e) cat("OK, errored:", conditionMessage(e), "\n"))
cat("\n")

cat("=== 4. Empirical size under H0 with RIGHT-SKEWED, HETEROSKEDASTIC errors (Hafner's own setting) ===\n")
# errors: negative log-chi-square(1), right-skewed, standardized -- as in the paper's footnote 1
rskew_innov <- function(n) {
  z <- rnorm(n)
  e <- -log(z^2)
  (e - mean(e)) / sd(e)
}
run_once <- function(seed, dist_skew) {
  set.seed(seed)
  Tn <- 100
  g <- 0.05 * (1 + 2 * cos(pi * (1:Tn) / Tn)^2)  # deterministic heteroskedastic scale
  y <- cumsum(g * rskew_innov(Tn))  # pure random walk under H0, right-skewed heteroskedastic errors
  cv <- exuber:::radf_wb_cv(y, minw = 20, nboot = 199, dist_skew = dist_skew, seed = 1)
  obs <- exuber:::rls_gsadf(exuber:::unroot(y), min_win = 20)
  sadf_obs <- obs[length(y) - 20 + 2]
  sadf_obs > cv$sadf_cv[1, "95%"]
}
rej_normal <- sapply(1:80, function(s) run_once(s, dist_skew = FALSE))
rej_skew   <- sapply(1:80, function(s) run_once(s, dist_skew = TRUE))
cat(sprintf("Empirical size, normal multiplier (dist_skew=FALSE): %.3f\n", mean(rej_normal)))
cat(sprintf("Empirical size, skewed multiplier (dist_skew=TRUE):  %.3f\n", mean(rej_skew)))
cat("(nominal 0.05; paper's own finding: undersized in small samples, bias grows with heteroskedasticity -- so well below 0.05 is expected and consistent with the source, not a red flag)\n")
hafner_dist_skew_moments_and_regression.py 93 lines
"""Python counterpart of hafner_dist_skew_moments_and_regression.R --
cross-checks pyexuber's port of `radf_wb_cv(dist_skew=True)`/
`_wb_dgp_hlst(dist_skew=True)` (Hafner 2020's skewness-corrected wild
bootstrap multiplier, R/radf_wb.R's `dist_skew` option) in exuber.cv.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/hafner_dist_skew_moments_and_regression.py

Checks 1-2 need no RNG match against R (the multiplier's defining moments
and the "additive option" regression check are both RNG-agnostic/
RNG-identity properties). Check 3 needs exuber._core (radf_wb_cv() calls
the compiled radf_stat() internally), so it only runs where the extension
is built (CI) -- see radf_kp_validation.py's own module docstring for the
same caveat.
"""

import numpy as np

from exuber.cv import _wb_dgp_hlst, radf_wb_cv


def check_moment_construction() -> None:
    """w = u/sqrt(2) + (v^2-1)/2, u,v ~ iid N(0,1) independent -- should
    have E[w]=0, E[w^2]=1, E[w^3]=1 (the paper's own construction, Step
    1), regardless of RNG engine."""
    rng = np.random.default_rng(1)
    n = 2_000_000
    u = rng.normal(size=n)
    v = rng.normal(size=n)
    w = u / np.sqrt(2) + (v**2 - 1) / 2
    print(f"E[w]={w.mean():.4f} (want 0)  E[w^2]={(w**2).mean():.4f} (want 1)  "
          f"E[w^3]={(w**3).mean():.4f} (want 1)")
    assert abs(w.mean()) < 0.01
    assert abs((w**2).mean() - 1) < 0.01
    assert abs((w**3).mean() - 1) < 0.03


def check_dist_skew_false_unchanged() -> None:
    """A purely additive option: dist_skew=False (the default) must
    reproduce the pre-change DGP bit-for-bit for the same seed."""
    y = np.cumsum(np.random.default_rng(5).normal(size=60))
    r1 = _wb_dgp_hlst(y, False, np.random.default_rng(5))
    r2 = _wb_dgp_hlst(y, False, np.random.default_rng(5), dist_skew=False)
    assert np.array_equal(r1, r2)
    print("OK: dist_skew=False identical to pre-change default behavior.")


def check_mutually_exclusive() -> None:
    data = np.cumsum(np.random.default_rng(0).normal(size=40))
    try:
        radf_wb_cv(data, minw=10, nboot=5, dist_rad=True, dist_skew=True)
        print("FAIL: expected an error")
    except ValueError as e:
        print(f"OK, errored: {e}")


def check_size_under_h0_right_skewed_heteroskedastic() -> None:
    """Empirical size under H0 with right-skewed, heteroskedastic errors
    (Hafner's own setting, footnote 1) -- needs exuber._core."""

    def rskew_innov(n: int, rng: np.random.Generator) -> np.ndarray:
        z = rng.normal(size=n)
        e = -np.log(z**2)
        return (e - e.mean()) / e.std()

    def run_once(seed: int, dist_skew: bool) -> bool:
        from exuber._unroot import unroot
        from exuber import _core

        rng = np.random.default_rng(seed)
        tn = 100
        g = 0.05 * (1 + 2 * np.cos(np.pi * np.arange(1, tn + 1) / tn) ** 2)
        y = np.cumsum(g * rskew_innov(tn, rng))
        cv = radf_wb_cv(y, minw=20, nboot=199, dist_skew=dist_skew, seed=1)
        obs = _core.radf_stat(unroot(y), 20, 0)
        sadf_obs = obs[tn - 20 + 1]
        return sadf_obs > cv.sadf_cv[0, 1]

    rej_normal = np.mean([run_once(s, False) for s in range(1, 41)])
    rej_skew = np.mean([run_once(s, True) for s in range(1, 41)])
    print(f"Empirical size, normal multiplier (dist_skew=False): {rej_normal:.3f}")
    print(f"Empirical size, skewed multiplier (dist_skew=True):  {rej_skew:.3f}")
    print("(nominal 0.05; paper's own finding: undersized in small samples under "
          "heteroskedasticity -- well below 0.05 is expected, not a red flag)")


if __name__ == "__main__":
    check_moment_construction()
    check_dist_skew_false_unchanged()
    check_mutually_exclusive()
    check_size_under_h0_right_skewed_heteroskedastic()
    print("All checks passed.")
hafner_dist_skew_power_and_size.R 40 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== Power check with dist_skew=TRUE bootstrap, ORDINARY normal innovations ===\n")
run_power <- function(seed) {
  set.seed(seed)
  Tn <- 100
  Te <- 60
  normal_part <- cumsum(rnorm(Te))
  expl_part <- normal_part[Te] * 1.06^(1:(Tn - Te)) + cumsum(rnorm(Tn - Te, sd = 0.3))
  y <- c(normal_part, expl_part)
  obs <- radf(y, minw = 20)$sadf
  cv <- radf_wb_cv(y, minw = 20, nboot = 199, dist_skew = TRUE, seed = 1)
  obs > cv$sadf_cv[1, "95%"]
}
power <- mean(sapply(1:30, run_power))
cat(sprintf("Empirical power (30 reps): %.3f\n\n", power))

cat("=== Size under H0 with the paper's own right-skewed innovation distribution
     (negative log-chi-square(1)), no heteroskedasticity added on top ===\n")
rskew_innov <- function(n) {
  z <- rnorm(n)
  e <- -log(z^2)
  (e - mean(e)) / sd(e)
}
run_size <- function(seed, dist_skew) {
  set.seed(seed)
  Tn <- 150
  y <- cumsum(rskew_innov(Tn))  # pure random walk under H0
  obs <- radf(y, minw = 20)$sadf
  cv <- radf_wb_cv(y, minw = 20, nboot = 199, dist_skew = dist_skew, seed = 1)
  obs > cv$sadf_cv[1, "95%"]
}
rej_normal <- mean(sapply(1:60, function(s) run_size(s, FALSE)))
rej_skew   <- mean(sapply(1:60, function(s) run_size(s, TRUE)))
cat(sprintf("Empirical size, normal multiplier: %.3f\n", rej_normal))
cat(sprintf("Empirical size, skewed multiplier: %.3f\n", rej_skew))
cat("(nominal 0.05)\n")
hafner_dist_skew_power_and_size.py 72 lines
"""Python counterpart of hafner_dist_skew_power_and_size.R -- power and
size checks for pyexuber's port of `radf_wb_cv(dist_skew=True)` (Hafner
2020's skewness-corrected wild bootstrap, exuber.cv). See
hafner_dist_skew_moments_and_regression.py for the moment-construction
and additive-option checks.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/hafner_dist_skew_power_and_size.py

Both checks below need exuber._core (radf() and radf_wb_cv() call the
compiled radf_stat() internally), so they only run where the extension
is built (CI).
"""

import numpy as np

from exuber.cv import radf_wb_cv
from exuber.radf import radf


def check_power_ordinary_normal_innovations() -> None:
    """Power check with dist_skew=True bootstrap, ordinary (non-skewed)
    normal innovations -- confirms the skewed multiplier doesn't harm
    basic detection ability."""

    def run_once(seed: int) -> bool:
        rng = np.random.default_rng(seed)
        tn, te = 100, 60
        normal_part = np.cumsum(rng.normal(size=te))
        expl_len = tn - te
        expl_part = normal_part[-1] * 1.06 ** np.arange(1, expl_len + 1) + np.cumsum(
            rng.normal(scale=0.3, size=expl_len)
        )
        y = np.concatenate([normal_part, expl_part])
        obs = radf(y, minw=20).sadf[0]
        cv = radf_wb_cv(y, minw=20, nboot=199, dist_skew=True, seed=1)
        return obs > cv.sadf_cv[0, 1]

    power = np.mean([run_once(s) for s in range(1, 31)])
    print(f"Empirical power (30 reps): {power:.3f}")


def check_size_under_h0_paper_own_distribution() -> None:
    """Size under H0 with the paper's own right-skewed innovation
    distribution (negative log-chi-square(1)), no heteroskedasticity
    added on top."""

    def rskew_innov(n: int, rng: np.random.Generator) -> np.ndarray:
        z = rng.normal(size=n)
        e = -np.log(z**2)
        return (e - e.mean()) / e.std()

    def run_size(seed: int, dist_skew: bool) -> bool:
        rng = np.random.default_rng(seed)
        tn = 150
        y = np.cumsum(rskew_innov(tn, rng))
        obs = radf(y, minw=20).sadf[0]
        cv = radf_wb_cv(y, minw=20, nboot=199, dist_skew=dist_skew, seed=1)
        return obs > cv.sadf_cv[0, 1]

    rej_normal = np.mean([run_size(s, False) for s in range(1, 61)])
    rej_skew = np.mean([run_size(s, True) for s in range(1, 61)])
    print(f"Empirical size, normal multiplier: {rej_normal:.3f}")
    print(f"Empirical size, skewed multiplier: {rej_skew:.3f}")
    print("(nominal 0.05)")


if __name__ == "__main__":
    check_power_ordinary_normal_innovations()
    check_size_under_h0_paper_own_distribution()
    print("All checks passed.")
radf_kp_validation.R 29 lines
# Replication script for radf_kp() (kernel-purge test, Harvey, Leybourne,
# Taylor & Zu 2024). See docs/volatility-robustness.md, "Kernel-purge test".
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== Critical-value replication vs Table I (T = 400, PSY_sigma) ===\n")
cat("Published: 1.712 / 1.935 / 2.296 (10%/5%/1%)\n\n")

set.seed(31415)
gsadf_kp <- replicate(800, radf_kp(cumsum(rnorm(400)))$gsadf)
own <- quantile(gsadf_kp, c(0.9, 0.95, 0.99))
cat("Independent run (n=400, nrep=800, seed=31415):", round(own, 3), "\n")
cat("Abs. difference from published:", round(abs(own - c(1.712, 1.935, 2.296)), 3), "\n\n")

cat("=== Consistency with radf_mc_cv() (Remark 3.2: T=Inf row should match\n")
cat("    the standard homoskedastic GSADF null) ===\n")
set.seed(2)
gsadf_kp_300 <- replicate(500, radf_kp(cumsum(rnorm(300)))$gsadf)
cv_mc_300 <- radf_mc_cv(300, nrep = 500, seed = 2)
cat("radf_kp() null quantiles (n=300):", round(quantile(gsadf_kp_300, c(0.9, 0.95, 0.99)), 3), "\n")
cat("radf_mc_cv(300) quantiles:       ", round(cv_mc_300$gsadf_cv, 3), "\n\n")

cat("=== Full test-kp.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-kp.R",
  reporter = "summary"
)
radf_kp_validation.py 100 lines
"""Python counterpart of radf_kp_validation.R -- cross-checks pyexuber's
port of radf_kp() (kernel-purge test, Harvey, Leybourne, Taylor & Zu 2024,
R/radf_kp.R) in exuber.volatility.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_kp_validation.py

Checks kernel_spot_vol()/kernel_purge()/radf_kp() bit-for-bit against R,
fed the SAME deterministic input series used by radf_tt_validation.py and
sign_based_finite_T_crosscheck.py (no RNG involved at the formula level):

    options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
    devtools::load_all("exuber", quiet = TRUE)
    set.seed(7); y <- round(cumsum(rnorm(40)), 8)
    exuber:::kernel_spot_vol(y, kernel = "gaussian")
    exuber:::kernel_purge(y, kernel = "gaussian")
    radf_kp(y, minw = 10)

radf_kp() itself needs exuber._core (it calls radf() internally), so the
last check only runs where the compiled extension is available (CI).
"""

import numpy as np

from exuber._kernel_vol import kernel_spot_vol
from exuber.radf_kp import kernel_purge, radf_kp

MINW = 10

Y_VEC = np.array(
    [
        2.28724716, 1.09047548, 0.39618297, -0.01610998, -0.98678332, -1.93406327,
        -1.18592393, -1.30287915, -1.15022153, 1.03975658, 1.39674281, 4.11349459,
        6.39494652, 6.71896706, 8.61503413, 9.08271464, 8.18891391, 7.88158561,
        7.87676319, 8.86492734, 9.7046777, 10.41001953, 11.71598425, 10.32798804,
        11.6009049, 11.78509767, 12.53737757, 13.12912262, 12.14607002, 11.87000607,
        10.99915505, 11.7178656, 11.82851848, 11.75005171, 11.32956125, 10.76743537,
        11.76494882, 10.65981876, 10.51753093, 10.83252583,
    ]
)

R_KSV_SIGMA2 = np.array(
    [
        0.4959746477, 0.5312260867, 0.5716433342, 0.6169370612, 0.6874127672,
        0.8412310930, 1.1577525057, 1.6723606201, 2.3107950220, 2.8881768207,
        3.1903140074, 3.0969314316, 2.6568967882, 2.0450515634, 1.4477027141,
        0.9864934574, 0.7115504863, 0.6200852851, 0.6744228299, 0.8193921797,
        0.9912734704, 1.1188219752, 1.1404876822, 1.0422514405, 0.8736254828,
        0.7103030842, 0.5977433306, 0.5317693774, 0.4823425959, 0.4254286845,
        0.3600372811, 0.3091871957, 0.3044556076, 0.3582846686, 0.4454091397,
        0.5159624129, 0.5322132895, 0.4917189956, 0.4177513067,
    ]
)
R_KSV_H = 0.0533043701

R_PURGED = np.array(
    [
        -1.7873025622, -2.7815277011, -3.3347316743, -4.5671929336, -5.7535320421,
        -4.8328468384, -4.9568680837, -4.8312994075, -3.3989648256, -3.1994688263,
        -1.7867645834, -0.5703660507, -0.3766526498, 0.9732799329, 1.3858598183,
        0.3777725907, -0.0501407948, -0.0571310824, 1.2267423867, 2.1748043745,
        2.8717529025, 4.0401985161, 2.8275287339, 4.0251706963, 4.2266895665,
        5.1809673847, 5.9841391137, 4.6283810407, 4.2390555386, 2.9405290986,
        4.1438219706, 4.3633073113, 4.1957073655, 3.4324194005, 2.6141599989,
        3.8927306251, 2.4765274358, 2.2724582562, 2.8167023625,
    ]
)

R_KP_ADF = -0.9285832329
R_KP_SADF = 0.6113330426
R_KP_GSADF = 0.8288627249


def check_kernel_spot_vol_matches_r() -> None:
    sigma2, h = kernel_spot_vol(Y_VEC, kernel="gaussian")
    np.testing.assert_allclose(sigma2, R_KSV_SIGMA2, atol=1e-6)
    np.testing.assert_allclose(h, R_KSV_H, atol=1e-6)
    print("kernel_spot_vol(): matches R's exuber:::kernel_spot_vol() to 1e-6.")


def check_kernel_purge_matches_r() -> None:
    purged = kernel_purge(Y_VEC, kernel="gaussian")
    np.testing.assert_allclose(purged, R_PURGED, atol=1e-6)
    print("kernel_purge(): matches R's exuber:::kernel_purge() to 1e-6.")


def check_radf_kp_matches_r() -> None:
    res = radf_kp(Y_VEC, minw=MINW)
    np.testing.assert_allclose(res.adf[0], R_KP_ADF, atol=1e-6)
    np.testing.assert_allclose(res.sadf[0], R_KP_SADF, atol=1e-6)
    np.testing.assert_allclose(res.gsadf[0], R_KP_GSADF, atol=1e-6)
    print("radf_kp(): matches R's radf_kp() to 1e-6.")


if __name__ == "__main__":
    check_kernel_spot_vol_matches_r()
    check_kernel_purge_matches_r()
    check_radf_kp_matches_r()
    print("All checks passed.")
radf_sb_cv_aic_bic_validation.R 41 lines
# Replication script for radf_sb_cv(type = "aic"/"bic"), the automatic
# lag-order selection of Pedersen & Schuette (2020) for the sieve bootstrap.
# See docs/volatility-robustness.md, "Pedersen & Schütte sieve bootstrap".
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. type = 'fixed' (pre-existing default) unaffected by the new arguments ===\n")
set.seed(101)
y <- cumsum(rnorm(90))
a <- radf_sb_cv(y, lag = 2, nboot = 60, seed = 22)
b <- radf_sb_cv(y, lag = 2, type = "fixed", nboot = 60, seed = 22)
cat("identical gsadf_panel_cv:", isTRUE(all.equal(a$gsadf_panel_cv, b$gsadf_panel_cv)), "\n\n")

cat("=== 2. type = 'aic'/'bic' picks up a nonzero lag on AR(2)-autocorrelated data ===\n")
set.seed(202)
n <- 200
e <- arima.sim(list(ar = c(0.35, 0.25)), n = n)
y2 <- cumsum(as.numeric(e))
sb_bic <- radf_sb_cv(y2, type = "bic", max_lag = 5, nboot = 60, seed = 22)
sb_aic <- radf_sb_cv(y2, type = "aic", max_lag = 5, nboot = 60, seed = 22)
cat("class:", class(sb_bic), "\n")
cat("selected lag (bic):", attr(sb_bic, "lag"), "\n")
cat("selected lag (aic):", attr(sb_aic, "lag"), "\n\n")

cat("=== 3. type = 'bic' modal lag on pure random-walk data (no true\n")
cat("    autocorrelation) is 0, across 10 independent draws ===\n")
lags <- vapply(1:10, function(s) {
  set.seed(s + 900)
  y <- cumsum(rnorm(120))
  attr(radf_sb_cv(y, type = "bic", max_lag = 6, nboot = 25, seed = 22), "lag")
}, integer(1))
cat("selected lags across 10 draws:", lags, "\n")
cat("modal lag:", as.integer(names(sort(table(lags), decreasing = TRUE))[1]), "\n\n")

cat("=== Full test-sb.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-sb.R",
  reporter = "summary"
)
radf_sb_cv_aic_bic_validation.py 109 lines
"""Python counterpart of radf_sb_cv_aic_bic_validation.R -- cross-checks
pyexuber's port of radf_sb_cv()/radf_sb_distr() (Pavlidis et al. 2016
sieve bootstrap, R/radf_sb.R) and its Pedersen & Schuette (2020) AIC/BIC
automatic lag selection, which reuses the shared lag_select() from
exuber._lagselect (the same subsystem radf_wb_ps_validation.py checks).

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_sb_cv_aic_bic_validation.py

lag_select() is deterministic and checked bit-for-bit against R (two
independent series below, reusing radf_wb_ps_validation.R's own
seed-11 series for the nonzero-lag case so the reference numbers aren't
duplicated). The bootstrap-DGP part of radf_sb_cv() itself uses numpy's
Generator, not R's RNG, so it's checked structurally, as in
radf_wb_ps_validation.py.
"""

import numpy as np

from exuber._lagselect import lag_select
from exuber.cv import radf_sb_cv
from exuber.radf import psy_minw

# y <- cumsum(rnorm(40)) after set.seed(11) -- same series as
# radf_wb_ps_validation.R/.py; lag_select(y, 'bic', max_lag=5) == 4 there.
Y_NONZERO_LAG = np.array(
    [
        -0.5910311026, -0.5644367336, -2.0809898307, -3.4436431799, -2.2651540239,
        -3.1993053436, -1.8756996974, -1.2507819074, -1.2965048631, -2.3006254388,
        -3.1290586755, -3.4774104006, -5.0157037977, -5.2712690432, -6.4212140759,
        -6.4088871082, -6.6318566490, -5.7440850011, -6.3362402809, -6.9919583995,
        -7.6744760217, -7.6903342145, -8.1329389998, -7.7803815004, -7.7072109181,
        -7.7000521177, -7.8876522283, -8.6533528738, -8.8744096946, -9.8579982820,
        -10.9622823242, -11.9004325384, -11.2218082942, -12.7993061595, -13.6692446177,
        -13.1845675724, -13.3706202710, -11.8250655708, -12.4364456409, -12.7842021283,
    ]
)

# y <- cumsum(rnorm(120)) after set.seed(901) (the s=1 draw of the existing
# R script's "modal lag 0 on pure random-walk data" check, reproduced with:
# set.seed(1 + 900); y <- cumsum(rnorm(120))) --
# lag_select(y, 'bic', max_lag=6) == 0 in R.
Y_ZERO_LAG = np.array(
    [
        0.8280005254, 0.9037403631, 1.0297638781, 3.2953730540, 2.4571820742,
        1.9881313218, 2.0036552915, 2.9649966337, 2.6837130519, 2.7534546993,
        2.3854981840, 2.1791112564, 2.7034419784, 1.1850348963, 0.8810668612,
        1.2207721547, 0.0086998789, -0.0403166404, -0.3933561298, 0.7179381554,
        0.5767380450, 2.8443491868, 2.3306774450, 1.8950008302, 0.5124493609,
        0.7907321379, 1.4771141254, 2.1673821143, 3.3046109976, 2.2997063424,
        5.4637789994, 6.7368314769, 6.8170202655, 6.7320646259, 6.2367888184,
        6.5863425516, 6.5152545756, 7.1525906165, 7.3899634326, 8.2405404171,
        6.7936506516, 4.8922572610, 3.3091199930, 4.4928717647, 5.8216340213,
        5.1727613201, 6.4639183405, 7.6068791535, 6.8849483108, 5.3793020469,
        4.3203202966, 3.2886314359, 5.2465118704, 5.4659803669, 6.2022668447,
        6.4388892920, 6.5529224693, 7.7872883608, 8.1538456891, 8.1673367230,
        9.7791618427, 10.3803305345, 9.7183931202, 9.9031786321, 10.7545557098,
        10.6928446347, 9.5782195803, 9.9318977725, 9.3332014070, 9.3562971436,
        10.4522518578, 11.9305100522, 12.5635943914, 12.1295041898, 10.8993394960,
        10.9233483727, 10.2472321968, 11.9068619299, 11.1611669364, 10.7446409638,
        10.7294083363, 12.4878449174, 12.1559236311, 11.0333089876, 9.4305997938,
        9.8618063784, 9.9668011181, 9.1291369253, 10.8708990612, 11.2428864427,
        10.1246813256, 10.1516123071, 10.0709268466, 10.8126420199, 10.4041225606,
        8.7313835197, 8.6688233086, 9.4735560265, 8.4051664325, 5.9722189285,
        7.9492864599, 6.4858189000, 6.7738346470, 7.1397413025, 6.7550253996,
        8.0640710638, 9.3552276060, 9.1812074033, 9.7607815192, 10.6397064963,
        9.8385103895, 8.1932508953, 8.3612089825, 8.2865638915, 8.8064160287,
        10.5658044873, 10.1533012384, 8.5183272011, 8.4386235415, 7.3939110514,
    ]
)


def check_lag_select_bit_for_bit() -> None:
    assert lag_select(Y_NONZERO_LAG, "bic", max_lag=5) == 4
    assert lag_select(Y_NONZERO_LAG, "aic", max_lag=5) == 5
    assert lag_select(Y_ZERO_LAG, "bic", max_lag=6) == 0
    print("lag_select(): matches R bit-for-bit (nonzero-lag bic=4/aic=5; zero-lag bic=0).")


def check_radf_sb_cv_fixed_vs_default() -> None:
    """type='fixed' (the default) is unaffected by the new type/max_lag
    arguments -- mirrors the R script's check 1."""
    minw = psy_minw(len(Y_NONZERO_LAG))
    a = radf_sb_cv(Y_NONZERO_LAG, minw=minw, lag=2, nboot=40, seed=22)
    b = radf_sb_cv(Y_NONZERO_LAG, minw=minw, lag=2, type="fixed", nboot=40, seed=22)
    np.testing.assert_array_equal(a.gsadf_panel_cv, b.gsadf_panel_cv)
    np.testing.assert_array_equal(a.bsadf_panel_cv, b.bsadf_panel_cv)
    print("radf_sb_cv(): default type='fixed' identical to explicit type='fixed'.")


def check_radf_sb_cv_shapes_lag_gt_0() -> None:
    """For lag > 0, bsadf_panel_cv has `nr - minw - lag` rows."""
    minw = psy_minw(len(Y_NONZERO_LAG))
    lag = 2
    sb = radf_sb_cv(Y_NONZERO_LAG, minw=minw, lag=lag, nboot=30, seed=1)
    expected_pointer = len(Y_NONZERO_LAG) - minw - lag
    assert sb.bsadf_panel_cv.shape == (expected_pointer, 3), (
        f"got {sb.bsadf_panel_cv.shape}, expected ({expected_pointer}, 3)"
    )
    assert sb.lag == lag
    print(f"radf_sb_cv(lag={lag}): bsadf_panel_cv shape is the full (nr-minw-lag, 3).")


if __name__ == "__main__":
    check_lag_select_bit_for_bit()
    check_radf_sb_cv_fixed_vs_default()
    check_radf_sb_cv_shapes_lag_gt_0()
    print("All checks passed.")
radf_sbz_validation.R 37 lines
# Replication script for radf_sbz_union() (SBZ: WLS + kernel volatility,
# Harvey, Leybourne & Zu 2019). See docs/volatility-robustness.md,
# "SBZ (WLS + kernel volatility)". The script checks the empirical size under
# a pure random walk, where supDF, supBZ and U should reject at about the
# nominal 5% rate.
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== Empirical size under H0 (pure random walk, no bubble) ===\n")
cat("Target: ~0.05 nominal for each of supDF/supBZ/U\n")
cat("After-fix numbers reported in the doc: supDF=0.033, supBZ=0.060, U=0.053\n\n")

set.seed(13579)
n <- 150
nrep <- 150
nboot <- 199

p_supDF <- p_supBZ <- p_U <- numeric(nrep)
for (i in seq_len(nrep)) {
  y <- cumsum(rnorm(n))
  res <- radf_sbz_union(y, minw = 20, nboot = nboot, seed = NULL)
  p_supDF[i] <- res$p_supDF
  p_supBZ[i] <- res$p_supBZ
  p_U[i] <- res$p_U
}

cat(sprintf("supDF empirical rejection rate: %.3f\n", mean(p_supDF < 0.05)))
cat(sprintf("supBZ empirical rejection rate: %.3f\n", mean(p_supBZ < 0.05)))
cat(sprintf("U     empirical rejection rate: %.3f\n", mean(p_U < 0.05)))

cat("\n=== Full test-sbz.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-sbz.R",
  reporter = "summary"
)
radf_sbz_validation.py 105 lines
"""Python counterpart of radf_sbz_validation.R -- cross-checks pyexuber's
port of radf_sbz()/radf_sbz_cv()/radf_sbz_union() (SBZ: WLS + kernel
volatility, Harvey, Leybourne & Zu 2019, R/radf_sbz.R) in
exuber.volatility.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_sbz_validation.py

Part 1 checks wls_dfstat_grid()/radf_sbz() bit-for-bit against R, fed the
SAME deterministic input series used by radf_tt_validation.py (no RNG
involved at the formula level):

    options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
    devtools::load_all("exuber", quiet = TRUE)
    set.seed(7); y <- round(cumsum(rnorm(40)), 8)
    vol <- exuber:::kernel_spot_vol(y, kernel = "gaussian")
    exuber:::wls_dfstat_grid(y, vol$sigma2, 10)
    radf_sbz(y, minw = 10)

Part 2 reproduces R's own H0 empirical-size check (see
docs/volatility-robustness.md, "SBZ"): a
pure random walk should reject at roughly the nominal rate, not grossly
more. Unlike radf_wb_cv(), radf_sbz()/radf_sbz_cv()'s own wild-bootstrap
DGP is pure Python (no exuber._core call), so this part runs everywhere.
radf_sbz_union() itself needs exuber._core (supDF via the compiled
radf_stat()), so its own check is CI-only.
"""

import numpy as np

from exuber._kernel_vol import kernel_spot_vol
from exuber.radf_sbz import radf_sbz, radf_sbz_cv, radf_sbz_union, wls_dfstat_grid

MINW = 10

Y_VEC = np.array(
    [
        2.28724716, 1.09047548, 0.39618297, -0.01610998, -0.98678332, -1.93406327,
        -1.18592393, -1.30287915, -1.15022153, 1.03975658, 1.39674281, 4.11349459,
        6.39494652, 6.71896706, 8.61503413, 9.08271464, 8.18891391, 7.88158561,
        7.87676319, 8.86492734, 9.7046777, 10.41001953, 11.71598425, 10.32798804,
        11.6009049, 11.78509767, 12.53737757, 13.12912262, 12.14607002, 11.87000607,
        10.99915505, 11.7178656, 11.82851848, 11.75005171, 11.32956125, 10.76743537,
        11.76494882, 10.65981876, 10.51753093, 10.83252583,
    ]
)

R_WLS_SADF = 1.6435722442
R_WLS_GSADF = 1.7667532509
R_SBZ_ADF = 0.0608511749
R_SBZ_SADF = 1.6435722442
R_SBZ_GSADF = 1.7667532509


def check_wls_dfstat_grid_matches_r() -> None:
    sigma2, _ = kernel_spot_vol(Y_VEC, kernel="gaussian")
    res = wls_dfstat_grid(Y_VEC, sigma2, MINW)
    np.testing.assert_allclose(res["sadf"], R_WLS_SADF, atol=1e-6)
    np.testing.assert_allclose(res["gsadf"], R_WLS_GSADF, atol=1e-6)
    print("wls_dfstat_grid(): matches R's exuber:::wls_dfstat_grid() to 1e-6.")


def check_radf_sbz_matches_r() -> None:
    res = radf_sbz(Y_VEC, minw=MINW)
    np.testing.assert_allclose(res.adf[0], R_SBZ_ADF, atol=1e-6)
    np.testing.assert_allclose(res.sadf[0], R_SBZ_SADF, atol=1e-6)
    np.testing.assert_allclose(res.gsadf[0], R_SBZ_GSADF, atol=1e-6)
    print("radf_sbz(): matches R's radf_sbz() to 1e-6.")


def check_empirical_size_under_h0() -> None:
    """Empirical rejection rate under H0 (pure random walk) should be close
    to nominal 0.05, not grossly oversized."""
    rng = np.random.default_rng(13579)
    n, nrep, nboot = 100, 60, 150
    rej_sbz = 0
    for _ in range(nrep):
        y = np.cumsum(rng.normal(size=n))
        res = radf_sbz(y, minw=15)
        cv = radf_sbz_cv(y, minw=15, nboot=nboot, seed=int(rng.integers(1_000_000_000)))
        if res.sadf[0] > cv.sadf_cv[0, 1]:
            rej_sbz += 1
    rate = rej_sbz / nrep
    print(f"supBZ empirical rejection rate under H0 (n={n}, nrep={nrep}, nboot={nboot}): {rate:.3f}")
    print("Target ~0.05 nominal.")
    assert rate < 0.25, f"rate={rate} is grossly oversized"


def check_radf_sbz_union_runs() -> None:
    """Needs exuber._core (supDF via the compiled radf_stat())."""
    rng = np.random.default_rng(1)
    y = np.cumsum(rng.normal(size=100))
    res = radf_sbz_union(y, minw=15, nboot=150, seed=1)
    print(f"supDF={res.supDF[0]:.4f} supBZ={res.supBZ[0]:.4f} U={res.U[0]:.4f} "
          f"p_supDF={res.p_supDF[0]:.3f} p_supBZ={res.p_supBZ[0]:.3f} p_U={res.p_U[0]:.3f}")
    assert res.U[0] >= res.supDF[0]  # U := max(supDF, ratio*supBZ)


if __name__ == "__main__":
    check_wls_dfstat_grid_matches_r()
    check_radf_sbz_matches_r()
    check_empirical_size_under_h0()
    check_radf_sbz_union_runs()
    print("All checks passed.")
radf_sign_dm_levelshift_validation.R 56 lines
# Replicates Harvey, Leybourne, Tatlow & Zu (2025)'s Table 1, Case 1 row
# k = 2, mu = 5, T = 400, p = 0.8 (their worst PSY over-size case at this T):
# published PSY/sPSY/sbarPSY empirical sizes = 0.337 / 0.121 / 0.050.
#
# DGP (Section 5.1): alpha_n = 0.5, alpha_mu = 0, n_T = floor(k*sqrt(T))
# level shifts, n_T^+ = round(p*n_T) positive shifts of magnitude mu_T = mu,
# n_T^- = n_T - n_T^+ negative shifts of magnitude -mu_T. Shift locations
# t_i iid from floor(T*U[0,1])+1, no repeats. y_1 = eps_1, mu_0 = 0. pi =
# 0.1 trimming (minw = floor(0.1*T)), tests at nominal 0.05, critical
# values simulated under the null model with no shifts, eps_t ~ iid N(0,1).
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

set.seed(20260811)
Tn <- 400
k <- 2
mu <- 5
p <- 0.8
nrep <- 500
minw <- floor(0.1 * Tn)

n_T <- floor(k * sqrt(Tn))
n_pos <- round(p * n_T)
n_neg <- n_T - n_pos

simulate_path <- function() {
  eps <- rnorm(Tn)
  shift_locs <- sample.int(Tn, n_T, replace = FALSE)
  shift_sign <- c(rep(1, n_pos), rep(-1, n_neg))
  mu_t <- numeric(Tn)
  mu_t[shift_locs] <- shift_sign * mu
  level <- cumsum(mu_t)
  y <- cumsum(eps) + level
  y
}

cat("Simulating critical values under the null (no shifts) ...\n")
cv_mc <- radf_mc_cv(Tn, minw = minw, nrep = nrep, seed = 1)
cv_sign <- radf_sign_cv(Tn, minw = minw, nrep = nrep, seed = 2)
cv_sign_dm <- radf_sign_dm_cv(Tn, minw = minw, nrep = nrep, seed = 3)

cat("Simulating rejection rates under Case 1 (k=2, mu=5, p=0.8, T=400) ...\n")
reject_psy <- reject_spsy <- reject_sbarpsy <- logical(nrep)
for (r in seq_len(nrep)) {
  y <- simulate_path()
  reject_psy[r] <- radf(y, minw = minw)$gsadf > cv_mc$gsadf_cv["95%"]
  reject_spsy[r] <- radf_sign(y, minw = minw)$gsadf > cv_sign$gsadf_cv["95%"]
  reject_sbarpsy[r] <- radf_sign_dm(y, minw = minw)$gsadf > cv_sign_dm$gsadf_cv["95%"]
}

cat("\n--- Empirical size (nominal 0.05), Case 1: k=2, mu=5, p=0.8, T=400 ---\n")
cat(sprintf("PSY:      %.3f  (published: 0.337)\n", mean(reject_psy)))
cat(sprintf("sPSY:     %.3f  (published: 0.121)\n", mean(reject_spsy)))
cat(sprintf("sbarPSY:  %.3f  (published: 0.050)\n", mean(reject_sbarpsy)))
radf_ssu_validation.R 192 lines
# Validation of ssu_test() and cusum_test() -- Kurozumi & Nishi (2025)'s
# SSU/GSSU stochastic-unit-root bubble tests, their UR/GUR union with
# SADF/GSADF, and the CS/GCS/CSSQ/GCSSQ CUSUM-type statistics.
# See docs/volatility-robustness.md, "Stochastic explosive\n# -coefficient test", for the full writeup.
#
# Run from the exuber-project/ root.

devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula-exact: t^{omega,c} vs. brute-force lm() + manual cross-moment ===\n")
set.seed(2)
n <- 150
y <- cumsum(rnorm(n))
ps <- exuber:::ssu_prefix_sums(y)

brute_force_stat <- function(hi, lo = 0) {
  win <- (lo + 1):hi
  x1 <- y[win]
  d1 <- y[win + 1] - y[win]
  x2 <- x1^2
  d2 <- d1^2

  fit6 <- lm(d1 ~ x1)
  fit7 <- lm(d2 ~ x2)
  eps_hat <- residuals(fit6)
  eta_hat <- residuals(fit7)
  L <- length(win)
  sigma2_eps <- sum(eps_hat^2) / (L - 2)
  sigma2_eta <- sum(eta_hat^2) / (L - 2)
  sigma2_epseta <- sum(eps_hat * eta_hat) / (L - 2)
  sigma_eps <- sqrt(sigma2_eps)
  sigma_eta <- sqrt(sigma2_eta)
  psi_hat <- sigma2_epseta / (sigma_eps * sigma_eta)

  omega_hat <- unname(coef(fit7)[2])
  Sxx2_c <- sum((x2 - mean(x2))^2)
  t_omega <- omega_hat / sqrt(sigma2_eta / Sxx2_c)

  num_corr <- sum((x2 - mean(x2)) * d1)
  den_corr <- sqrt(Sxx2_c)
  correction <- (psi_hat / sigma_eps) * num_corr / den_corr
  (t_omega - correction) / sqrt(1 - psi_hat^2)
}

for (hi_check in c(50, 80, 120, 149)) {
  fast <- exuber:::ssu_stat_path(ps, hi_check)
  manual <- brute_force_stat(hi_check)
  cat(sprintf("hi=%d fast=%.8f manual=%.8f |diff|=%.2e\n", hi_check, fast, manual, abs(fast - manual)))
}

cat("\n=== 2. Table I lookup ===\n")
for (lv in c(90, 95, 99)) {
  cat(sprintf("sig_lvl=%g -> crit=%.2f\n", lv, exuber:::ssu_q(lv)))
}
res <- tryCatch(exuber:::ssu_q(80), error = function(e) "ERROR (expected)")
cat("untabulated sig_lvl=80:", res, "\n")

cat("\n=== 3. minw matches psy_minw() (SSU's own r0 formula) ===\n")
set.seed(1)
y2 <- cumsum(rnorm(120))
out <- ssu_test(y2)
cat("minw:", attr(out, "minw"), " psy_minw(120):", psy_minw(120), "\n")

cat("\n=== 4. Empirical false-alarm rate under H0 ===\n")
set.seed(1)
nrep <- 300
n <- 200
fa_10 <- fa_05 <- fa_01 <- 0
for (i in seq_len(nrep)) {
  yy <- cumsum(rnorm(n))
  out10 <- ssu_test(yy, sig_lvl = 90)
  out05 <- ssu_test(yy, sig_lvl = 95)
  out01 <- ssu_test(yy, sig_lvl = 99)
  if (out10$detected) fa_10 <- fa_10 + 1
  if (out05$detected) fa_05 <- fa_05 + 1
  if (out01$detected) fa_01 <- fa_01 + 1
}
cat(sprintf("sig_lvl=90%%  FA rate: %.3f (nominal 0.10)\n", fa_10 / nrep))
cat(sprintf("sig_lvl=95%%  FA rate: %.3f (nominal 0.05)\n", fa_05 / nrep))
cat(sprintf("sig_lvl=99%%  FA rate: %.3f (nominal 0.01)\n", fa_01 / nrep))

cat("\n=== 5. Detection power: stochastic-explosive-coefficient DGP (KN's own eq. 2 style) ===\n")
set.seed(2)
nrep <- 60
n <- 200
make_stochastic_bubble <- function(n, te_frac = 0.5, c1 = 3, a = 4) {
  yy <- numeric(n)
  yy[1] <- rnorm(1)
  Te <- round(te_frac * n)
  for (t in 2:n) {
    if (t <= Te) {
      yy[t] <- yy[t - 1] + rnorm(1)
    } else {
      rho_t <- 1 + c1 / n + a * rnorm(1) / sqrt(n)
      yy[t] <- rho_t * yy[t - 1] + rnorm(1)
    }
  }
  yy
}
det_ssu <- 0
for (i in seq_len(nrep)) {
  yy <- make_stochastic_bubble(n)
  out <- ssu_test(yy, sig_lvl = 95)
  if (out$detected) det_ssu <- det_ssu + 1
}
cat(sprintf("SSU power on stochastic-coefficient bubble DGP: %.3f\n", det_ssu / nrep))

cat("\n=== 6. Detection power on a deterministic explosive DGP, vs. standard SADF ===\n")
set.seed(3)
det_ssu2 <- det_sadf <- 0
for (i in seq_len(nrep)) {
  n1 <- 100
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.03^(1:100) + cumsum(rnorm(100, sd = 1))
  yy <- c(normal_part, expl_part)
  out <- ssu_test(yy, sig_lvl = 95)
  r <- radf(yy)
  cv <- radf_mc_cv(length(yy))
  if (out$detected) det_ssu2 <- det_ssu2 + 1
  if (r$sadf > cv$sadf_cv[2]) det_sadf <- det_sadf + 1
}
cat(sprintf("SSU power: %.3f  SADF power: %.3f (deterministic explosive DGP)\n", det_ssu2 / nrep, det_sadf / nrep))

cat("\n=== 7. GSSU: windows off the sample start, and the sup over starts ===\n")
d_lo <- max(vapply(c(1, 17, 60), function(l) abs(exuber:::ssu_stat_path(ps, 120, l) - brute_force_stat(120, l)), 0))
g <- exuber:::gssu_stat_path(ps, 60:70, 25)
bg <- vapply(60:70, function(h) max(vapply(0:(h - 25), function(l) brute_force_stat(h, l), 0)), 0)
cat(sprintf("max|diff| lo > 0: %.2e   gssu path max|diff|: %.2e\n", d_lo, max(abs(g - bg))))

cat("\n=== 8. CUSUM family: running max/min vs brute-force double loop ===\n")
set.seed(7)
yc <- cumsum(rnorm(60)) + c(rep(0, 40), cumsum(1.1^(1:20)))
dd <- diff(yc)
nd <- length(dd)
worst <- 0
for (type in c("cs", "gcs", "cssq", "gcssq")) {
  vals <- unlist(lapply(1:nd, function(k) {
    vapply(if (type %in% c("cs", "cssq")) 0 else 0:(k - 1), function(j) {
      w <- dd[(j + 1):k]
      if (type %in% c("cs", "gcs")) {
        sum(w) / sqrt(mean(dd^2) * nd)
      } else {
        (sum(w^2) - (k - j) / nd * sum(dd^2)) / sqrt((mean(dd^4) - mean(dd^2)^2) * nd)
      }
    }, 0)
  }))
  out <- cusum_test(yc, type = type)
  worst <- max(worst, abs(out$sup - max(vals)), if (!is.null(out$inf)) abs(out$inf - min(vals)) else 0)
}
cat(sprintf("max|diff| over cs/gcs/cssq/gcssq sup and inf: %.2e\n", worst))

cat("\n=== 9. Empirical size at 5%, n = 200, 300 reps, all statistics ===\n")
n <- 200
cv200 <- radf_mc_cv(n, nrep = 2000, seed = 1)
decide <- function(yy) {
  c(
    SSU = unname(ssu_test(yy)$detected),
    GSSU = unname(ssu_test(yy, type = "gssu")$detected),
    UR = unname(ssu_test(yy, union = TRUE, cv = cv200)$union_detected),
    GUR = unname(ssu_test(yy, type = "gssu", union = TRUE, cv = cv200)$union_detected),
    CS = unname(cusum_test(yy, type = "cs")$detected),
    GCS = unname(cusum_test(yy, type = "gcs")$detected),
    CSSQ = unname(cusum_test(yy, type = "cssq")$detected),
    GCSSQ = unname(cusum_test(yy, type = "gcssq")$detected)
  )
}
size <- rowMeans(vapply(1:300, function(i) {
  set.seed(9000 + i)
  decide(cumsum(rnorm(n)))
}, logical(8)))
print(round(size, 3))

cat("\n=== 10. Power at 5%, n = 200, 100 reps: stochastic (a = 4) vs deterministic (a = 0) coefficient ===\n")
kn_dgp <- function(n, a, c1 = 3, te_frac = 0.5) {
  yy <- numeric(n)
  for (t in 2:n) {
    rho <- if (t > te_frac * n) 1 + c1 / n + a * rnorm(1) / sqrt(n) else 1
    yy[t] <- rho * yy[t - 1] + rnorm(1)
  }
  yy
}
for (a in c(4, 0)) {
  pw <- rowMeans(vapply(1:100, function(i) {
    set.seed(9500 + i)
    decide(kn_dgp(n, a, c1 = if (a == 0) 10 else 3))
  }, logical(8)))
  cat(sprintf("a = %g:\n", a))
  print(round(pw, 3))
}

cat("\ndone\n")
radf_ssu_validation.py 188 lines
"""Python counterpart of radf_ssu_validation.R -- cross-checks pyexuber's
ports of ssu_test() (Kurozumi & Nishi 2025's SSU/GSSU, R/ssu_test.R) and
cusum_test() (their CS/GCS/CSSQ/GCSSQ, R/cusum_test.R).

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_ssu_validation.py

Part 1 checks ssu_stat_path()/ssu_test() bit-for-bit against R, fed the
SAME deterministic input series used by radf_tt_validation.py (no RNG
involved at the formula level):

    options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
    devtools::load_all("exuber", quiet = TRUE)
    set.seed(7); y <- round(cumsum(rnorm(40)), 8)
    ps <- exuber:::ssu_prefix_sums(y)
    exuber:::ssu_stat_path(ps, 10:39)
    exuber:::gssu_stat_path(ps, 10:39, 10)
    sapply(c("cs", "gcs", "cssq", "gcssq"), function(t) cusum_test(y, type = t)$sup)

Part 2 also cross-checks ssu_stat_path()'s bilinear cross-moment
expansion against a from-scratch brute-force computation (two separately
fitted OLS regressions plus a manual residual cross-moment) at several
window sizes, independent of both R and the closed-form implementation --
the same style of check test-ssu.R's own R validation uses.

Part 3 reproduces the Table I lookup checks and a detection-power check
on Kurozumi & Nishi's own eq. 2 style stochastic-explosive-coefficient DGP
(their own alternative, own RNG).
"""

import numpy as np

from exuber.cusum_test import cusum_test
from exuber.ssu_test import gssu_stat_path, ssu_prefix_sums, ssu_q, ssu_stat_path, ssu_test

MINW = 10

Y_VEC = np.array(
    [
        2.28724716, 1.09047548, 0.39618297, -0.01610998, -0.98678332, -1.93406327,
        -1.18592393, -1.30287915, -1.15022153, 1.03975658, 1.39674281, 4.11349459,
        6.39494652, 6.71896706, 8.61503413, 9.08271464, 8.18891391, 7.88158561,
        7.87676319, 8.86492734, 9.7046777, 10.41001953, 11.71598425, 10.32798804,
        11.6009049, 11.78509767, 12.53737757, 13.12912262, 12.14607002, 11.87000607,
        10.99915505, 11.7178656, 11.82851848, 11.75005171, 11.32956125, 10.76743537,
        11.76494882, 10.65981876, 10.51753093, 10.83252583,
    ]
)

R_SSU_STAT = np.array(
    [
        0.3833518443, 0.4403276581, 0.7473456123, -0.8456697733, -0.7600438094,
        -1.4430830294, -0.6408326735, -0.7920469795, -1.0376584345, -1.3367638315,
        -1.6830472945, -2.0283393484, -2.2007969446, -0.6169301776, -0.7930989796,
        -1.0713490608, -1.3478299468, -1.6197853915, -1.1205521906, -1.2116954558,
        -1.0588494530, -1.2158651418, -1.3690602636, -1.4871311323, -1.5224013406,
        -1.5261706384, -1.6270001876, -1.4260822913, -1.4925456402, -1.5806977666,
    ]
)

R_GSSU_STAT = np.array(
    [
        0.3833518443, 0.4403276581, 0.7473456123, -0.8393593797, -0.7600438094,
        -0.9952568858, 0.6193076072, 0.6941608385, 0.6196343917, 0.2487737184,
        -0.1345189093, -0.4991489099, -0.2622429334, 1.3058362526, 5.0869003395,
        1.7989985737, 1.3466144707, 0.7934786227, 0.9397520677, 0.6317869290,
        0.6806068074, 0.6455130002, 0.4240810063, 0.2383198599, 1.0852937648,
        1.1420563060, 0.2393356038, 0.2603072375, 0.3044020047, 0.5839649062,
    ]
)

R_CUSUM_SUP = {"cs": 1.7059972635, "gcs": 2.3702314238, "cssq": 1.1433215401, "gcssq": 1.5264986415}


def check_ssu_stat_path_matches_r() -> None:
    ps = ssu_prefix_sums(Y_VEC)
    hi_idx = np.arange(MINW, len(Y_VEC))
    stat = ssu_stat_path(ps, hi_idx)
    np.testing.assert_allclose(stat, R_SSU_STAT, atol=1e-6)
    print("ssu_stat_path(): matches R's exuber:::ssu_stat_path() to 1e-6.")


def check_ssu_test_matches_r() -> None:
    res = ssu_test(Y_VEC, minw=MINW, sig_lvl=95)
    np.testing.assert_allclose(res.sadf[0], R_SSU_STAT.max(), atol=1e-6)
    print(f"ssu_test(): sadf={res.sadf[0]:.10f} matches max(R_SSU_STAT)={R_SSU_STAT.max():.10f}.")


def check_gssu_and_cusum_match_r() -> None:
    ps = ssu_prefix_sums(Y_VEC)
    stat = gssu_stat_path(ps, np.arange(MINW, len(Y_VEC)), MINW)
    np.testing.assert_allclose(stat, R_GSSU_STAT, atol=1e-6)
    for t, sup in R_CUSUM_SUP.items():
        assert abs(cusum_test(Y_VEC, type=t).sup[0] - sup) < 1e-8, t
    print("gssu_stat_path() and cusum_test() sup: match R to 1e-6 / 1e-8.")


def check_formula_vs_brute_force() -> None:
    """Independent of R: ssu_stat_path()'s bilinear expansion against a
    from-scratch computation (two OLS fits + manual cross-moment)."""
    rng = np.random.default_rng(2)
    n = 150
    y = np.cumsum(rng.normal(size=n))
    ps = ssu_prefix_sums(y)

    def brute_force_stat(hi: int) -> float:
        win = np.arange(hi)
        x1 = y[win]
        d1 = y[win + 1] - x1
        x2 = x1**2
        d2 = d1**2

        a1 = np.column_stack([np.ones(hi), x1])
        beta1, *_ = np.linalg.lstsq(a1, d1, rcond=None)
        eps_hat = d1 - a1 @ beta1

        a2 = np.column_stack([np.ones(hi), x2])
        beta2, *_ = np.linalg.lstsq(a2, d2, rcond=None)
        eta_hat = d2 - a2 @ beta2

        sigma2_eps = np.sum(eps_hat**2) / (hi - 2)
        sigma2_eta = np.sum(eta_hat**2) / (hi - 2)
        sigma2_epseta = np.sum(eps_hat * eta_hat) / (hi - 2)
        sigma_eps, sigma_eta = np.sqrt(sigma2_eps), np.sqrt(sigma2_eta)
        psi_hat = sigma2_epseta / (sigma_eps * sigma_eta)

        omega_hat = beta2[1]
        sxx2_c = np.sum((x2 - x2.mean()) ** 2)
        t_omega = omega_hat / np.sqrt(sigma2_eta / sxx2_c)

        num_corr = np.sum((x2 - x2.mean()) * d1)
        den_corr = np.sqrt(sxx2_c)
        correction = (psi_hat / sigma_eps) * num_corr / den_corr
        return (t_omega - correction) / np.sqrt(1 - psi_hat**2)

    for hi in (50, 80, 120, 149):
        fast = ssu_stat_path(ps, np.array([hi]))[0]
        manual = brute_force_stat(hi)
        print(f"hi={hi} fast={fast:.8f} manual={manual:.8f} |diff|={abs(fast - manual):.2e}")
        assert abs(fast - manual) < 1e-6


def check_table_lookup() -> None:
    for lv, expect in ((90, 2.90), (95, 3.30), (99, 4.20)):
        assert ssu_q(lv) == expect
        print(f"sig_lvl={lv} -> crit={ssu_q(lv):.2f}")
    try:
        ssu_q(80)
        print("FAIL: expected an error")
    except ValueError as e:
        print(f"untabulated sig_lvl=80: OK, errored: {e}")


def check_power_on_stochastic_coefficient_dgp() -> None:
    """Detection power on Kurozumi & Nishi's own eq. 2 style
    stochastic-explosive-coefficient alternative."""

    def make_stochastic_bubble(rng: np.random.Generator, n: int, te_frac: float = 0.5,
                                c1: float = 3.0, a: float = 4.0) -> np.ndarray:
        y = np.empty(n)
        y[0] = rng.normal()
        te = round(te_frac * n)
        for t in range(1, n):
            if t < te:
                y[t] = y[t - 1] + rng.normal()
            else:
                rho_t = 1 + c1 / n + a * rng.normal() / np.sqrt(n)
                y[t] = rho_t * y[t - 1] + rng.normal()
        return y

    rng = np.random.default_rng(2)
    n, nrep = 200, 60
    detected = sum(
        ssu_test(make_stochastic_bubble(rng, n), sig_lvl=95).detected[0] for _ in range(nrep)
    )
    power = detected / nrep
    print(f"SSU power on stochastic-coefficient bubble DGP: {power:.3f}")


if __name__ == "__main__":
    check_ssu_stat_path_matches_r()
    check_ssu_test_matches_r()
    check_gssu_and_cusum_match_r()
    check_formula_vs_brute_force()
    check_table_lookup()
    check_power_on_stochastic_coefficient_dgp()
    print("All checks passed.")
radf_tt_validation.R 52 lines
# Replication script for radf_tt() and radf_tt_cv() (STADF/GSTADF, Kurozumi,
# Skrobotov & Tsarev 2024). See docs/volatility-robustness.md,
# "Time-transformed test (STADF / GSTADF)".
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula check: gls_dfstat_grid() vs brute-force lm() ===\n")
set.seed(4242)
y <- cumsum(rnorm(65))
minw <- 15
res <- exuber:::gls_dfstat_grid(y, minw)

n1 <- length(y) - 1L
dy <- diff(y - y[1])
ylag <- (y - y[1])[1:n1]
b_idx <- minw:n1
badf_lm <- vapply(b_idx, function(b) {
  fit <- lm(dy[1:b] ~ ylag[1:b] - 1)
  summary(fit)$coefficients[1, "t value"]
}, numeric(1))

cat("max|badf_formula - badf_lm| =", max(abs(res$badf - badf_lm)), "\n\n")

cat("=== 2. Critical-value replication vs Whitehouse (2019)'s published STADF triple ===\n")
cat("Published (r0 = 0.1): 2.319, 2.626, 3.223\n")
set.seed(99001)
n <- 300
minw2 <- 30
sadf <- vapply(replicate(4000, exuber:::gls_dfstat_grid(cumsum(rnorm(n)), minw2),
  simplify = FALSE
), `[[`, numeric(1), "sadf")
own_mc <- quantile(sadf, c(0.9, 0.95, 0.99))
cat("Own independent MC (n=300, nrep=4000, seed=99001):", round(own_mc, 3), "\n")

cv <- radf_tt_cv(n = 300, minw = 30, nrep = 4000, seed = 555)
cat("radf_tt_cv(n=300, minw=30, nrep=4000, seed=555) sadf_cv:", round(cv$sadf_cv, 3), "\n\n")

cat("=== 3. Behavioral sanity check: unit-root -> explosive -> collapse series ===\n")
set.seed(1)
normal_part <- cumsum(rnorm(90))
expl_part <- normal_part[90] * 1.04^(1:60) + cumsum(rnorm(60, sd = 0.5))
y_bubble <- c(normal_part, expl_part)
res_bubble <- radf_tt(y_bubble, minw = 20)
cat("gsadf =", round(res_bubble$gsadf, 3), "vs critical-value range ~2.0-3.3 above\n\n")

cat("=== 4. Full test-tt.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-tt.R",
  reporter = "summary"
)
radf_tt_validation.py 128 lines
"""Python counterpart of radf_tt_validation.R -- cross-checks pyexuber's
port of radf_tt()/radf_tt_cv() (STADF/GSTADF, Kurozumi, Skrobotov & Tsarev
2024, R/radf_tt.R) in exuber.volatility.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_tt_validation.py

Part 1 checks gls_dfstat_grid()/variance_profile()/radf_tt() bit-for-bit
against R, fed the SAME deterministic input series (no RNG involved at the
formula level, so R and numpy agree exactly) -- produced with:

    options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
    devtools::load_all("exuber", quiet = TRUE)
    set.seed(7); y <- round(cumsum(rnorm(40)), 8)
    exuber:::gls_dfstat_grid(y, 10)
    exuber:::variance_profile(y, kernel = "uniform")
    radf_tt(y, minw = 10, kernel = "uniform")

Part 2 checks radf_tt_cv() (Monte Carlo, numpy's own RNG -- see sim.py's
module docstring for why this can't be bit-exact against R) against
Whitehouse (2019)'s published STADF triple, quoted in the paper's footnote
4 for minw/n = 0.1: (2.319, 2.626, 3.223). Since this is the fixed
asymptotic target of a pivotal simulation (not an R-RNG-specific value),
a wide-tolerance comparison is a real, RNG-agnostic cross-check, not just
a shape/sanity check.
"""

import numpy as np

from exuber._gls_dfstat import gls_dfstat_grid
from exuber.radf_tt import radf_tt, radf_tt_cv, variance_profile

MINW = 10

Y_VEC = np.array(
    [
        2.28724716, 1.09047548, 0.39618297, -0.01610998, -0.98678332, -1.93406327,
        -1.18592393, -1.30287915, -1.15022153, 1.03975658, 1.39674281, 4.11349459,
        6.39494652, 6.71896706, 8.61503413, 9.08271464, 8.18891391, 7.88158561,
        7.87676319, 8.86492734, 9.7046777, 10.41001953, 11.71598425, 10.32798804,
        11.6009049, 11.78509767, 12.53737757, 13.12912262, 12.14607002, 11.87000607,
        10.99915505, 11.7178656, 11.82851848, 11.75005171, 11.32956125, 10.76743537,
        11.76494882, 10.65981876, 10.51753093, 10.83252583,
    ]
)

R_GLS_BADF = np.array(
    [
        -0.4869476194, -0.5993680238, -0.2024272131, -0.0927192182, 0.4725944183,
        0.5988636710, 0.2121796002, 0.1113111528, 0.1065742445, 0.3591482854,
        0.5876420656, 0.7788759548, 1.1488183477, 0.5616782653, 0.8780267990,
        0.8944064816, 1.0772802859, 1.2107378397, 0.8360708791, 0.7377989079,
        0.4970657730, 0.6456036924, 0.6603799133, 0.6309975992, 0.5245109634,
        0.3967069003, 0.5846610107, 0.3332265155, 0.3047853676, 0.3604560044,
    ]
)
R_GLS_SADF = 1.2107378397
R_GLS_GSADF = 1.9637889135

R_VP_ETAGRID = np.array(
    [
        0.0, 0.0446045107, 0.0644273035, 0.0713730614, 0.1025117707, 0.1199565554,
        0.1522699431, 0.1523421936, 0.1534211678, 0.3066249818, 0.3113364130,
        0.3113364130, 0.4621388947, 0.4623333174, 0.5629110394, 0.5650758544,
        0.6055428527, 0.6150329465, 0.6170647429, 0.6362261844, 0.6488411132,
        0.6577037608, 0.6964175182, 0.7756513090, 0.8175841271, 0.8179259903,
        0.8317696672, 0.8375285024, 0.8734079342, 0.8764756454, 0.9011078597,
        0.9177485559, 0.9189042776, 0.9191068751, 0.9229030275, 0.9299740903,
        0.9702629790, 0.9942297001, 0.9942346351, 1.0,
    ]
)
R_VP_OMEGA2 = 0.8233400111

R_TT_ADF = 0.3401194521
R_TT_SADF = 1.1571296746
R_TT_GSADF = 1.8910439255


def check_gls_dfstat_grid_matches_r() -> None:
    res = gls_dfstat_grid(Y_VEC, MINW)
    np.testing.assert_allclose(res["badf"], R_GLS_BADF, atol=1e-6)
    np.testing.assert_allclose(res["sadf"], R_GLS_SADF, atol=1e-6)
    np.testing.assert_allclose(res["gsadf"], R_GLS_GSADF, atol=1e-6)
    print("gls_dfstat_grid(): matches R's exuber:::gls_dfstat_grid() to 1e-6.")


def check_variance_profile_matches_r() -> None:
    eta_grid, omega2 = variance_profile(Y_VEC, kernel="uniform")
    np.testing.assert_allclose(eta_grid, R_VP_ETAGRID, atol=1e-6)
    np.testing.assert_allclose(omega2, R_VP_OMEGA2, atol=1e-6)
    print("variance_profile(): matches R's exuber:::variance_profile() to 1e-6.")


def check_radf_tt_matches_r() -> None:
    res = radf_tt(Y_VEC, minw=MINW, kernel="uniform")
    np.testing.assert_allclose(res.adf[0], R_TT_ADF, atol=1e-6)
    np.testing.assert_allclose(res.sadf[0], R_TT_SADF, atol=1e-6)
    np.testing.assert_allclose(res.gsadf[0], R_TT_GSADF, atol=1e-6)
    print("radf_tt(): matches R's radf_tt() to 1e-6.")


def check_radf_tt_cv_against_published_whitehouse() -> None:
    """radf_tt_cv()'s sadf_cv is a Monte Carlo estimate of a pivotal
    (RNG-agnostic) asymptotic target -- comparable directly to the
    published triple even though numpy's RNG differs from R's."""
    cv = radf_tt_cv(n=300, minw=30, nrep=1500, seed=555)
    published = np.array([2.319, 2.626, 3.223])
    diff = np.abs(cv.sadf_cv - published)
    assert np.all(diff < 0.35), f"sadf_cv={cv.sadf_cv} too far from published {published}"
    print(f"radf_tt_cv() sadf_cv={cv.sadf_cv} vs published {published} (max diff {diff.max():.3f}).")


def check_radf_tt_cv_badf_cv_identity() -> None:
    """badf_cv's last row must be bit-identical to adf_cv -- adf is
    literally badf's last point per replicate (see radf_tt.R's own note)."""
    cv = radf_tt_cv(n=60, minw=15, nrep=200, seed=1)
    np.testing.assert_allclose(cv.badf_cv[-1], cv.adf_cv)
    print("radf_tt_cv(): badf_cv's last row matches adf_cv exactly.")


if __name__ == "__main__":
    check_gls_dfstat_grid_matches_r()
    check_variance_profile_matches_r()
    check_radf_tt_matches_r()
    check_radf_tt_cv_against_published_whitehouse()
    check_radf_tt_cv_badf_cv_identity()
    print("All checks passed.")
radf_wb_ps_validation.R 59 lines
# Replication script for radf_wb_ps_cv() and radf_wb_ps_distr() (the
# Phillips & Shi (2020) wild bootstrap variant in R/radf_wb.R) and the shared
# OLS lag-selection and AR-fit routines it needs (adf_res() and lag_select(),
# also in R/radf_wb.R).
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. lag_select()/adf_res(): deterministic (no RNG), bit-for-bit reference ===\n")
set.seed(11)
y <- cumsum(rnorm(40))

cat("lag_select(y, 'aic', max_lag=5):", exuber:::lag_select(y, criterion = "aic", max_lag = 5), "\n")
cat("lag_select(y, 'bic', max_lag=5):", exuber:::lag_select(y, criterion = "bic", max_lag = 5), "\n")

ar <- exuber:::adf_res(y, adflag = 2, type = "fixed")
cat("adf_res(y, adflag=2, type='fixed')$beta:", sprintf("%.10f", ar$beta), "\n")
cat("adf_res(...)$res[1:5]:", sprintf("%.10f", ar$res[1:5]), "\n")
cat("adf_res(...)$res length:", length(ar$res), "\n\n")

cat("=== 2. radf_wb_ps_cv(): structural + shape checks ===\n")
cat("(bootstrap draws use R's own RNG, not comparable bit-for-bit to a\n")
cat(" numpy Generator port -- this checks shape/monotonicity/sanity only)\n")
minw <- psy_minw(length(y))
wb <- radf_wb_ps_cv(y, minw = minw, nboot = 80, adflag = 0, seed = 5)
cat("class:", paste(class(wb), collapse = ","), "\n")
cat("adf_cv dim:", dim(wb$adf_cv), "\n")
cat("gsadf_cv dim:", dim(wb$gsadf_cv), "\n")
cat("bsadf_cv dim:", dim(wb$bsadf_cv), "\n")
cat("gsadf_cv monotonic (90 <= 95 <= 99):", all(diff(as.vector(wb$gsadf_cv)) >= 0), "\n\n")

cat("=== 3. tb (training-window) mode: badf/bsadf collapse to repeated sadf/gsadf ===\n")
tb <- minw + 10
wbt <- radf_wb_ps_cv(y, minw = minw, nboot = 60, adflag = 0, tb = tb, seed = 6)
pointer_full <- length(y) - minw
cat("badf_cv dim (expect", pointer_full, "x 3 x 1):", dim(wbt$badf_cv), "\n")
cat(
  "badf_cv[1,,1] == sadf_cv[1,]:",
  isTRUE(all.equal(as.vector(wbt$badf_cv[1, , 1]), as.vector(wbt$sadf_cv[1, ]))), "\n"
)
cat(
  "bsadf_cv[1,,1] == gsadf_cv[1,]:",
  isTRUE(all.equal(as.vector(wbt$bsadf_cv[1, , 1]), as.vector(wbt$gsadf_cv[1, ]))), "\n"
)

# Reference output (R 4.6.1, seed as above):
#
# lag_select(y, 'aic', max_lag=5): 5
# lag_select(y, 'bic', max_lag=5): 4
# adf_res(y, adflag=2, type='fixed')$beta: -0.2766891117 -0.0510771847 0.0954335649
# adf_res(...)$res[1:5]: -1.1659634957 1.5303078394 -0.4672254328 1.4401135170 1.0583623423
# adf_res(...)$res length: 37
#
# adf_cv dim: 1 3 ; gsadf_cv dim: 1 3 ; bsadf_cv dim: 29 3 1
# gsadf_cv monotonic: TRUE
#
# badf_cv dim: 29 3 1 ; badf_cv[1,,1] == sadf_cv[1,]: TRUE ;
# bsadf_cv[1,,1] == gsadf_cv[1,]: TRUE
radf_wb_ps_validation.py 86 lines
"""Python counterpart of radf_wb_ps_validation.R -- cross-checks pyexuber's
port of exuber's shared OLS lag-selection/AR-fit subsystem
(exuber._lagselect: lag_select(), adf_res()) and radf_wb_ps_cv() (the
Phillips & Shi (2020) wild bootstrap variant).

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/radf_wb_ps_validation.py

lag_select()/adf_res() are deterministic (no RNG) and checked bit-for-bit
against the R reference numbers below (produced by
`Rscript docs/replication/volatility-robustness/radf_wb_ps_validation.R`,
seed as in that script). radf_wb_ps_cv()'s bootstrap draws use numpy's
Generator, not R's RNG (see cv.py's module docstring), so that part is
checked structurally (shapes, monotonic quantiles, the tb-mode collapse
identity) rather than bit-for-bit -- same approach as radf_wb_cv's own
tests.
"""

import numpy as np

from exuber._lagselect import adf_res, lag_select
from exuber.cv import radf_wb_ps_cv
from exuber.radf import psy_minw

# y <- cumsum(rnorm(40)) after set.seed(11) in R -- see the .R script.
Y = np.array(
    [
        -0.5910311026, -0.5644367336, -2.0809898307, -3.4436431799, -2.2651540239,
        -3.1993053436, -1.8756996974, -1.2507819074, -1.2965048631, -2.3006254388,
        -3.1290586755, -3.4774104006, -5.0157037977, -5.2712690432, -6.4212140759,
        -6.4088871082, -6.6318566490, -5.7440850011, -6.3362402809, -6.9919583995,
        -7.6744760217, -7.6903342145, -8.1329389998, -7.7803815004, -7.7072109181,
        -7.7000521177, -7.8876522283, -8.6533528738, -8.8744096946, -9.8579982820,
        -10.9622823242, -11.9004325384, -11.2218082942, -12.7993061595, -13.6692446177,
        -13.1845675724, -13.3706202710, -11.8250655708, -12.4364456409, -12.7842021283,
    ]
)


def check_lag_select_and_adf_res() -> None:
    assert lag_select(Y, "aic", max_lag=5) == 5
    assert lag_select(Y, "bic", max_lag=5) == 4

    fit = adf_res(Y, adflag=2, type="fixed")
    np.testing.assert_allclose(
        fit.beta, [-0.2766891117, -0.0510771847, 0.0954335649], atol=1e-8
    )
    np.testing.assert_allclose(
        fit.res[:5],
        [-1.1659634957, 1.5303078394, -0.4672254328, 1.4401135170, 1.0583623423],
        atol=1e-8,
    )
    assert len(fit.res) == 37
    print("lag_select()/adf_res(): match R bit-for-bit.")


def check_radf_wb_ps_cv_shapes() -> None:
    minw = psy_minw(len(Y))
    wb = radf_wb_ps_cv(Y, minw=minw, nboot=80, adflag=0, seed=5)

    assert wb.adf_cv.shape == (1, 3)
    assert wb.gsadf_cv.shape == (1, 3)
    pointer = len(Y) - minw
    assert wb.bsadf_cv.shape == (pointer, 3, 1)
    assert np.all(np.diff(wb.gsadf_cv.ravel()) >= 0)
    print("radf_wb_ps_cv(): shapes and quantile ordering match R's structural checks.")


def check_tb_mode() -> None:
    minw = psy_minw(len(Y))
    tb = minw + 10
    wbt = radf_wb_ps_cv(Y, minw=minw, nboot=60, adflag=0, tb=tb, seed=6)

    pointer_full = len(Y) - minw
    assert wbt.badf_cv.shape == (pointer_full, 3, 1)
    np.testing.assert_allclose(wbt.badf_cv[0, :, 0], wbt.sadf_cv[0, :])
    np.testing.assert_allclose(wbt.bsadf_cv[0, :, 0], wbt.gsadf_cv[0, :])
    print("radf_wb_ps_cv(tb=...): badf/bsadf collapse to repeated sadf/gsadf, as in R.")


if __name__ == "__main__":
    check_lag_select_and_adf_res()
    check_radf_wb_ps_cv_shapes()
    check_tb_mode()
    print("All checks passed.")
sign_based_finite_T_crosscheck.R 22 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== Cross-check radf_sign_cv() at EXACT finite T=100, vs paper's own T=100 table row ===\n")
cat("(avoids T->Inf convergence ambiguity -- paper documents sPSY converges slowly)\n")
cat("Paper T=100: sPWY (10%,5%,1%) = (2.470, 2.859, 3.656); sPSY = (4.381, 5.578, 13.056)\n\n")

n <- 100
minw <- round(0.1 * n)
cv <- radf_sign_cv(n, minw = minw, nrep = 2000, seed = 1)
cat("Simulated sadf_cv (-> sPWY):", cv$sadf_cv, "\n")
cat("Simulated gsadf_cv (-> sPSY):", cv$gsadf_cv, "\n\n")

cat("=== Also T=200 for a second data point ===\n")
cat("Paper T=200: sPWY = (2.405, 2.735, 3.434); sPSY = (3.469, 3.901, 4.957)\n")
n2 <- 200
minw2 <- round(0.1 * n2)
cv2 <- radf_sign_cv(n2, minw = minw2, nrep = 1500, seed = 1)
cat("Simulated sadf_cv (-> sPWY):", cv2$sadf_cv, "\n")
cat("Simulated gsadf_cv (-> sPSY):", cv2$gsadf_cv, "\n")
sign_based_finite_T_crosscheck.py 131 lines
"""Python counterpart of sign_based_finite_T_crosscheck.R -- cross-checks
pyexuber's port of radf_sign()/radf_sign_cv() (sign-based sGSADF, Harvey,
Leybourne & Zu 2020, R/radf_sign.R) in exuber.volatility.

Run standalone: uv run --project pyexuber python
docs/replication/volatility-robustness/sign_based_finite_T_crosscheck.py

Part 1 checks gls_dfstat_grid(sign_transform(y))/gls_dfstat_grid(
sign_demean_transform(y)) bit-for-bit against R on the SAME deterministic
input series used by radf_tt_validation.py (no RNG at the formula level):

    options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
    devtools::load_all("exuber", quiet = TRUE)
    set.seed(7); y <- round(cumsum(rnorm(40)), 8)
    exuber:::gls_dfstat_grid(exuber:::sign_transform(y), 10)
    exuber:::gls_dfstat_grid(exuber:::sign_demean_transform(y), 10)

Part 2 checks radf_sign_cv() against the paper's own Table 1 at the
EXACT finite T = 200 (not the T = Inf asymptotic row): the paper's own
text documents sPSY's finite-sample critical values converging to the
asymptotic limit much more slowly than sPWY's, so comparing at the exact
matching finite T avoids that convergence ambiguity (same reasoning
sign_based_finite_T_crosscheck.R uses). Like radf_tt_validation.py's
Whitehouse check, this is a real RNG-agnostic cross-check against a fixed
published target, not just a shape/sanity check.
"""

import numpy as np

from exuber._gls_dfstat import gls_dfstat_grid
from exuber.radf_sign import (
    radf_sign,
    radf_sign_cv,
    radf_sign_dm,
    sign_demean_transform,
    sign_transform,
)

MINW = 10

Y_VEC = np.array(
    [
        2.28724716, 1.09047548, 0.39618297, -0.01610998, -0.98678332, -1.93406327,
        -1.18592393, -1.30287915, -1.15022153, 1.03975658, 1.39674281, 4.11349459,
        6.39494652, 6.71896706, 8.61503413, 9.08271464, 8.18891391, 7.88158561,
        7.87676319, 8.86492734, 9.7046777, 10.41001953, 11.71598425, 10.32798804,
        11.6009049, 11.78509767, 12.53737757, 13.12912262, 12.14607002, 11.87000607,
        10.99915505, 11.7178656, 11.82851848, 11.75005171, 11.32956125, 10.76743537,
        11.76494882, 10.65981876, 10.51753093, 10.83252583,
    ]
)

R_SIGN_SADF = 0.6739661980
R_SIGN_GSADF = 2.0040132787
R_SIGNDM_SADF = 3.6784167432
R_SIGNDM_GSADF = 3.6784167432


def check_radf_sign_matches_r() -> None:
    res = radf_sign(Y_VEC, minw=MINW)
    np.testing.assert_allclose(res.sadf[0], R_SIGN_SADF, atol=1e-6)
    np.testing.assert_allclose(res.gsadf[0], R_SIGN_GSADF, atol=1e-6)
    print("radf_sign(): matches R's radf_sign() to 1e-6.")


def check_radf_sign_dm_matches_r() -> None:
    res = radf_sign_dm(Y_VEC, minw=MINW)
    np.testing.assert_allclose(res.sadf[0], R_SIGNDM_SADF, atol=1e-6)
    np.testing.assert_allclose(res.gsadf[0], R_SIGNDM_GSADF, atol=1e-6)
    print("radf_sign_dm(): matches R's radf_sign_dm() to 1e-6.")


def check_exact_invariance_to_heteroskedasticity() -> None:
    """The paper's central claim: identical sadf/gsadf under wildly
    different volatility scaling of the SAME sign pattern."""
    rng = np.random.default_rng(7)
    n2, te = 150, 90
    base_incr = rng.normal(size=te)
    expl_incr = np.concatenate([[rng.normal(loc=3)], rng.normal(size=n2 - te - 1)])
    raw_dy = np.concatenate([base_incr, expl_incr])
    y_homo = np.cumsum(raw_dy)
    vol_pattern = np.concatenate([np.full(40, 0.1), np.full(60, 10.0), np.full(n2 - 100, 1.0)])
    y_hetero = np.cumsum(raw_dy * vol_pattern)

    r_homo = radf_sign(y_homo, minw=20)
    r_hetero = radf_sign(y_hetero, minw=20)
    print(f"homoskedastic:   sadf={r_homo.sadf[0]:.6f} gsadf={r_homo.gsadf[0]:.6f}")
    print(f"heteroskedastic: sadf={r_hetero.sadf[0]:.6f} gsadf={r_hetero.gsadf[0]:.6f}")
    np.testing.assert_allclose(r_homo.sadf, r_hetero.sadf)
    np.testing.assert_allclose(r_homo.gsadf, r_hetero.gsadf)
    print("Exact invariance confirmed: bit-identical sadf/gsadf under a wildly different volatility path.")


def check_radf_sign_cv_against_published_table1_t200() -> None:
    cv = radf_sign_cv(n=200, minw=20, nrep=1500, seed=1)
    published_sadf = np.array([2.405, 2.735, 3.434])  # sPWY, T=200
    published_gsadf = np.array([3.469, 3.901, 4.957])  # sPSY, T=200
    print(f"Simulated sadf_cv (sPWY):  {cv.sadf_cv} vs published {published_sadf}")
    print(f"Simulated gsadf_cv (sPSY): {cv.gsadf_cv} vs published {published_gsadf}")
    assert np.all(np.abs(cv.sadf_cv - published_sadf) < 0.4)
    assert np.all(np.abs(cv.gsadf_cv - published_gsadf) < 0.6)


def check_power() -> None:
    """Empirical power on a clear mildly explosive alternative, using
    radf_sign_cv()'s own simulated critical value."""
    rng = np.random.default_rng(2)
    cv = radf_sign_cv(n=150, minw=20, nrep=500, seed=2)

    def run_once() -> bool:
        te = 90
        normal_part = np.cumsum(rng.normal(size=te))
        expl_len = 150 - te
        expl_part = normal_part[-1] * 1.05 ** np.arange(1, expl_len + 1) + np.cumsum(
            rng.normal(scale=0.5, size=expl_len)
        )
        y = np.concatenate([normal_part, expl_part])
        return radf_sign(y, minw=20).gsadf[0] > cv.gsadf_cv[1]

    power = np.mean([run_once() for _ in range(30)])
    print(f"Empirical power (30 reps, gsadf vs simulated 95% cv at n=150): {power:.3f}")


if __name__ == "__main__":
    check_radf_sign_matches_r()
    check_radf_sign_dm_matches_r()
    check_exact_invariance_to_heteroskedasticity()
    check_radf_sign_cv_against_published_table1_t200()
    check_power()
    print("All checks passed.")
sign_based_invariance_and_power.R 47 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Cross-check radf_sign_cv() vs paper's Table 1 asymptotic (T=Inf) values ===\n")
cat("Paper: sPWY (10%,5%,1%) = (2.410, 2.734, 3.248); sPSY = (2.933, 3.180, 3.655)\n")
n <- 300
minw <- round(0.1 * n)
cv <- radf_sign_cv(n, minw = minw, nrep = 1500, seed = 1)
cat("Simulated sadf_cv (-> sPWY):", cv$sadf_cv, "\n")
cat("Simulated gsadf_cv (-> sPSY):", cv$gsadf_cv, "\n\n")

cat("=== 2. Exact invariance to heteroskedasticity: same sign-path, different scale ===\n")
set.seed(7)
n2 <- 150
Te <- 90
base_incr <- rnorm(Te)  # normal-part increments
expl_incr <- c(rnorm(1, mean = 3), rnorm(n2 - Te - 1))  # some explosive-ish increments
raw_dy <- c(base_incr, expl_incr)
y_homo <- cumsum(raw_dy)  # constant volatility

# now scale by a wild heteroskedastic pattern -- SAME SIGNS, different magnitude
vol_pattern <- c(rep(0.1, 40), rep(10, 60), rep(1, n2 - 100))
y_hetero <- cumsum(raw_dy * vol_pattern)

r_homo <- radf_sign(y_homo, minw = 20)
r_hetero <- radf_sign(y_hetero, minw = 20)
cat(sprintf("homoskedastic:   sadf=%.6f gsadf=%.6f\n", r_homo$sadf, r_homo$gsadf))
cat(sprintf("heteroskedastic: sadf=%.6f gsadf=%.6f\n", r_hetero$sadf, r_hetero$gsadf))
cat("identical:", isTRUE(all.equal(r_homo$sadf, r_hetero$sadf)) &&
                    isTRUE(all.equal(r_homo$gsadf, r_hetero$gsadf)), "\n\n")

cat("=== 3. Power check: does radf_sign correctly flag a clear bubble via its own cv? ===\n")
run_power <- function(seed) {
  set.seed(seed)
  Tn <- 150
  Te <- 90
  normal_part <- cumsum(rnorm(Te))
  expl_part <- normal_part[Te] * 1.05^(1:(Tn - Te)) + cumsum(rnorm(Tn - Te, sd = 0.5))
  y <- c(normal_part, expl_part)
  obs <- radf_sign(y, minw = 20)
  obs$gsadf > cv2$gsadf_cv["95%"]
}
cv2 <- radf_sign_cv(150, minw = 20, nrep = 500, seed = 2)
power <- mean(sapply(1:30, run_power))
cat(sprintf("Empirical power (30 reps, gsadf vs simulated 95%% cv at n=150): %.3f\n", power))