Skip to content
exuber

Replication

Dating and root inference

Origination, collapse and recovery dates, plus confidence intervals on the explosive root itself.

This page is the technical record behind the methods. To learn how to run them, start with Dating and root inference in the guide.

Once radf() or datestamp() has flagged an episode as explosive, two questions follow. Dating asks when the episode started, ended or recovered. Root inference asks how explosive it was: what the autoregressive root ρ\rho is, what confidence interval it carries and how fast the series was doubling. The two sit in one file because they are consecutive steps in the exuber workflow (radf(), then datestamp(), then a sub-sample, then refinement). The methods share little statistical machinery. Status labels are the ones used in volatility-robustness.md.

MethodPaperStatus
SSR/BIC dating, PDC/KS routePang, Du & Chong (2021); Kurozumi & Skrobotov (2023)done
SSR/BIC dating, HLS/HLW routeHarvey, Leybourne & Sollis (2017); Harvey, Leybourne & Whitehouse (2020)done (single-bubble HLS and multi-bubble HLW)
Root inference (Cauchy CI and normal-t CI)Phillips & Magdalinos (2007); Guo, Sun & Wang (2019)done
Confidence sets for bubble datesKurozumi & Skrobotov (2025)evaluated, not implemented
Improved retrospective datingKejriwal, Nguyen & Perron (2025)done (single bubble, and multi-bubble dynamic programme dating_knp(breaks = ))
WLS dating under time-varying volatilityKurozumi & Skrobotov (2023)done
Reverse-regression recovery datingPhillips & Shi (2014/2019)done, with caveats

All papers are listed in references.md.


SSR/BIC dating vs. PSY recursive dating

PSY dates an episode where the recursive BSADF statistic first crosses, and later re-crosses, a critical value. The methods in this section replace that rule with a model-based one that minimises the sum of squared residuals (SSR) over candidate break dates and chooses the regime structure by BIC. They differ in how they handle several bubbles in one series.

Status: the PDC/KS route is dating_pdc(), HLS’s single-bubble route is dating_hls() and HLW’s multi-bubble two-step wrapper is dating_hlw().

Sources

  1. Harvey, D. I., Leybourne, S. J. & Sollis, R. (2017). Improving the accuracy of asset price bubble start and end date estimators. Journal of Empirical Finance, 40, 121–138, doi:10.1016/j.jempfin.2016.11.001 (“HLS”).
  2. Harvey, D. I., Leybourne, S. J. & Whitehouse, E. J. (2020). Date-stamping multiple bubble regimes. Journal of Empirical Finance, 58, 226–246, doi:10.1016/j.jempfin.2020.06.004 (“HLW”).
  3. Pang, T., Du, L. & Chong, T. T. L. (2021). Estimating multiple breaks in nonstationary autoregressive models. Journal of Econometrics, 221(1), 277–311 (“PDC”).
  4. Kurozumi, E. & Skrobotov, A. (2023). On the asymptotic behavior of bubble date estimators. Journal of Time Series Analysis, 44(4), 359–373 (“KS”).

HLS (2017): single-bubble SSR + BIC dating

HLS defines four regime-structure models for a series yty_t. Each is a piecewise OLS regression of Δyt\Delta y_t on regime dummies and dummy-interacted yt−1y_{t-1} (p. 7), with Dt(a,b)=1(⌊aT⌋<t≤⌊bT⌋)D_t(a,b) = \mathbf 1(\lfloor aT \rfloor < t \le \lfloor bT \rfloor):

Model 1:Δyt=μ1Dt(τ1,1)+δ1Dt(τ1,1) yt−1+v1tunit root, then a bubble to the sample endModel 2:Δyt=μ1Dt(τ1,τ2)+δ1Dt(τ1,τ2) yt−1+v2tunit root, bubble, unit rootModel 3:Δyt=μ1Dt(τ1,τ2)+μ2Dt(τ2,1)+δ1Dt(τ1,τ2) yt−1+δ2Dt(τ2,1) yt−1+v3tunit root, bubble, collapse to the endModel 4:Δyt=μ1Dt(τ1,τ2)+μ2Dt(τ2,τ3)+δ1Dt(τ1,τ2) yt−1+δ2Dt(τ2,τ3) yt−1+v4tunit root, bubble, collapse, unit root\begin{aligned} \text{Model 1:}\quad & \Delta y_t = \mu_1 D_t(\tau_1,1) + \delta_1 D_t(\tau_1,1)\,y_{t-1} + v_{1t} && \text{unit root, then a bubble to the sample end}\\ \text{Model 2:}\quad & \Delta y_t = \mu_1 D_t(\tau_1,\tau_2) + \delta_1 D_t(\tau_1,\tau_2)\,y_{t-1} + v_{2t} && \text{unit root, bubble, unit root}\\ \text{Model 3:}\quad & \Delta y_t = \mu_1 D_t(\tau_1,\tau_2) + \mu_2 D_t(\tau_2,1) + \delta_1 D_t(\tau_1,\tau_2)\,y_{t-1} + \delta_2 D_t(\tau_2,1)\,y_{t-1} + v_{3t} && \text{unit root, bubble, collapse to the end}\\ \text{Model 4:}\quad & \Delta y_t = \mu_1 D_t(\tau_1,\tau_2) + \mu_2 D_t(\tau_2,\tau_3) + \delta_1 D_t(\tau_1,\tau_2)\,y_{t-1} + \delta_2 D_t(\tau_2,\tau_3)\,y_{t-1} + v_{4t} && \text{unit root, bubble, collapse, unit root} \end{aligned}

For each model the break fractions (τ^1,τ^2,τ^3)(\hat\tau_1, \hat\tau_2, \hat\tau_3) jointly minimise the model’s residual sum of squares SSRj\mathrm{SSR}_j over all candidate dates that satisfy the ordering and sign constraints. The bubble phase must be upward, for example. Theorem 1 shows that ⌊τ^iT⌋−⌊τi,0T⌋→p0\lfloor \hat\tau_i T \rfloor - \lfloor \tau_{i,0} T \rfloor \to_p 0 for each correctly paired DGP and model. With a fixed-magnitude bubble the estimator is therefore consistent for the exact date and not only for the break fraction.

The four models are compared by a BIC whose penalty counts the fitted coefficients plus the estimated break dates (p. 8):

BIC1=Tln⁡{T−1SSR1(τ^1,1)}+(2+1)ln⁡T,BIC2=Tln⁡{T−1SSR2(τ^1,τ^2)}+(2+2)ln⁡T,BIC3=Tln⁡{T−1SSR3(τ^1,τ^2,1)}+(4+2)ln⁡T,BIC4=Tln⁡{T−1SSR4(τ^1,τ^2,τ^3)}+(4+3)ln⁡T,jopt=arg⁡min⁡jBICj.\begin{aligned} \mathrm{BIC}_1 &= T \ln\{T^{-1}\mathrm{SSR}_1(\hat\tau_1, 1)\} + (2+1)\ln T,\\ \mathrm{BIC}_2 &= T \ln\{T^{-1}\mathrm{SSR}_2(\hat\tau_1, \hat\tau_2)\} + (2+2)\ln T,\\ \mathrm{BIC}_3 &= T \ln\{T^{-1}\mathrm{SSR}_3(\hat\tau_1, \hat\tau_2, 1)\} + (4+2)\ln T,\\ \mathrm{BIC}_4 &= T \ln\{T^{-1}\mathrm{SSR}_4(\hat\tau_1, \hat\tau_2, \hat\tau_3)\} + (4+3)\ln T, \end{aligned} \qquad j_{\mathrm{opt}} = \arg\min_j \mathrm{BIC}_j .

In practice (Section 5) HLS impose minimum regime durations, τ1≥s\tau_1 \ge s, τ2−τ1≥s\tau_2 - \tau_1 \ge s and τ3−τ2≥s/2\tau_3 - \tau_2 \ge s/2, with s=0.1s = 0.1 in the T=200T = 200 simulations and s=0.05s = 0.05 in the T=389T = 389 application. Beyond that the method is a brute-force grid search over one, two or three breakpoints, depending on the model.

HLW (2020): two-step extension to multiple bubbles

HLS’s four-model set grows combinatorially with the number of bubbles, and PSY’s end dates are known to be biased late. HLW therefore combine the two (p. 9).

  • Step 1. Run PSY’s GSADF/BSADF detection and dating as it stands, which gives preliminary start and end fractions for each of the N^\hat N detected bubbles. These split the sample into N^\hat N disjoint date windows [sj,ej][s_j, e_j]. Windows are split at the midpoint between consecutive PSY regimes, with a rule that ensures that a window starts inside a post-explosive (unit-root) regime and never in the middle of a bubble.
  • Step 2. Apply HLS’s Model 1–4 procedure independently within each window. For every window but the last, only Models 2 and 4 are allowed, because a window boundary is a unit-root point and not a sample end.

The authors describe this as a refinement of PSY’s output: “we propose a dating methodology based on minimum sum of squared residual estimators and BIC model selection, but using prior information gleaned from the PSY dating procedure as a means of reducing the dimensionality” (HLW, Section 1).

PDC (2021) and KS (2023): sequential sample-splitting

PDC take the same SSR-minimisation idea for a single bubble episode and make it much cheaper. Their model has three regimes (unit root, explosive, stationary collapse) with breaks τ1,0<τ2,0\tau_{1,0} < \tau_{2,0}. A stochastic-order argument (their Example 3 and Lemmas A.2–A.4) shows that under the bubble DGP the collapse date is identified first, because the drop in SSR at the collapse dominates the drop at the origination. The two breaks can therefore be estimated one after the other and not jointly (PDC p. 9):

Step 1:τ^2=arg⁡min⁡τ∈(0,1)RSS2,T(τ),RSS2,T(τ)=∑t≤⌊τT⌋(yt−β^x(τ) yt−1)2+∑t>⌊τT⌋(yt−β^3(τ) yt−1)2,β^x(τ)=∑t≤⌊τT⌋yt yt−1∑t≤⌊τT⌋yt−12,Step 2:repeat the one-break minimisation on the left sub-sample [1,τ^2T] to get τ^1.\begin{aligned} \text{Step 1:}\quad & \hat\tau_2 = \arg\min_{\tau \in (0,1)} \mathrm{RSS}_{2,T}(\tau),\\ & \mathrm{RSS}_{2,T}(\tau) = \sum_{t \le \lfloor \tau T \rfloor} \bigl(y_t - \hat\beta_x(\tau)\, y_{t-1}\bigr)^2 + \sum_{t > \lfloor \tau T \rfloor} \bigl(y_t - \hat\beta_3(\tau)\, y_{t-1}\bigr)^2,\\ & \hat\beta_x(\tau) = \frac{\sum_{t \le \lfloor \tau T \rfloor} y_t\, y_{t-1}}{\sum_{t \le \lfloor \tau T \rfloor} y_{t-1}^2},\\ \text{Step 2:}\quad & \text{repeat the one-break minimisation on the left sub-sample } [1, \hat\tau_2 T] \text{ to get } \hat\tau_1 . \end{aligned}

The model has no intercept and one AR(1) coefficient per regime, unlike HLS’s intercept and dummies. Each β^(τ)\hat\beta(\tau) is a ratio of two prefix sums, so the whole RSS(τ)\mathrm{RSS}(\tau) curve costs O(T)O(T). There is no joint grid search and no BIC step. The algorithm always assumes that the three-regime structure holds.

KS add a fourth regime, a unit-root recovery after the stationary collapse, and reuse the sequential logic for the extra break. They contrast their cost with that of HLS: “we perform the three SSR minimization with one break each with O(T) computations, while Harvey et al. (2017) requires minimizing the three break model over all possible combinations of these breaks” (KS, Section 1). They also suggest running their method inside each HLW date window in place of HLS’s joint fit.

Published numbers

HLS Table 1 (Nasdaq composite real price index, 1973:2–2005:6, the series of PWY):

SamplePSY testPSY startPSY endBIC modelBIC startBIC end
1973:2–2005:6 (full)3.07***1998:112000:1231998:112000:9
1973:2–2000:9 (pseudo-real-time)3.07***1998:112000:912000:12000:9
1973:2–2000:103.07***1998:112000:1012000:12000:10
1973:2–2000:113.07***1998:112000:1111999:122000:11
1973:2–2000:123.07***1998:112000:1231998:112000:9
1973:2–2001:13.07***1998:112000:1231998:112000:9

This is the one concrete head-to-head between the two dating rules in HLS. Both agree on the 1998:11 start, and PSY’s end date (2000:12) is three months later than the BIC end date (2000:9).

HLS’s Monte Carlo comparison (Section 6, Figures 1–3) and the HLW simulations (Section 4, Figures 1–6) are reported only as plots of frequencies. The text states that the BIC method outperforms PSY in finite samples, particularly for the end date, and that PSY estimates “typically fall somewhat later than the true date”, but gives no percentages. These results are therefore recorded as qualitative claims.

HLW Table 1 (BIC model-selection frequencies across six DGPs, A–F):

DGPRegimeModel 1Model 2Model 3Model 4True model
Aj=10.0070.1330.1280.7324
Aj=20.0330.0620.4510.4544
Bj=10.0130.1270.1340.7264
Bj=20.0260.0600.6330.2813
Cj=10.0490.7650.0800.1062
Cj=20.0890.1760.2990.4364
Dj=10.2850.6840.0090.0222
Dj=20.6270.3430.0160.0141
Ej=10.0060.0270.0010.9664
Ej=20.1560.0390.0220.7824
Ej=30.6420.0150.0020.3421
Fj=10.0020.0880.0270.8834
Fj=20.0180.0950.2520.6354
Fj=30.0140.0610.5240.4013

BIC picks the true model most often in every row. The weakest cases are DGP A, regime 2, and DGP C, regime 2, where the correct-model rate is about 44–45%. A reversion to a unit root close to the end of the window is easily missed, as the paper’s own caveat says. This is the frequency of correct model identification and not a measure of dating accuracy. The paper notes that choosing the wrong model, for example Model 3 in place of Model 4, still typically gives accurate break estimates.

KS empirical application (Section 6, in prose). NASDAQ Composite, monthly, January 1985 to August 2013: BIC4=3387.727\mathrm{BIC}_4 = 3387.727 beats BIC3=3404.268\mathrm{BIC}_3 = 3404.268 and BIC2=3409.296\mathrm{BIC}_2 = 3409.296, so the four-regime model is chosen. The collapse is dated February 2000, the origination (from the left sub-sample) August 1998 and the recovery (right sub-sample) September 2001. US real house price index (FHFA), January 1991 to December 2012: BIC4=−2918.223\mathrm{BIC}_4 = -2918.223 is chosen again, with collapse in November 2006, origination in September 1997 and recovery in May 2011.

KS Monte Carlo (Section 5) is shown as histograms. The prose gives only approximate figures: the frequency of selecting the true break date “is around 30% for T=400 and 65% for T=800”, and other quantities are “close to 100%” or “approximately 75% and 100% for T=400 and 800”. They should be read as orders of magnitude.

How the methods relate to datestamp()

datestamp() (exuber/R/radf-methods.R, with the helpers stamp(), stamp_to_index() and add_peak()) post-processes statistics that radf() has already computed. It compares the recursive bsadf or badf sequences with critical values and finds contiguous runs of exceedance. No regression is fitted in that path.

The SSR-based routes are different in kind. They need new regime-dummy or no-intercept AR(1) fitting code and a minimum-regime-duration trimming parameter (s in HLS/HLW). The HLS/HLW route also needs a BIC comparison and a search over one to three breakpoints. The PDC/KS route is smaller, because each break is a single O(T)O(T) scan of a closed-form ratio of cumulative sums, but it takes the number of regimes (three or four) as given. It cannot tell a bubble that collapses from one that is still running at the sample end. That distinction is what the model selection of HLS/HLW provides.

Implementation: HLS route

dating_hls(data, trim = 0.05) is in exuber/R/dating_hls.R and is tested in exuber/tests/testthat/test-hls.R. It fits all four models, each by exact SSR minimisation over its candidate breakpoints, subject to the minimum-regime trim and the sign constraints. It selects among them by BIC, nlog⁡(SSR/n)+df log⁡nn \log(\mathrm{SSR}/n) + \mathrm{df}\,\log n with df∈{3,4,6,7}\mathrm{df} \in \{3, 4, 6, 7\} for Models 1–4. The function returns the selected model and its origination, collapse and recovery dates (NA for any that the model does not have), plus the BIC of every candidate.

The sign constraints follow HLW’s statement of HLS’s models. Model 1 requires yT>yτ1y_T > y_{\tau_1}. Models 3 and 4 require the fitted peak yτ2y_{\tau_2} to exceed both the level at the start of the bubble and the level reached after the collapse regime (yTy_T for Model 3, yτ3y_{\tau_3} for Model 4).

Algorithm. The regime dummies of the four models never overlap, so the SSR of any candidate partition is the sum of independent per-segment OLS fits. A segment with no active dummy has no fitted parameters (SSR=∑Δyt2\mathrm{SSR} = \sum \Delta y_t^2), and a segment with an active dummy is an intercept-and-slope fit. Both are closed-form ratios of cumulative sums (Sx,Sxx,Sz,Szz,SxzS_x, S_{xx}, S_z, S_{zz}, S_{xz}), so each candidate breakpoint, pair or triple is evaluated in O(1)O(1) from precomputed prefix sums with no repeated lm() calls. The search takes under two seconds at T=400T = 400, in plain R.

Validation. hls_segment_ssr() matches the SSR of a brute-force lm() fit (tolerance 10−810^{-8}) for arbitrary segments, and the full joint three-breakpoint search of Model 4 matches an exhaustive nested-lm() search bit for bit on a small synthetic series. The Monte Carlo runs use DGPs built on a large positive base level (100 + cumsum(rnorm(...))), so that the explosive signal is a large departure from noise on the absolute scale.

  • True Model 4 DGP (unit root, bubble, mean-reverting collapse, unit-root recovery): BIC selects Model 3 or 4 in 100% of 30 replications, split 80% and 20%. Telling a final recovery regime from a continued collapse is the hardest case, which is consistent with the correct-model rate of 44–45% in HLW’s weakest DGP. The mean absolute bias is about 0 observations for the origination and about 1 for the collapse.
  • True Model 2 DGP: Model 2 is selected in 100% of 30 replications, with origination bias exactly 0.
  • True Model 1 DGP: Model 1 is selected in 100% of 30 replications, with origination bias exactly 0.
  • Pure H0H_0 (no bubble): BIC never selects Model 4 (0% across 30 replications) and splits between the three simpler models (30% Model 1, 57% Model 2, 13% Model 3). The method has no “no bubble” option, since it is designed to run downstream of a PSY-detected episode.

Replication script: replication/dating-and-root-inference/radf_hls_validation.R.

Implementation: HLW route

dating_hlw(data, cv = NULL, minw = NULL, trim = 0.1, min_duration = NULL, nboot = 199L, seed = NULL, join = 3L) is in exuber/R/dating_hlw.R and is tested in exuber/tests/testthat/test-hlw.R. It wraps dating_hls() in HLW’s two steps.

  1. Run PSY’s detection and dating (radf() and datestamp(), with min_duration defaulting to psy_ds(n), HLW’s own ln⁡T\ln T minimum-episode rule) to get a start and end position for each detected episode.
  2. Carve the sample into disjoint date windows. The end of window jj is the midpoint between that episode’s PSY end and the next episode’s PSY start, ej=τ2PSY[j]+⌊(τ1PSY[j+1]−τ2PSY[j])/2⌋e_j = \tau_2^{\mathrm{PSY}}[j] + \lfloor (\tau_1^{\mathrm{PSY}}[j+1] - \tau_2^{\mathrm{PSY}}[j]) / 2 \rfloor, and the last window runs to the sample end. Each window is fitted with hls_fit_series(), the shared helper of dating_hls(), restricted to Models 2 and 4 for every window but the last. After window jj is fitted, HLW’s sequential-adjustment rule moves the start of the next window to the first observation of the fitted post-explosive regime (sj+1=sj+τ2,locals_{j+1} = s_j + \tau_{2,\mathrm{local}} for a Model 2 fit and sj+τ3,locals_j + \tau_{3,\mathrm{local}} for Model 4), so that a later window cannot start inside a bubble.

A series with no detected episode returns an empty result and no error.

Run-joining. PSY’s detection can split one true bubble into several runs. HLW’s rule treats up to three non-rejections surrounded on both sides by an explosive regime of length ln⁡T\ln T as a single episode. join = 3L applies it to the regimes from datestamp() before the windows are built (hlw_join_runs() in R, _join_runs() in pyexuber, unit-tested on identical cases). join = 0 switches it off. Gaps wider than three non-rejections are not joined, by design.

Validation.

  • On a synthetic two-bubble DGP (20 replications), step 1 finds exactly two windows in 14 of 20 replications with the run-joining rule, and in 13 of 20 without it. In the clean replications the origination and collapse bias for both bubbles is exactly 0. Windows are correctly ordered and do not overlap in all 20.
  • Under a pure H0H_0 null, dating_hlw() returns no windows in all 20 replications.
  • On a single clean bubble (15 replications), the final window matches standalone dating_hls() on the whole series exactly (model, origination and collapse) in every replication that has a final window (12 of 15). The paper states that the two-step procedure reduces to HLS when there is a single episode.

Replication script: replication/dating-and-root-inference/radf_hlw_validation.R.

Implementation: PDC/KS route

dating_pdc(data, regimes = 3L, trim = 0.05) is in exuber/R/dating_pdc.R and is tested in exuber/tests/testthat/test-pdc.R. The helper pdc_find_break(y, trim) minimises the single-break no-intercept AR(1) RSS from prefix sums of ∑ytyt−1\sum y_t y_{t-1} and ∑yt−12\sum y_{t-1}^2. dating_pdc() applies it in PDC’s order (collapse first, then origination on the left sub-sample) and once more on the right sub-sample for KS’s recovery date when regimes = 4.

Validation.

  1. pdc_find_break() matches a brute-force scan that fits lm(y[t] ~ y[t-1] - 1) on every candidate split, bit for bit (tolerance 10−810^{-8}).
  2. On a synthetic three-regime series (unit root, explosive, collapse) and a four-regime series (with a recovery), dating_pdc() recovers the true breaks to within one or two observations in the low-noise, long-series limit. The collapse regime in these tests is a stationary AR(1) (ρ=0.5\rho = 0.5). A deterministic exponential decay would be a poor test DGP, because its flat tail is indistinguishable from the random walk of the recovery regime.
  3. At moderate TT (T=350T = 350, 30 seeds) the exact-date recovery rate is 3.3% for the origination and 0% for the collapse, with mean absolute errors of 5.5 and 1.0. This matches the Monte Carlo of KS (Section 5), which reports about 30% exact-date recovery at T=400T = 400 and about 65% at T=800T = 800.

The full suite has 13 assertions for this function, all passing. Replication script: replication/dating-and-root-inference/radf_pdc_validation.R.


Root inference

Status: done. rootstamp() in exuber/R/rootstamp.R is one S3 generic. The default method handles a single sub-sample and the radf_obj method handles every episode of a datestamp() result at once. Tests are in exuber/tests/testthat/test-rootstamp.R.

Sources

  • Phillips, P. C. B. & Magdalinos, T. (2007). Limit theory for moderate deviations from a unit root. Journal of Econometrics, 136(1), 115–130. Working paper: Cowles Foundation DP 1471, open at cowles.yale.edu/sites/default/files/2022-08/d1471.pdf.
  • Guo, G., Sun, Y. & Wang, S. (2019). Testing for moderate explosiveness. The Econometrics Journal, 22(3), 279–303.
  • Skrobotov, A. (2023). Testing for explosive bubbles: a review. arXiv:2207.08249, which restates both results.

Two results

  1. Phillips & Magdalinos (2007), Cauchy limit. For a mildly explosive AR(1) with ρn=1+c/nα\rho_n = 1 + c/n^\alpha, c>0c > 0 and α∈(0,1)\alpha \in (0,1), Theorem 4.3 (their eq. 26) gives

    nαρnn2c (ρ^n−ρn)⇒C,\frac{n^\alpha \rho_n^n}{2c}\,(\hat\rho_n - \rho_n) \Rightarrow C,

    with CC standard Cauchy, also under non-Gaussian errors. Remark (i) (eq. 27) gives the simpler fixed-root case of White (1958), which needs neither α\alpha nor cc:

    ρnρ2−1 (ρ^n−ρ)⇒C.\frac{\rho^n}{\rho^2 - 1}\,(\hat\rho_n - \rho) \Rightarrow C .

    Replacing ρ\rho by ρ^\hat\rho in the normalisation gives the two-sided interval

    ρ^±qα/2 ρ^2−1ρ^n,\hat\rho \pm q_{\alpha/2}\,\frac{\hat\rho^2 - 1}{\hat\rho^n},

    where qα/2q_{\alpha/2} is a standard Cauchy quantile (qcauchy(), or equivalently qt(., df = 1)). The interval of eq. 26 needs an estimate of α\alpha, and is not implemented.

  2. Guo, Sun & Wang (2019), normal limit. With a drift term allowed, the ordinary regression tt-statistic for ρT\rho_T is asymptotically standard normal under i.i.d. errors (Student’s tt or HAR under dependence). It does not require cc, kTk_T or the rate, so it is the simpler interval and the default.

Interface

rootstamp() fits the no-intercept AR(1) regression of the Phillips–Magdalinos model on a sub-sample, for example an episode already identified by datestamp(). It reports

  • rho and rho_ci, the point estimate and the Wald interval (ρ^±z se\hat\rho \pm z\,\mathrm{se}, with the standard error from the no-intercept OLS fit), or the Cauchy interval with type = "cauchy";
  • doubling_time and doubling_time_ci, log⁡2/log⁡ρ^\log 2 / \log\hat\rho, the number of periods for the series to double at the estimated rate. The interval comes from transforming the endpoints of the ρ\rho interval, and the bounds flip because the doubling time decreases in ρ\rho.

The Cauchy interval assumes a fixed explosive root, while the normal-tt interval allows drift and dependence. Root inference on very short episodes (duration 1 or 2) is as unreliable as fitting a regression to two or three points. Use the min_duration argument of datestamp() to filter them.

Also relevant background: Phillips, Magdalinos & Giraitis (2010), J. Econometrics 158(2), 274–279, show that moderate-deviation theory joins the local-to-unity case smoothly as α→0\alpha \to 0. It would be the reference for roots close to the local-to-unity boundary.

Validation

  • Cauchy percentiles. The standard Cauchy distribution is Student’s tt with one degree of freedom, so the percentiles of Skrobotov’s footnote 17 can be checked exactly: qt(0.95, 1) = 6.313752, qt(0.975, 1) = 12.7062 and qt(0.995, 1) = 63.65674, against 6.3156.315, 12.712.7 and 63.6567463.65674 in the footnote. They are tested in test-rootstamp.R.
  • Cauchy interval. It brackets the point estimate and matches the closed-form eq. 27 formula exactly.
  • Point estimate. On an explosive AR(1) with ρ=1.05\rho = 1.05 and n=200n = 200, rootstamp() recovers ρ^=1.0500\hat\rho = 1.0500. The confidence interval is indistinguishable from the point estimate at that precision. This is the super-consistency of explosive-root estimation: the regressor yt−1y_{t-1} grows geometrically, so ∑yt−12\sum y_{t-1}^2 grows at rate ρ2n\rho^{2n} and the standard error collapses much faster than in the unit-root or stationary case.
  • Coverage. For ρ=1.03\rho = 1.03 and an episode of 149 observations, 500 replications give about 90% coverage for a nominal 95% interval. For ρ=1.05\rho = 1.05 and n=200n = 200, 800 replications give 94.6%. Undercoverage is a finite-sample effect of a T→∞T \to \infty result for an estimator that converges slowly, and it shrinks as nn or ρ−1\rho - 1 grows. The package test asserts a loose bound (above 80%) and not the nominal rate.

Replication script: replication/dating-and-root-inference/rootstamp_validation.R.


Confidence sets for bubble dates

Status: evaluated, not implemented.

Source

Kurozumi, E. & Skrobotov, A. (2025). Confidence Sets for the Emergence, Collapse, and Recovery Dates of a Bubble. arXiv:2511.16172.

Idea

The paper builds a confidence interval for estimated dates. It is the dating analogue of rootstamp(), and not a new detection method. It does not use the limiting distribution of the break-date estimator, which the authors found performs poorly for bubble dates. Instead, it inverts hypothesis tests on the break location: a likelihood-ratio-type test (Eo & Morley 2015) and Elliott–Müller-type tests (2007), used separately and combined. New limiting null and alternative distributions are derived for each and evaluated by Monte Carlo. The emergence, collapse and recovery dates are estimated separately.

Ingredients

  • The “12”-direction tests LRa,12e\mathrm{LR}^e_{a,12} and EMa,12e\mathrm{EM}^e_{a,12} (eq. 15–16) have closed-form critical values, cvLR12,0.05e=λ1 χ1,0.052cv^e_{\mathrm{LR}12, 0.05} = \lambda_1\, \chi^2_{1, 0.05} and cvEM12,0.05e=λ1 χ1,0.052cv^e_{\mathrm{EM}12, 0.05} = \sqrt{\lambda_1\, \chi^2_{1,0.05}}.

  • The “21”-direction tests (LRa,21e\mathrm{LR}^e_{a,21}, EMa,21e\mathrm{EM}^e_{a,21}, EMb,21e\mathrm{EM}^e_{b,21}, eq. 17–19) have no closed form, but the paper publishes a response-surface regression for their critical values, cv=a0,ℓ+a−1,ℓ/λ1∗+a1,ℓλ1∗+a2,ℓλ1∗2+a3,ℓλ1∗3cv = a_{0,\ell} + a_{-1,\ell}/\lambda_1^* + a_{1,\ell}\lambda_1^* + a_{2,\ell}\lambda_1^{*2} + a_{3,\ell}\lambda_1^{*3}, with coefficients in its Table 1.

  • The recommended "LEe\mathrm{LE}^e" test combines LRb,12e\mathrm{LR}^e_{b,12} with EMa,21e\mathrm{EM}^e_{a,21}, because LRa,12e\mathrm{LR}^e_{a,12} alone is over-sized in finite samples. LRb,12e\mathrm{LR}^e_{b,12} (eq. 11) is a minimum over candidate break dates of

    yT22−ρ^a∑t=T1+1T2yt−12T ϕ^a2(T2−T1) σ^2/2,\frac{y^2_{T_2} - \hat\rho_a \sum_{t=T_1+1}^{T_2} y^2_{t-1}}{T\, \hat\phi_a^{2(T_2 - T_1)}\, \hat\sigma^2 / 2},

    whose numerator follows the prefix-sum pattern of hls_prefix_sums(). EMa,21e\mathrm{EM}^e_{a,21} (eq. 18) is an integral over a continuum of candidate break points of an ADF(λ2∗,λ1∗)\mathrm{ADF}(\lambda_2^*, \lambda_1^*) functional, which has no counterpart in exuber and is not a discrete search.

The construction needs the estimators ρ^a\hat\rho_a, ϕ^a\hat\phi_a and σ^2\hat\sigma^2 and the admissible-date set Λ12e\Lambda^e_{12} from Section 2 of the paper, and then the integral of EMa,21e\mathrm{EM}^e_{a,21}, repeated for three dates. The work is of the scale of HLS/HLW.


Improved retrospective dating

Status: done, as dating_knp(breaks = ), covering the single-bubble correction and the multi-bubble dynamic programme of Section 3.

Source

Kejriwal, M., Nguyen, L. & Perron, P. (2025). An Improved Procedure for Retrospectively Dating the Emergence and Collapse of Bubbles. Journal of Time Series Analysis, 46(5), 867–883, doi:10.1111/jtsa.12810.

Idea

KNP fix a bias in the joint-SSR estimator of the HLS family. Their model uses HLS’s fixed autoregressive coefficient (not the mildly explosive ρT→1\rho_T \to 1 of Phillips–Magdalinos) with an abrupt collapse. Theorem 1 shows that the standard joint-SSR estimator is inconsistent. The origination estimate converges to the collapse date, and the collapse estimate converges to a date after the true collapse, offset by the trimming parameter. The fix (Theorem 2) is a modified SSR that omits the single residual at the implosion date, which restores consistency of both dates. A footnote shows that the omission is numerically equivalent to a one-time dummy in HLS’s Model 4 regression.

Section 3 adds a Bai–Perron/Perron–Qu-style dynamic programme for several bubbles. The unit-root regimes are restricted (μ=0\mu = 0, ρ=1\rho = 1), so each segment cost is known in closed form and the iteration over initial values that Perron–Qu need is not required.

KNP’s single-bubble model has the shape of HLS’s Model 2: an unfitted unit root, an explosive regime fitted with intercept and slope, and an unfitted unit root after an instantaneous collapse. Regressing the level yty_t on yt−1y_{t-1} with an intercept gives the same residuals and SSR as regressing Δyt\Delta y_t on yt−1y_{t-1} (the slope shifts by one), which is the regression that hls_segment_ssr() computes. The whole correction is

SSRom(T1,T2)=SSR(T1,T2)−(ΔyT2+1)2,\mathrm{SSR}_{\mathrm{om}}(T_1, T_2) = \mathrm{SSR}(T_1, T_2) - (\Delta y_{T_2 + 1})^2,

an already computed SSR minus one squared term.

Implementation

dating_knp(data, trim = 0.05, omit = TRUE, breaks = 2L) is in exuber/R/dating_knp.R and is tested in exuber/tests/testthat/test-knp.R. knp_find_break() reuses hls_prefix_sums() and hls_segment_ssr() and searches (τ1,τ2)(\tau_1, \tau_2) jointly to minimise the omission-corrected SSR. With omit = FALSE it minimises the plain SSR, which is inconsistent, so that the effect of the correction can be shown. Unlike hls_model23(), the candidate set has no sign constraint on the fitted peak.

breaks is the paper’s mm: two per bubble, or an odd number to let the last bubble run to the sample end (its collapse is NA). As in the paper, mm is taken as given, because KNP leave its selection open. breaks = 2 keeps the exhaustive single-bubble search. For more breaks knp_dp() runs the dynamic programme with the objective of their eq. 11. Regimes alternate between unit root and explosive, starting with a unit root, and every unit-root regime after the first omits its first residual. Each segment cost is ∑z2\sum z^2 over the segment (minus its first term after a collapse when omit = TRUE) or the intercept-and-slope OLS SSR (hls_segment_ssr(..., fit = TRUE)). The programme is O(mT2)O(mT^2) with O(1)O(1) segment costs and returns the exact global minimiser of the grid search. With more breaks, origination, collapse and delta are matrices with one row per bubble.

Validation

The formula check is exact. omit = FALSE and omit = TRUE both match an exhaustive nested-lm() search, and knp_dp(y, 2) returns the same dates and SSR as the single-bubble search (∣ΔSSR∣=0|\Delta \mathrm{SSR}| = 0). With three and four breaks it matches a brute-force search over every admissible partition, with and without omission (n=28n = 28, ∣ΔSSR∣=2.7×10−14|\Delta\mathrm{SSR}| = 2.7 \times 10^{-14}).

The Monte Carlo uses KNP’s DGP (unit root, no-intercept explosive AR(1), instantaneous collapse back near the pre-bubble level, fresh unit root).

  • Single bubble (T=200T = 200, T1=50T_1 = 50, T2=90T_2 = 90, δ=1.05\delta = 1.05, 30 replications). Theorem 1 is reproduced for the naive estimator: the mean error of the origination estimate against the true collapse date is mean⁡∣τ^1−T2∣=1.0\operatorname{mean}|\hat\tau_1 - T_2| = 1.0, far below its error against its own origination, mean⁡∣τ^1−T1∣=39.0\operatorname{mean}|\hat\tau_1 - T_1| = 39.0. With the omission the latter falls from 39.0 to 13.0 observations. The residual bias at T=200T = 200 is expected, because Theorem 2 is an asymptotic result. The collapse date and the explosive coefficient are close to their true values (mean⁡∣τ^2−T2∣=1.0\operatorname{mean}|\hat\tau_2 - T_2| = 1.0, mean δ^=0.986\hat\delta = 0.986 against a true 1.051.05).
  • Two bubbles (T=200T = 200, bubbles over 41–70 and 121–150, δ=1.05\delta = 1.05, 50 replications). The mean absolute date error per break (origination 1, collapse 1, origination 2, collapse 2) is 11.9/5.9/10.3/3.811.9 / 5.9 / 10.3 / 3.8 observations with the omission correction and 33.4/18.5/29.6/13.233.4 / 18.5 / 29.6 / 13.2 without it. The inconsistency of Theorem 1 carries over to several bubbles, and so does the fix.

dating_knp() is also in pyexuber (_knp_dp()), where it agrees with R and with the same brute force. Replication script: replication/dating-and-root-inference/radf_knp_validation.R.


WLS dating under time-varying volatility

Status: done, as dating_pdc(..., type = "wls").

Source

Kurozumi, E. & Skrobotov, A. (2023). Improving the accuracy of bubble date estimators under time-varying volatility. arXiv:2306.02977.

Idea

The estimator is a two-step generalisation of the PDC/KS sequential estimator. Step 1 is dating_pdc() as described above: fit the homoskedastic no-intercept AR(1) break model and keep the residuals. Step 2 estimates the time-varying error variance σt2\sigma_t^2 nonparametrically from those residuals and re-estimates each break by minimising a weighted SSR,

∑t(yt−a yt−1)2σt2,\sum_t \frac{(y_t - a\, y_{t-1})^2}{\sigma_t^2},

in place of the unweighted sum. This is the cumulative-sum construction of pdc_find_break() with each yty_t, ytyt−1y_t y_{t-1} and yt−12y_{t-1}^2 divided by σt2\sigma_t^2 before the prefix sum, so it remains closed form and O(T)O(T) per break.

Implementation

dating_pdc(data, ..., type = c("ols", "wls")) is in exuber/R/dating_pdc.R. type = "ols" is the original estimator. type = "wls" does the following.

  1. Run the sequential type = "ols" fit to get the first-step breaks.
  2. pdc_regime_resid(y, breaks) computes the fitted no-intercept AR(1) residual at every pair (yt−1,yt)(y_{t-1}, y_t), with one OLS ρ\rho per regime implied by those breaks.
  3. nw_spot_vol(), a Nadaraya–Watson kernel smoother with a leave-one-out cross-validated bandwidth, turns the residuals into σ^t2\hat\sigma_t^2. It is the smoother shared with SBZ, which kernel_spot_vol(y) calls as nw_spot_vol(diff(y)).
  4. pdc_find_break() takes an optional weights argument (NULL gives the unweighted search). Each cumulative sum is multiplied by the weights before the prefix sum.
  5. The sequential search (collapse, then origination, then recovery) runs again with weights = 1 / sigma_t^2, using the matching slice of the full-sample variance vector for each sub-sample.

No critical values are needed, because this is point estimation and not a threshold-crossing test.

Validation

Two Monte Carlo checks with 40 seeds each.

  • Homoskedastic DGP. The origination-date mean absolute error is 5.42 for OLS and 5.53 for WLS. Weighting costs essentially nothing when there is no heteroskedasticity to exploit.
  • Volatility burst in the first 20% of the pre-bubble regime, the scenario with the largest gains in the paper’s Monte Carlo. The origination-date mean absolute error falls from 13.05 (OLS) to 2.33 (WLS), about 5.6 times lower. The unweighted objective lets the noisy early segment dominate the origination split, and WLS downweights it. The collapse date is near-exact in both cases, because the explosive-to-collapse transition dominates the SSR whatever happens earlier, as PDC’s stochastic-order argument says.

A test in test-pdc.R asserts a loose version of this margin (a factor of 2).

Replication scripts: replication/dating-and-root-inference/radf_pdc_wls_heteroskedastic_mae.R, radf_pdc_wls_homoskedastic_mae.R.


Reverse-regression recovery dating

Status: done, as radf_recovery() and radf_recovery_cv(), with the caveats listed under Validation. The recovery date frf_r behaves well. The crisis-origination date fcf_c and the false-detection rate under the null are noisier.

Source

Phillips, P. C. B. & Shi, S. (2014). Financial Bubble Implosion. Cowles Foundation DP 1967, published as Financial Bubble Implosion and Reverse Regression, Econometric Theory.

Idea

Reverse the series, Xt∗=XT+1−tX^*_t = X_{T+1-t}, run the same BSDF/BSADF recursion that PSY uses on X∗X^*, and map the crossing fractions back to the original time index (their eq. 8–9):

f^r=1−g^e,g^e=inf⁡{g∈[g0,1]:BSDFg(g0)>scv}(recovery date),f^c=1−g^c,g^c=inf⁡{g∈[g^e,1]:BSDFg(g0)<scv}(crisis-origination date).\begin{aligned} \hat f_r &= 1 - \hat g_e, & \hat g_e &= \inf\{ g \in [g_0, 1] : \mathrm{BSDF}_g(g_0) > \mathrm{scv} \} && \text{(recovery date)},\\ \hat f_c &= 1 - \hat g_c, & \hat g_c &= \inf\{ g \in [\hat g_e, 1] : \mathrm{BSDF}_g(g_0) < \mathrm{scv} \} && \text{(crisis-origination date)}. \end{aligned}

Because g^c\hat g_c is searched only after g^e\hat g_e, f^c≤f^r\hat f_c \le \hat f_r always. f^c\hat f_c is the collapse-onset date of the original series, derived by reverse regression as an alternative to the collapse date that PSY’s forward test already gives, and f^r\hat f_r follows it. Reversing a mildly explosive process followed by a mildly integrated collapse turns the collapse regime into an explosive regime in reverse time, so recovery and crisis dating become the right-tailed test of PSY applied to rev(x).

Theorem 1 shows that the null limit of the reverse statistic, Fg(W,g0)F_g(W, g_0), is not the forward distribution Ff(W,f0)F_f(W, f_0). Reversing a random walk makes the reversed lagged regressor correlated with the reversed current error (E[XT−j+2∗ εT−j+2]≠0E[X^*_{T-j+2}\,\varepsilon_{T-j+2}] \ne 0), which changes the critical values even under the null. A paired Monte Carlo (n=100n = 100, minw = 20, 5000 replications) comparing radf_mc_cv() with the same recursion on the reversed path gives critical values that differ by about 0.04 on average and up to about 0.11 at the 95% level.

Section 4.3 of the paper has a separate sequential extension (eq. 10–11). It applies the reverse regression repeatedly on a growing sample from the collapse date TcT_c forward, and stops at the first sample end for which a correction is detected. It produces the further correction of January 2004 and the full return to normal conditions in May 2004 in their dot-com application. It is not part of the eq. 8–9 pair and is not implemented. It would be an outer loop around radf_recovery(), similar in structure to monitor().

In that application (Section 5, NASDAQ price–dividend ratio, reported in prose) the eq. 8–9 pair gives a crash from March to November 2000, so fcf_c is March 2000 and frf_r is November 2000. We have not reproduced this, because the underlying series is not available.

Implementation

radf_recovery() runs the bsadf recursion of radf() on the reversed series, compares it with a reversal-calibrated boundary and maps the first up-crossing and down-crossing back through f=n+1−gf = n + 1 - g. radf_recovery_cv() produces the boundary by the simulate-then-quantile construction of radf_mc_cv(), including its cummax(badf) shortcut for the bsadf boundary, with one added rev() before the recursion. No C++ and no new statistic are needed.

Validation and caveats

  • fc≤frf_c \le f_r holds by construction whenever both dates are identified and uncensored, and held in every replication.
  • The bias of frf_r on synthetic collapse-then-recovery data is a few observations, in the same direction and of similar size as the roughly six observations early in Table 5 of the paper.
  • The bias of fcf_c is larger, approaching the length of the synthetic collapse window in some runs.
  • Under a pure random-walk null (n=100n = 100, minw = 20, 95% level, one reversal-calibrated critical value reused across 200 fresh draws) the false-detection rate is about 29%. This is higher than comparable forward-test numbers in this project, such as the cumulative false-alarm rate of about 10% for monitor() over a 75-point horizon. The inf in eq. 9 defines the first down-crossing with no persistence requirement, so a transient noise-driven dip below the boundary triggers a premature fcf_c. This is a property of the literal crossing rule under finite-sample noise.

The roxygen documentation of radf_recovery() carries the same caveat. Replication script: replication/dating-and-root-inference/radf_recovery_validation.R.

Replication scripts

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

radf_hls_validation.R 133 lines
# Validation script for dating_hls() (Harvey, Leybourne & Sollis 2017,
# "Improving the accuracy of asset price bubble start and end date
# estimators"). See docs/dating-and-root-inference.md,
# "SSR/BIC dating vs. PSY recursive dating", for the full write-up. Run
# from the exuber/ package root (or adjust the devtools::load_all() path
# below).

Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula-exact: hls_segment_ssr() vs brute-force lm() ===\n")
set.seed(1)
y <- cumsum(rnorm(40))
ps <- exuber:::hls_prefix_sums(y)
n1 <- length(y) - 1L
x_all <- y[1:n1]; z_all <- y[2:(n1 + 1)] - y[1:n1]
for (seg in list(c(1, 10), c(11, 25), c(5, 39))) {
  lo <- seg[1] - 1; hi <- seg[2]
  manual <- exuber:::hls_segment_ssr(ps, lo, hi, TRUE)
  idx <- (lo + 1):hi
  brute <- sum(resid(lm(z_all[idx] ~ x_all[idx]))^2)
  cat(sprintf("segment (%d,%d]: manual=%.6f brute=%.6f match=%s\n",
              lo, hi, manual, brute, isTRUE(all.equal(manual, brute, tolerance = 1e-8))))
}

cat("\n=== 2. Full Model 4 (3-breakpoint) grid search vs brute-force nested lm() ===\n")
set.seed(5)
n3 <- 24
y3 <- cumsum(rnorm(n3))
ps3 <- exuber:::hls_prefix_sums(y3)
m4 <- exuber:::hls_model4(y3, ps3, trim = 0.1)
n1c <- n3 - 1L
x3 <- y3[1:n1c]; z3 <- y3[2:(n1c + 1)] - y3[1:n1c]
k_min3 <- max(2L, ceiling(0.1 * n1c))
best4 <- list(ssr = Inf)
for (tau1 in k_min3:(n1c - 3 * k_min3)) {
  for (tau2 in (tau1 + k_min3):(n1c - 2 * k_min3)) {
    if (y3[tau2 + 1] <= y3[tau1 + 1]) next
    for (tau3 in (tau2 + k_min3):(n1c - k_min3)) {
      if (y3[tau2 + 1] <= y3[tau3 + 1]) next
      ssr <- sum(z3[1:tau1]^2) +
        sum(resid(lm(z3[(tau1 + 1):tau2] ~ x3[(tau1 + 1):tau2]))^2) +
        sum(resid(lm(z3[(tau2 + 1):tau3] ~ x3[(tau2 + 1):tau3]))^2) +
        sum(z3[(tau3 + 1):n1c]^2)
      if (ssr < best4$ssr) best4 <- list(tau1 = tau1, tau2 = tau2, tau3 = tau3, ssr = ssr)
    }
  }
}
cat("vectorized:", m4$tau1, m4$tau2, m4$tau3, m4$ssr, "\n")
cat("brute force:", best4$tau1, best4$tau2, best4$tau3, best4$ssr, "\n")

cat("\n=== 3. Performance at realistic sample sizes ===\n")
for (n in c(100, 200, 400)) {
  set.seed(1)
  yn <- cumsum(rnorm(n))
  t0 <- Sys.time()
  dating_hls(yn, trim = 0.05)
  cat(sprintf("n=%d: %.2f sec\n", n, as.numeric(Sys.time() - t0)))
}

cat("\n=== 4. Monte Carlo: model-selection accuracy and breakpoint bias by DGP ===\n")
cat("(bubble/collapse regimes built on a large positive base (100) so the\n")
cat("explosive signal is a large departure from noise on the absolute scale;\n")
cat("a small mean-zero random-walk base gives too weak a signal and BIC then\n")
cat("favours Model 1)\n\n")

sim_model4 <- function(seed, n1 = 60, n2 = 25, n3 = 25, n4 = 40, base = 100, c_bubble = 1.05) {
  set.seed(seed)
  unit1 <- base + cumsum(rnorm(n1))
  bubble <- unit1[n1] * c_bubble^(1:n2) + cumsum(rnorm(n2))
  target <- bubble[n2] * 0.5
  collapse <- numeric(n3)
  collapse[1] <- bubble[n2] + rnorm(1)
  for (k in 2:n3) collapse[k] <- target + 0.85 * (collapse[k - 1] - target) + rnorm(1)
  recovery <- collapse[n3] + cumsum(rnorm(n4))
  list(y = c(unit1, bubble, collapse, recovery), true_tau1 = n1, true_tau2 = n1 + n2)
}
run4 <- function(seed) {
  sim <- sim_model4(seed)
  out <- dating_hls(sim$y, trim = 0.05)
  list(model = out$model[["series1"]],
       orig_bias = as.numeric(out$origination[["series1"]]) - sim$true_tau1,
       coll_bias = if (!is.na(out$collapse[["series1"]])) as.numeric(out$collapse[["series1"]]) - sim$true_tau2 else NA)
}
res4 <- lapply(1:30, run4)
models4 <- sapply(res4, `[[`, "model")
cat("Model 4 DGP -- selection freq:", paste(sapply(1:4, function(m) sprintf("M%d=%.2f", m, mean(models4 == m))), collapse = ", "), "\n")
cat(sprintf("  origination mean|bias|=%.2f; collapse mean|bias| (M3/4 only)=%.2f\n",
            mean(abs(sapply(res4, `[[`, "orig_bias"))),
            mean(abs(sapply(res4[models4 %in% c(3, 4)], `[[`, "coll_bias")))))

sim_model2 <- function(seed, n1 = 60, n2 = 30, n3 = 60, base = 100, c_bubble = 1.05) {
  set.seed(seed)
  unit1 <- base + cumsum(rnorm(n1))
  bubble <- unit1[n1] * c_bubble^(1:n2) + cumsum(rnorm(n2))
  unit2 <- bubble[n2] + cumsum(rnorm(n3))
  list(y = c(unit1, bubble, unit2), true_tau1 = n1)
}
run2 <- function(seed) {
  sim <- sim_model2(seed)
  out <- dating_hls(sim$y, trim = 0.05)
  list(model = out$model[["series1"]], orig_bias = as.numeric(out$origination[["series1"]]) - sim$true_tau1)
}
res2 <- lapply(1:30, run2)
models2 <- sapply(res2, `[[`, "model")
cat("Model 2 DGP -- selection freq:", paste(sapply(1:4, function(m) sprintf("M%d=%.2f", m, mean(models2 == m))), collapse = ", "),
    sprintf("; origination mean|bias|=%.2f\n", mean(abs(sapply(res2, `[[`, "orig_bias")))))

sim_model1 <- function(seed, n1 = 80, n2 = 60, base = 100, c_bubble = 1.05) {
  set.seed(seed)
  unit1 <- base + cumsum(rnorm(n1))
  bubble <- unit1[n1] * c_bubble^(1:n2) + cumsum(rnorm(n2))
  list(y = c(unit1, bubble), true_tau1 = n1)
}
run1 <- function(seed) {
  sim <- sim_model1(seed)
  out <- dating_hls(sim$y, trim = 0.05)
  list(model = out$model[["series1"]], orig_bias = as.numeric(out$origination[["series1"]]) - sim$true_tau1)
}
res1 <- lapply(1:30, run1)
models1 <- sapply(res1, `[[`, "model")
cat("Model 1 DGP -- selection freq:", paste(sapply(1:4, function(m) sprintf("M%d=%.2f", m, mean(models1 == m))), collapse = ", "),
    sprintf("; origination mean|bias|=%.2f\n", mean(abs(sapply(res1, `[[`, "orig_bias")))))

run_h0 <- function(seed) {
  set.seed(seed)
  y0 <- 100 + cumsum(rnorm(150))
  dating_hls(y0, trim = 0.05)$model[["series1"]]
}
models0 <- sapply(1:30, run_h0)
cat("Pure H0 (no bubble) -- selection freq:", paste(sapply(1:4, function(m) sprintf("M%d=%.2f", m, mean(models0 == m))), collapse = ", "), "\n")
radf_hls_validation.py 187 lines
"""Python replication script for dating_hls() (Harvey, Leybourne & Sollis
2017 SSR+BIC dating), cross-checking pyexuber's port against
radf_hls_validation.R in this same folder.

RNG note: numpy's Generator, not R's RNG -- independent seeds, same
qualitative checks (see rootstamp_validation.py's module docstring for
the same convention elsewhere in this port). dating_hls() is pure numpy
(no C++ extension needed), so this script runs anywhere, unlike
radf_recovery_validation.py.

R's own script (docs/dating-and-root-inference.md, "Implementation (HLS
route)"), reports for context (not asserted bit-for-bit,
different RNG): Model 4 DGP -- BIC selects Model 3/4 in 100% of 30 reps,
split ~80/20; Model 2 DGP -- 100% Model 2, origination bias exactly 0;
Model 1 DGP -- 100% Model 1, bias exactly 0; pure H0 -- 0% Model 4,
split ~30/57/13 across Models 1/2/3.

Run standalone:
uv run --project pyexuber python
docs/replication/dating-and-root-inference/radf_hls_validation.py
"""

import math

import numpy as np

from exuber._hls_common import _hls_model4, _hls_prefix_sums, _hls_segment_ssr
from exuber.dating_hls import dating_hls


def _ols_ssr(xseg: np.ndarray, zseg: np.ndarray) -> float:
    a = np.vstack([xseg, np.ones_like(xseg)]).T
    coef, *_ = np.linalg.lstsq(a, zseg, rcond=None)
    return float(np.sum((zseg - a @ coef) ** 2))


def check_segment_ssr_formula_exact() -> None:
    print("=== 1. Formula-exact: _hls_segment_ssr() vs brute-force OLS ===")
    rng = np.random.default_rng(1)
    y = np.cumsum(rng.normal(size=40))
    ps = _hls_prefix_sums(y)
    n1 = len(y) - 1
    x_all, z_all = y[:n1], np.diff(y)
    for lo, hi in [(0, 10), (10, 25), (4, 39)]:
        manual = _hls_segment_ssr(ps, lo, hi, True)
        brute = _ols_ssr(x_all[lo:hi], z_all[lo:hi])
        print(f"  segment ({lo},{hi}]: manual={manual:.6f} brute={brute:.6f}")
        assert abs(manual - brute) < 1e-8


def check_model4_formula_exact() -> None:
    print("\n=== 2. Full Model 4 (3-breakpoint) joint grid search vs brute force ===")
    rng = np.random.default_rng(5)
    n = 24
    y = np.cumsum(rng.normal(size=n))
    ps = _hls_prefix_sums(y)
    tau1, tau2, tau3, ssr = _hls_model4(y, ps, trim=0.1)

    n1 = n - 1
    x, z = y[:n1], np.diff(y)
    k_min = max(2, math.ceil(0.1 * n1))
    best_ssr, best = math.inf, None
    for t1 in range(k_min, n1 - 3 * k_min + 1):
        for t2 in range(t1 + k_min, n1 - 2 * k_min + 1):
            if y[t2] <= y[t1]:
                continue
            for t3 in range(t2 + k_min, n1 - k_min + 1):
                if y[t2] <= y[t3]:
                    continue
                s = (
                    np.sum(z[:t1] ** 2)
                    + _ols_ssr(x[t1:t2], z[t1:t2])
                    + _ols_ssr(x[t2:t3], z[t2:t3])
                    + np.sum(z[t3:n1] ** 2)
                )
                if s < best_ssr:
                    best_ssr, best = s, (t1, t2, t3)
    print(f"  vectorized: {(tau1, tau2, tau3)}, ssr={ssr}")
    print(f"  brute:      {best}, ssr={best_ssr}")
    assert (tau1, tau2, tau3) == best
    assert abs(ssr - best_ssr) < 1e-6


def check_performance() -> None:
    print("\n=== 3. Performance at realistic sample sizes ===")
    import time

    for n in (100, 200, 400):
        rng = np.random.default_rng(1)
        y = np.cumsum(rng.normal(size=n))
        t0 = time.time()
        dating_hls(y, trim=0.05)
        print(f"  n={n}: {time.time() - t0:.2f} sec")


def _sim_model4(seed, n1=60, n2=25, n3=25, n4=40, base=100.0, c_bubble=1.05):
    rng = np.random.default_rng(seed)
    unit1 = base + np.cumsum(rng.normal(size=n1))
    bubble = unit1[-1] * c_bubble ** np.arange(1, n2 + 1) + np.cumsum(rng.normal(size=n2))
    target = bubble[-1] * 0.5
    collapse = np.empty(n3)
    collapse[0] = bubble[-1] + rng.normal()
    for k in range(1, n3):
        collapse[k] = target + 0.85 * (collapse[k - 1] - target) + rng.normal()
    recovery = collapse[-1] + np.cumsum(rng.normal(size=n4))
    return np.concatenate([unit1, bubble, collapse, recovery]), n1, n1 + n2


def _sim_model2(seed, n1=60, n2=30, n3=60, base=100.0, c_bubble=1.05):
    rng = np.random.default_rng(seed)
    unit1 = base + np.cumsum(rng.normal(size=n1))
    bubble = unit1[-1] * c_bubble ** np.arange(1, n2 + 1) + np.cumsum(rng.normal(size=n2))
    unit2 = bubble[-1] + np.cumsum(rng.normal(size=n3))
    return np.concatenate([unit1, bubble, unit2]), n1


def _sim_model1(seed, n1=80, n2=60, base=100.0, c_bubble=1.05):
    rng = np.random.default_rng(seed)
    unit1 = base + np.cumsum(rng.normal(size=n1))
    bubble = unit1[-1] * c_bubble ** np.arange(1, n2 + 1) + np.cumsum(rng.normal(size=n2))
    return np.concatenate([unit1, bubble]), n1


def check_monte_carlo_model_selection() -> None:
    print("\n=== 4. Monte Carlo: model-selection accuracy and breakpoint bias by DGP ===")
    print("(bubble/collapse regimes on a large positive base (100), matching R's")
    print(" own fix for a weak-signal DGP that biased selection toward Model 1)\n")

    res4 = []
    for s in range(30):
        y, t1, t2 = _sim_model4(s)
        out = dating_hls(y, trim=0.05)
        coll = out.collapse[0] - t2 if not math.isnan(out.collapse[0]) else None
        res4.append((out.model[0], out.origination[0] - t1, coll))
    models4 = [m for m, _, _ in res4]
    freq4 = {m: models4.count(m) / 30 for m in (1, 2, 3, 4)}
    print(f"  Model 4 DGP -- selection freq: {freq4}")
    orig_bias4 = np.mean([abs(b) for _, b, _ in res4])
    coll_biases = [c for m, _, c in res4 if m in (3, 4) and c is not None]
    coll_bias4 = np.mean([abs(c) for c in coll_biases]) if coll_biases else float("nan")
    print(f"  origination mean|bias|={orig_bias4:.2f}; collapse mean|bias| (M3/4)={coll_bias4:.2f}")
    assert freq4[3] + freq4[4] > 0.8  # BIC should favor a distinct-collapse model

    res2 = []
    for s in range(30):
        y, t1 = _sim_model2(s)
        out = dating_hls(y, trim=0.05)
        res2.append((out.model[0], out.origination[0] - t1))
    models2 = [m for m, _ in res2]
    freq2 = {m: models2.count(m) / 30 for m in (1, 2, 3, 4)}
    print(f"  Model 2 DGP -- selection freq: {freq2}; origination mean|bias|="
          f"{np.mean([abs(b) for _, b in res2]):.2f}")
    assert freq2[2] > 0.8

    res1 = []
    for s in range(30):
        y, t1 = _sim_model1(s)
        out = dating_hls(y, trim=0.05)
        res1.append((out.model[0], out.origination[0] - t1))
    models1 = [m for m, _ in res1]
    freq1 = {m: models1.count(m) / 30 for m in (1, 2, 3, 4)}
    print(f"  Model 1 DGP -- selection freq: {freq1}; origination mean|bias|="
          f"{np.mean([abs(b) for _, b in res1]):.2f}")
    assert freq1[1] > 0.8

    models0 = []
    for s in range(30):
        rng = np.random.default_rng(s)
        y0 = 100 + np.cumsum(rng.normal(size=150))
        out = dating_hls(y0, trim=0.05)
        models0.append(out.model[0])
    freq0 = {m: models0.count(m) / 30 for m in (1, 2, 3, 4)}
    print(f"  Pure H0 (no bubble) -- selection freq: {freq0}")
    assert freq0[4] < 0.3  # no runaway complex-model selection under pure noise


def main() -> None:
    check_segment_ssr_formula_exact()
    check_model4_formula_exact()
    check_performance()
    check_monte_carlo_model_selection()
    print("\nAll dating_hls() checks passed.")


if __name__ == "__main__":
    main()
radf_hlw_validation.R 101 lines
# Validation script for dating_hlw() (Harvey, Leybourne & Whitehouse 2020,
# "Date-stamping multiple bubble regimes" -- the two-step wrapper around
# dating_hls() that extends single-bubble SSR/BIC dating to series with
# more than one explosive episode). See docs/
# dating-and-root-inference.md, "SSR/BIC dating vs. PSY recursive
# dating", for the full write-up. Run from the exuber-project/ root (or
# adjust the devtools::load_all() path below).

Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. hlw_local_to_global() arithmetic (window-local breakpoint\n")
cat("     -> global i-index / date position) ===\n\n")
g <- exuber:::hlw_local_to_global(local_tau = 5L, s = 21L)
cat("local_tau=5, s=21 -> i_index=", g$i_index, " position=", g$position,
    " (expect 25, 26)\n\n")

cat("=== 2. Two genuine bubbles: window count + breakpoint accuracy (20 reps) ===\n")
cat("(the PSY detection of step 1 can split a true episode into extra windows\n")
cat("under noise, which HLW discuss; the full window-count distribution is\n")
cat("reported)\n\n")
sim_two_bubbles <- function(seed, n1a = 50, n2a = 20, n3a = 30, n1b = 50, n2b = 20, n3b = 30) {
  set.seed(seed)
  e1 <- 100 + cumsum(rnorm(n1a))
  b1 <- e1[n1a] * 1.05^(1:n2a) + cumsum(rnorm(n2a))
  u1 <- b1[n2a] + cumsum(rnorm(n3a))
  e2 <- u1[n3a] + cumsum(rnorm(n1b))
  b2 <- e2[n1b] * 1.05^(1:n2b) + cumsum(rnorm(n2b))
  u2 <- b2[n2b] + cumsum(rnorm(n3b))
  y <- c(e1, b1, u1, e2, b2, u2)
  list(y = y, true1 = c(n1a, n1a + n2a), true2 = c(n1a + n2a + n3a + n1b, n1a + n2a + n3a + n1b + n2b))
}
run_two <- function(seed) {
  sim <- sim_two_bubbles(seed)
  out <- dating_hlw(sim$y, trim = 0.1, min_duration = psy_ds(length(sim$y)), nboot = 199, seed = 1)
  df <- out[["series1"]]
  list(n_windows = nrow(df), df = df, true1 = sim$true1, true2 = sim$true2)
}
res_two <- lapply(1:20, run_two)
n_windows <- sapply(res_two, `[[`, "n_windows")
cat("Window count distribution:", paste(names(table(n_windows)), table(n_windows), sep = "=", collapse = ", "), "\n")
two_win <- res_two[n_windows == 2]
cat(sprintf("Reps with exactly 2 windows: %d/20\n", length(two_win)))
if (length(two_win) > 0) {
  orig1_bias <- sapply(two_win, function(r) as.numeric(r$df$origination[1]) - r$true1[1])
  coll1_bias <- sapply(two_win, function(r) as.numeric(r$df$collapse[1]) - r$true1[2])
  orig2_bias <- sapply(two_win, function(r) as.numeric(r$df$origination[2]) - r$true2[1])
  coll2_bias <- sapply(two_win, function(r) as.numeric(r$df$collapse[2]) - r$true2[2])
  cat(sprintf("Bubble 1: origination mean|bias|=%.2f, collapse mean|bias|=%.2f\n",
              mean(abs(orig1_bias)), mean(abs(coll1_bias))))
  cat(sprintf("Bubble 2: origination mean|bias|=%.2f, collapse mean|bias|=%.2f\n\n",
              mean(abs(orig2_bias)), mean(abs(coll2_bias))))
}

cat("=== 3. Windows are always ordered (never overlapping) ===\n")
ok <- sapply(res_two, function(r) {
  if (nrow(r$df) < 2) return(TRUE)
  all(diff(as.numeric(r$df$origination)) > 0)
})
cat("all ordered:", all(ok), "\n\n")

cat("=== 4. Pure H0 (no bubble at all): no error, and how often 0 windows ===\n")
run_h0 <- function(seed) {
  set.seed(seed)
  y0 <- 100 + cumsum(rnorm(150))
  out <- dating_hlw(y0, trim = 0.1, nboot = 199, seed = 1)
  nrow(out[["series1"]])
}
n0 <- sapply(1:20, run_h0)
cat("Window counts under H0:", paste(names(table(n0)), table(n0), sep = "=", collapse = ", "), "\n\n")

cat("=== 5. Single clean bubble: final window vs standalone dating_hls() ===\n")
run_single <- function(seed, n1 = 60, n2 = 25, n3 = 25, n4 = 40) {
  set.seed(seed)
  unit1 <- 100 + cumsum(rnorm(n1))
  bubble <- unit1[n1] * 1.05^(1:n2) + cumsum(rnorm(n2))
  target <- bubble[n2] * 0.5
  collapse <- numeric(n3)
  collapse[1] <- bubble[n2] + rnorm(1)
  for (k in 2:n3) collapse[k] <- target + 0.85 * (collapse[k - 1] - target) + rnorm(1)
  recovery <- collapse[n3] + cumsum(rnorm(n4))
  y <- c(unit1, bubble, collapse, recovery)
  hls_out <- dating_hls(y, trim = 0.1)
  hlw_out <- dating_hlw(y, trim = 0.1, min_duration = psy_ds(length(y)), nboot = 199, seed = 1)
  df <- hlw_out[["series1"]]
  last <- df[nrow(df), ]
  list(
    n_windows = nrow(df),
    match = identical(unname(hls_out$model[["series1"]]), last$model) &&
      identical(unname(hls_out$origination[["series1"]]), last$origination) &&
      identical(unname(hls_out$collapse[["series1"]]), last$collapse)
  )
}
res_single <- lapply(1:15, run_single)
cat(sprintf("Final-window match rate vs standalone dating_hls(): %.2f\n",
            mean(sapply(res_single, `[[`, "match"))))
cat("Window count distribution:",
    paste(names(table(sapply(res_single, `[[`, "n_windows"))),
          table(sapply(res_single, `[[`, "n_windows")), sep = "=", collapse = ", "), "\n")
radf_hlw_validation.py 165 lines
"""Python replication script for dating_hlw() (Harvey, Leybourne &
Whitehouse 2020 multi-bubble two-step wrapper around dating_hls()),
cross-checking pyexuber's port against radf_hlw_validation.R in this same
folder.

Two kinds of checks run here.

1. The window construction and per-window fitting
   (_dating_hlw_from_episodes()) is pure numpy and needs no C++ extension.
   Sections 1 to 3 use hand-built Episode objects in place of a noisy
   datestamp() detection, which separates the logic under test from the
   noise of the PSY step-1 detection.
2. The full pipeline (radf() -> radf_wb_cv() -> datestamp() -> per-window
   dating_hlw()) needs the C++ extension. Section 4 is run by
   pyexuber/tests/test_dating_validation.py in CI, with loose structural
   assertions.

RNG note: numpy's Generator, not R's RNG (see rootstamp_validation.py's
module docstring for the same convention).

R's own script (docs/dating-and-root-inference.md, "Implementation (HLW
route)"), reports for context (not asserted bit-for-bit):
exactly 2 windows detected in 13/20 reps on a synthetic two-bubble DGP
(the rest were split by PSY step-1 detection noise); among clean 2-window reps, origination/collapse bias exactly 0 in
every replication; single-bubble final window matched standalone
dating_hls() in 100% of reps with a final window.

Run standalone:
uv run --project pyexuber python
docs/replication/dating-and-root-inference/radf_hlw_validation.py
"""

import warnings

import numpy as np

from exuber.datestamp import Episode
from exuber.dating_hls import dating_hls
from exuber.dating_hlw import _dating_hlw_from_episodes, _hlw_local_to_global, dating_hlw


def check_local_to_global_arithmetic() -> None:
    print("=== 1. _hlw_local_to_global() arithmetic ===")
    print("(R's own check: local_tau=5, s=21 (1-indexed) -> i_index=25, position=26;")
    print(" s0 (0-indexed window start) = s - 1 = 20)\n")
    g = _hlw_local_to_global(local_tau=5, s0=20)
    print(f"  local_tau=5, s0=20 -> position={g} (expect 26)")
    assert g == 26


def _sim_hls_model4(seed, n1=60, n2=25, n3=25, n4=40, base=100.0, c_bubble=1.05):
    rng = np.random.default_rng(seed)
    unit1 = base + np.cumsum(rng.normal(size=n1))
    bubble = unit1[-1] * c_bubble ** np.arange(1, n2 + 1) + np.cumsum(rng.normal(size=n2))
    target = bubble[-1] * 0.5
    collapse = np.empty(n3)
    collapse[0] = bubble[-1] + rng.normal()
    for k in range(1, n3):
        collapse[k] = target + 0.85 * (collapse[k - 1] - target) + rng.normal()
    recovery = collapse[-1] + np.cumsum(rng.normal(size=n4))
    return np.concatenate([unit1, bubble, collapse, recovery]), n1, n1 + n2


def check_single_episode_reduces_to_dating_hls() -> None:
    print("\n=== 2. Single clean episode: window matches standalone dating_hls() ===")
    match = 0
    for seed in range(15):
        y, _t1, _t2 = _sim_hls_model4(seed)
        n = len(y)
        ep = Episode(start=0, peak=0, end=None, duration=n, ongoing=True)
        hlw_eps = _dating_hlw_from_episodes(y, [ep], n, trim=0.1)
        hls_out = dating_hls(y, trim=0.1)
        last = hlw_eps[0]
        ok = (
            last.model == hls_out.model[0]
            and last.origination == hls_out.origination[0]
            and last.collapse == hls_out.collapse[0]
        )
        match += ok
    rate = match / 15
    print(f"  Final-window match rate vs standalone dating_hls(): {rate:.2f}")
    assert rate == 1.0


def _sim_two_bubbles(seed, n1a=50, n2a=20, n3a=30, n1b=50, n2b=20, n3b=30):
    rng = np.random.default_rng(seed)
    e1 = 100 + np.cumsum(rng.normal(size=n1a))
    b1 = e1[-1] * 1.05 ** np.arange(1, n2a + 1) + np.cumsum(rng.normal(size=n2a))
    u1 = b1[-1] + np.cumsum(rng.normal(size=n3a))
    e2 = u1[-1] + np.cumsum(rng.normal(size=n1b))
    b2 = e2[-1] * 1.05 ** np.arange(1, n2b + 1) + np.cumsum(rng.normal(size=n2b))
    u2 = b2[-1] + np.cumsum(rng.normal(size=n3b))
    y = np.concatenate([e1, b1, u1, e2, b2, u2])
    true1 = (n1a, n1a + n2a)
    true2 = (n1a + n2a + n3a + n1b, n1a + n2a + n3a + n1b + n2b)
    return y, true1, true2


def check_two_window_accuracy() -> None:
    print("\n=== 3. Two bubbles, clean hand-built windows: breakpoint accuracy ===")
    print("(isolates window-construction + per-window HLS fitting from step-1")
    print(" PSY detection noise -- that detection pass is exuber's/pyexuber's")
    print(" own already-tested datestamp(), not new code this port adds)\n")
    o1b, c1b, o2b, c2b = [], [], [], []
    for seed in range(20):
        y, true1, true2 = _sim_two_bubbles(seed)
        n = len(y)
        ep1 = Episode(start=true1[0] - 5, peak=0, end=true1[1] + 5, duration=0, ongoing=False)
        ep2 = Episode(start=true2[0] - 5, peak=0, end=true2[1] + 5, duration=0, ongoing=False)
        eps = _dating_hlw_from_episodes(y, [ep1, ep2], n, trim=0.1)
        if len(eps) == 2:
            o1b.append(eps[0].origination - true1[0])
            c1b.append(eps[0].collapse - true1[1])
            o2b.append(eps[1].origination - true2[0])
            c2b.append(eps[1].collapse - true2[1])
    print(f"  clean 2-window reps: {len(o1b)}/20")
    print(
        f"  bubble 1: orig mean|bias|={np.mean(np.abs(o1b)):.2f}, "
        f"coll mean|bias|={np.mean(np.abs(c1b)):.2f}"
    )
    print(
        f"  bubble 2: orig mean|bias|={np.mean(np.abs(o2b)):.2f}, "
        f"coll mean|bias|={np.mean(np.abs(c2b)):.2f}"
    )
    assert len(o1b) > 0
    assert np.mean(np.abs(o1b)) < 5
    assert np.mean(np.abs(o2b)) < 5


def check_end_to_end_h0_and_two_bubble() -> None:
    print("\n=== 4. Full end-to-end pipeline (needs the C++ extension) ===")
    print("(radf() -> radf_wb_cv() -> datestamp() -> per-window dating_hlw();")
    print(" needs the C++ extension -- see module docstring)\n")

    with warnings.catch_warnings():
        warnings.simplefilter("ignore")
        rng = np.random.default_rng(2)
        y0 = 100 + np.cumsum(rng.normal(size=150))
        out0 = dating_hlw(y0, trim=0.1, nboot=199, seed=1)
        print(f"  Pure H0: {len(out0.episodes['series1'])} windows detected (expect 0, no error)")
        assert out0.episodes["series1"] == []

        n_windows = []
        for seed in range(8):
            y, _true1, _true2 = _sim_two_bubbles(seed)
            out = dating_hlw(y, trim=0.1, nboot=199, seed=1)
            eps = out.episodes["series1"]
            n_windows.append(len(eps))
            if len(eps) == 2:
                assert eps[0].origination < eps[1].origination
        print(f"  Two-bubble DGP window counts (8 reps): {n_windows}")
        assert all(w >= 0 for w in n_windows)


def main() -> None:
    check_local_to_global_arithmetic()
    check_single_episode_reduces_to_dating_hls()
    check_two_window_accuracy()
    check_end_to_end_h0_and_two_bubble()
    print("\nAll dating_hlw() checks passed.")


if __name__ == "__main__":
    main()
radf_knp_validation.R 119 lines
# Validation script for dating_knp() (Kejriwal, Nguyen & Perron 2025, "An
# Improved Procedure for Retrospectively Dating the Emergence and
# Collapse of Bubbles"). See docs/dating-and-root-
# inference.md, "SSR/BIC dating vs. PSY recursive dating", for the full
# write-up. Run from the exuber-project/ root (or adjust the
# devtools::load_all() path below).

Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula-exact: knp_find_break() vs brute-force nested lm() search ===\n")
set.seed(3)
n <- 26
y <- cumsum(rnorm(n))
n1 <- n - 1L
x <- y[1:n1]; z <- y[2:(n1 + 1)] - y[1:n1]
k_min <- max(2L, ceiling(0.1 * n1))

for (omit in c(FALSE, TRUE)) {
  fit <- exuber:::knp_find_break(y, trim = 0.1, omit = omit)
  best <- list(ssr = Inf)
  for (tau1 in k_min:(n1 - 2 * k_min)) {
    for (tau2 in (tau1 + k_min):(n1 - k_min)) {
      idx_mid <- (tau1 + 1):tau2
      ssr <- sum(z[1:tau1]^2) + sum(resid(lm(z[idx_mid] ~ x[idx_mid]))^2) + sum(z[(tau2 + 1):n1]^2)
      if (omit) ssr <- ssr - z[tau2 + 1]^2
      if (ssr < best$ssr) best <- list(tau1 = tau1, tau2 = tau2, ssr = ssr)
    }
  }
  cat(sprintf("omit=%s: vectorized=(%d,%d,%.4f)  brute=(%d,%d,%.4f)\n",
              omit, fit$tau1, fit$tau2, fit$ssr, best$tau1, best$tau2, best$ssr))
}

cat("\n=== 2. Reproducing Theorem 1 (naive inconsistency) vs Theorem 2\n")
cat("     (omission-corrected consistency) ===\n")
cat("(KNP's own DGP: unit root -> no-intercept explosive AR(1) -> an\n")
cat("instantaneous collapse back near the pre-bubble level -> fresh unit\n")
cat("root. Theorem 1 proves plain OLS's origination-date estimate\n")
cat("converges to the TRUE COLLAPSE date, not the true origination date;\n")
cat("Theorem 2 proves the single-residual omission fixes this.)\n\n")

sim_knp <- function(seed, T1 = 50, T2 = 90, T = 200, delta = 1.05) {
  set.seed(seed)
  y <- numeric(T)
  y[1] <- 0
  for (t in 2:T1) y[t] <- y[t - 1] + rnorm(1)
  for (t in (T1 + 1):T2) y[t] <- delta * y[t - 1] + rnorm(1)
  y[T2 + 1] <- y[T1] + rnorm(1)
  if (T2 + 2 <= T) for (t in (T2 + 2):T) y[t] <- y[t - 1] + rnorm(1)
  list(y = y, T1 = T1, T2 = T2)
}
run <- function(seed, omit) {
  sim <- sim_knp(seed)
  fit <- exuber:::knp_find_break(sim$y, trim = 0.05, omit = omit)
  c(tau1 = fit$tau1, tau2 = fit$tau2, T1 = sim$T1, T2 = sim$T2)
}
res_naive <- t(sapply(1:30, run, omit = FALSE))
res_om <- t(sapply(1:30, run, omit = TRUE))

cat(sprintf("Naive (omit=FALSE):     mean|tau1-T1|=%.1f   mean|tau1-T2|=%.1f  (tau1 should track T2, not T1)\n",
            mean(abs(res_naive[, "tau1"] - res_naive[, "T1"])),
            mean(abs(res_naive[, "tau1"] - res_naive[, "T2"]))))
cat(sprintf("Omission-corrected:     mean|tau1-T1|=%.2f   mean|tau2-T2|=%.2f\n\n",
            mean(abs(res_om[, "tau1"] - res_om[, "T1"])),
            mean(abs(res_om[, "tau2"] - res_om[, "T2"]))))

cat("=== 3. delta_hat accuracy under omission correction (true delta=1.05) ===\n")
run_delta <- function(seed) {
  sim <- sim_knp(seed)
  out <- dating_knp(sim$y, trim = 0.05, omit = TRUE)
  unname(out$delta[["series1"]])
}
deltas <- sapply(1:30, run_delta)
cat(sprintf("mean delta_hat = %.3f (true = 1.05), mean|bias| = %.3f\n", mean(deltas), mean(abs(deltas - 1.05))))

cat("\n=== 4. Multi-bubble DP (Section 3.2): exact vs the single-bubble search and brute force ===\n")
set.seed(11)
y <- cumsum(rnorm(80))
dp <- exuber:::knp_dp(y, 2, trim = 0.05)
fb <- exuber:::knp_find_break(y, trim = 0.05)
cat(sprintf("breaks = 2: DP taus (%d, %d) vs search (%d, %d), |dSSR| = %.2e\n",
            dp$tau[1], dp$tau[2], fb$tau1, fb$tau2, abs(dp$ssr - fb$ssr)))
set.seed(12)
y <- cumsum(rnorm(28))
n1 <- 27
xx <- y[1:n1]
zz <- diff(y)
k_min <- 3
brute4 <- Inf
for (a in k_min:(n1 - 4 * k_min)) for (b in (a + k_min):(n1 - 3 * k_min)) for (c in (b + k_min):(n1 - 2 * k_min)) for (d in (c + k_min):(n1 - k_min)) {
  ssr <- sum(zz[1:a]^2) + sum(resid(lm(zz[(a + 1):b] ~ xx[(a + 1):b]))^2) +
    sum(zz[(b + 2):c]^2) + sum(resid(lm(zz[(c + 1):d] ~ xx[(c + 1):d]))^2) + sum(zz[(d + 2):n1]^2)
  if (ssr < brute4) {
    brute4 <- ssr
    arg4 <- c(a, b, c, d)
  }
}
dp4 <- exuber:::knp_dp(y, 4, trim = 0.1)
cat(sprintf("breaks = 4, n = 28: DP taus %s vs brute force %s, |dSSR| = %.2e\n",
            paste(dp4$tau, collapse = ","), paste(arg4, collapse = ","), abs(dp4$ssr - brute4)))

cat("\n=== 5. Two-bubble Monte Carlo (T = 200; bubbles 41-70 and 121-150, delta = 1.05) ===\n")
sim_knp2 <- function(seed, T = 200, delta = 1.05) {
  set.seed(seed)
  y <- numeric(T)
  for (t in 2:T) {
    y[t] <- if ((t > 40 && t <= 70) || (t > 120 && t <= 150)) delta * y[t - 1] + rnorm(1) else y[t - 1] + rnorm(1)
    if (t == 71) y[t] <- y[40] + rnorm(1)
    if (t == 151) y[t] <- y[120] + rnorm(1)
  }
  y
}
truth <- c(40, 70, 120, 150)
for (omit in c(TRUE, FALSE)) {
  err <- t(sapply(1:50, function(s) abs(exuber:::knp_dp(sim_knp2(s), 4, trim = 0.05, omit = omit)$tau - truth)))
  cat(sprintf("omit = %-5s mean|tau - true| per break: %s\n", omit, paste(sprintf("%.1f", colMeans(err)), collapse = "  ")))
}
radf_knp_validation.py 165 lines
"""Python replication script for dating_knp() (Kejriwal, Nguyen & Perron
2025 bias-corrected single-bubble dating), cross-checking pyexuber's port
against radf_knp_validation.R in this same folder.

RNG note: numpy's Generator, not R's RNG -- independent seeds, same
qualitative checks (see rootstamp_validation.py's module docstring for
the same convention elsewhere in this port). dating_knp() is pure numpy
(no C++ extension needed), so this script WAS run directly on the dev
machine that wrote the port -- all numbers below are real local runs.

R's own script (docs/dating-and-root-inference.md, "Improved
retrospective dating"), reports for context (not
asserted bit-for-bit, different RNG): naive (omit=FALSE) mean|tau1-T1|
=39.0 vs mean|tau1-T2|=1.0 (Theorem 1: tau1_hat tracks the COLLAPSE
date, not origination); omission-corrected mean|tau1-T1|=13.0 (Theorem
2: a genuine correction, not full elimination at this finite T);
delta_hat mean 0.986 vs true 1.05.

Run standalone:
uv run --project pyexuber python
docs/replication/dating-and-root-inference/radf_knp_validation.py
"""

import math

import numpy as np

from exuber.dating_knp import _knp_dp, _knp_find_break, dating_knp


def _ols_ssr(xseg: np.ndarray, zseg: np.ndarray) -> float:
    a = np.vstack([xseg, np.ones_like(xseg)]).T
    coef, *_ = np.linalg.lstsq(a, zseg, rcond=None)
    return float(np.sum((zseg - a @ coef) ** 2))


def check_formula_exact() -> None:
    print("=== 1. Formula-exact: _knp_find_break() vs brute-force nested OLS ===")
    rng = np.random.default_rng(3)
    n = 26
    y = np.cumsum(rng.normal(size=n))
    n1 = n - 1
    x, z = y[:n1], np.diff(y)
    k_min = max(2, math.ceil(0.1 * n1))

    for omit in (False, True):
        tau1, tau2, ssr = _knp_find_break(y, trim=0.1, omit=omit)
        best_ssr, best = math.inf, None
        for t1 in range(k_min, n1 - 2 * k_min + 1):
            for t2 in range(t1 + k_min, n1 - k_min + 1):
                s = np.sum(z[:t1] ** 2) + _ols_ssr(x[t1:t2], z[t1:t2]) + np.sum(z[t2:n1] ** 2)
                if omit:
                    s -= z[t2] ** 2
                if s < best_ssr:
                    best_ssr, best = s, (t1, t2)
        print(f"  omit={omit}: vectorized=({tau1},{tau2},{ssr:.4f})  brute={best},{best_ssr:.4f}")
        assert (tau1, tau2) == best
        assert abs(ssr - best_ssr) < 1e-6


def _sim_knp(seed, t1=50, t2=90, t=200, delta=1.05):
    rng = np.random.default_rng(seed)
    y = np.zeros(t)
    for i in range(1, t1):
        y[i] = y[i - 1] + rng.normal()
    for i in range(t1, t2):
        y[i] = delta * y[i - 1] + rng.normal()
    y[t2] = y[t1 - 1] + rng.normal()
    for i in range(t2 + 1, t):
        y[i] = y[i - 1] + rng.normal()
    return y, t1, t2


def check_theorem_1_and_2() -> None:
    print("\n=== 2. Reproducing Theorem 1 (naive inconsistency) vs Theorem 2")
    print("     (omission-corrected consistency) ===")
    print("(KNP's own DGP: unit root -> no-intercept explosive AR(1) -> an")
    print("instantaneous collapse back near the pre-bubble level -> fresh unit")
    print("root. Theorem 1 proves plain OLS's origination-date estimate")
    print("converges to the TRUE COLLAPSE date, not the true origination date;")
    print("Theorem 2 proves the single-residual omission fixes this.)\n")

    def run(seed, omit):
        y, t1, t2 = _sim_knp(seed)
        tau1, tau2, _ssr = _knp_find_break(y, trim=0.05, omit=omit)
        return tau1, tau2, t1, t2

    res_naive = [run(s, False) for s in range(30)]
    res_om = [run(s, True) for s in range(30)]

    bias_naive_t1 = np.mean([abs(tau1 - t1) for tau1, _tau2, t1, _t2 in res_naive])
    bias_naive_t2 = np.mean([abs(tau1 - t2) for tau1, _tau2, _t1, t2 in res_naive])
    bias_om_t1 = np.mean([abs(tau1 - t1) for tau1, _tau2, t1, _t2 in res_om])
    bias_om_t2 = np.mean([abs(tau2 - t2) for _tau1, tau2, _t1, t2 in res_om])

    print(
        f"  Naive (omit=False): mean|tau1-T1|={bias_naive_t1:.1f}  "
        f"mean|tau1-T2|={bias_naive_t2:.1f}  (tau1 should track T2, not T1)"
    )
    print(
        f"  Omission-corrected: mean|tau1-T1|={bias_om_t1:.2f}  "
        f"mean|tau2-T2|={bias_om_t2:.2f}"
    )
    assert bias_naive_t2 < bias_naive_t1
    assert bias_om_t1 < bias_naive_t1 / 2


def check_delta_accuracy() -> None:
    print("\n=== 3. delta_hat accuracy under omission correction (true delta=1.05) ===")
    deltas = []
    for s in range(30):
        y, _t1, _t2 = _sim_knp(s)
        out = dating_knp(y, trim=0.05, omit=True)
        deltas.append(out.delta[0])
    deltas = np.array(deltas)
    print(
        f"  mean delta_hat = {deltas.mean():.3f} (true = 1.05), "
        f"mean|bias| = {np.mean(np.abs(deltas - 1.05)):.3f}"
    )
    assert np.mean(np.abs(deltas - 1.05)) < 0.3


# set.seed(7); y <- round(cumsum(rnorm(40)), 8) -- shared with the SSU script
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,
    ]
)


def check_multi_bubble_dp() -> None:
    """Section 3.2 DP: breaks=2 reproduces the single-bubble search; 3 and 4
    breaks reproduce R's dates and coefficients (Rscript: dating_knp(y,
    trim = 0.1, breaks = b) on Y_VEC)."""
    print("\n=== Multi-bubble dynamic programme ===")
    y = np.cumsum(np.random.default_rng(11).normal(size=80))
    tau, ssr = _knp_dp(y, 2, 0.05, True)
    t1, t2, fssr = _knp_find_break(y, 0.05, True)
    assert tau == [t1, t2] and abs(ssr - fssr) < 1e-10
    print(f"  breaks=2: DP {tau} = single-bubble search ({t1}, {t2})")
    r3 = dating_knp(Y_VEC, trim=0.1, breaks=3)
    r4 = dating_knp(Y_VEC, trim=0.1, breaks=4)
    assert list(r3.origination[:, 0]) == [9, 19] and np.isnan(r3.collapse[1, 0])
    assert list(r4.collapse[:, 0]) == [14, 23]
    np.testing.assert_allclose(r4.delta[:, 0], [0.8410590945, 1.0818531838], atol=1e-8)
    print(f"  breaks=4: origination {r4.origination[:, 0]}, collapse {r4.collapse[:, 0]} (R: 9 19 / 14 23)")


def main() -> None:
    check_formula_exact()
    check_theorem_1_and_2()
    check_delta_accuracy()
    check_multi_bubble_dp()
    print("\nAll dating_knp() checks passed.")


if __name__ == "__main__":
    main()
radf_pdc_validation.R 133 lines
# Replication script for dating_pdc()'s base OLS estimator (Pang, Du & Chong
# 2021 / Kurozumi & Skrobotov 2023 sequential sample-splitting dating).
# Archived retroactively -- see docs/dating-and-root-inference.md,
# "SSR/BIC dating vs. PSY recursive dating", "Implementation (PDC/KS route)"
# for the narrative this reproduces. The WLS variant (Kurozumi & Skrobotov
# 2023's volatility correction) already has its own replication scripts
# (radf_pdc_wls_*_mae.R in this folder); this one covers the base
# dating_pdc(..., type = "ols") route those build on. Different seeds/sample
# sizes from test-pdc.R throughout, per this project's convention of an
# independent check rather than a re-execution of the same numbers.
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula check: pdc_find_break() vs brute-force lm() RSS scan ===\n")
set.seed(9001)
y <- cumsum(rnorm(120))
trim <- 0.05
res <- exuber:::pdc_find_break(y, trim)

n1 <- length(y) - 1L
ylag <- y[1:n1]
ycur <- y[2:(n1 + 1)]
k_min <- max(2L, ceiling(trim * n1))
k_max <- n1 - k_min
rss_brute <- sapply(k_min:k_max, function(k) {
  left <- 1:k
  right <- (k + 1):n1
  sum(lm(ycur[left] ~ ylag[left] - 1)$residuals^2) +
    sum(lm(ycur[right] ~ ylag[right] - 1)$residuals^2)
})
brute_break <- (k_min:k_max)[which.min(rss_brute)]
cat("closed-form break_idx:", res$break_idx, " brute-force break_idx:", brute_break, "\n")
cat("abs diff in rss:", abs(res$rss - min(rss_brute)), "\n\n")

cat("=== 2. 3-regime consistency in the low-noise/long-series/strong-effect limit ===\n")
set.seed(4001)
n1_len <- 400
n2_len <- 200
n3_len <- 250
regime1 <- cumsum(rnorm(n1_len, sd = 1))
regime2 <- regime1[n1_len] * 1.08^(1:n2_len) + cumsum(rnorm(n2_len, sd = 0.1))
peak <- regime2[n2_len]
rho3 <- 0.5
regime3 <- numeric(n3_len)
regime3[1] <- rho3 * peak + rnorm(1, sd = 0.5)
for (t in 2:n3_len) regime3[t] <- rho3 * regime3[t - 1] + rnorm(1, sd = 0.5)
y3 <- c(regime1, regime2, regime3)

out3 <- dating_pdc(y3, regimes = 3L, trim = 0.05)
true_origination <- n1_len
true_collapse <- n1_len + n2_len
cat("origination: true =", true_origination, " estimated =", out3$origination,
  " |err| =", abs(out3$origination - true_origination), "\n"
)
cat("collapse:    true =", true_collapse, " estimated =", out3$collapse,
  " |err| =", abs(out3$collapse - true_collapse), "\n\n"
)

cat("=== 3. 4-regime (KS extension) consistency in the same low-noise limit ===\n")
set.seed(4002)
n1_len <- 300
n2_len <- 150
n3_len <- 200
n4_len <- 200
regime1 <- cumsum(rnorm(n1_len, sd = 0.5))
regime2 <- regime1[n1_len] * 1.08^(1:n2_len) + cumsum(rnorm(n2_len, sd = 0.1))
peak <- regime2[n2_len]
rho3 <- 0.5
regime3 <- numeric(n3_len)
regime3[1] <- rho3 * peak + rnorm(1, sd = 1)
for (t in 2:n3_len) regime3[t] <- rho3 * regime3[t - 1] + rnorm(1, sd = 1)
regime4 <- regime3[n3_len] + cumsum(rnorm(n4_len, sd = 0.5))
y4 <- c(regime1, regime2, regime3, regime4)

out4 <- dating_pdc(y4, regimes = 4L, trim = 0.05)
true_origination <- n1_len
true_collapse <- n1_len + n2_len
true_recovery <- n1_len + n2_len + n3_len
cat("origination: true =", true_origination, " estimated =", out4$origination,
  " |err| =", abs(out4$origination - true_origination), "\n"
)
cat("collapse:    true =", true_collapse, " estimated =", out4$collapse,
  " |err| =", abs(out4$collapse - true_collapse), "\n"
)
cat("recovery:    true =", true_recovery, " estimated =", out4$recovery,
  " |err| =", abs(out4$recovery - true_recovery), "\n\n"
)

cat("=== 4. Honest moderate-T characterization (KS's own MC: ~30% exact\n")
cat("    recovery at T=400) -- not asserting tight accuracy, just reporting it ===\n")
n1_len <- 160
n2_len <- 80
n3_len <- 110
exact_orig <- exact_coll <- logical(30)
abs_err_orig <- abs_err_coll <- numeric(30)
for (s in 1:30) {
  set.seed(s + 5000)
  regime1 <- cumsum(rnorm(n1_len, sd = 1))
  regime2 <- regime1[n1_len] * 1.05^(1:n2_len) + cumsum(rnorm(n2_len, sd = 0.2))
  peak <- regime2[n2_len]
  rho3 <- 0.5
  regime3 <- numeric(n3_len)
  regime3[1] <- rho3 * peak + rnorm(1, sd = 0.5)
  for (t in 2:n3_len) regime3[t] <- rho3 * regime3[t - 1] + rnorm(1, sd = 0.5)
  y <- c(regime1, regime2, regime3)
  out <- dating_pdc(y, regimes = 3L, trim = 0.05)
  exact_orig[s] <- out$origination == n1_len
  exact_coll[s] <- out$collapse == (n1_len + n2_len)
  abs_err_orig[s] <- abs(out$origination - n1_len)
  abs_err_coll[s] <- abs(out$collapse - (n1_len + n2_len))
}
cat("T =", n1_len + n2_len + n3_len, ", 30 seeds\n")
cat("exact-date recovery rate: origination =", mean(exact_orig),
  " collapse =", mean(exact_coll), "\n"
)
cat("mean |error|: origination =", round(mean(abs_err_orig), 2),
  " collapse =", round(mean(abs_err_coll), 2), "\n"
)
cat("(KS's own Monte Carlo reports ~30% exact-date recovery at T=400. Own\n")
cat(" run's exact-hit rate came in lower than that on this DGP, but the\n")
cat(" mean |error| is small (collapse off by ~1 on average, never more) --\n")
cat(" consistent with the same qualitative point KS make (moderate-T exact\n")
cat(" -date recovery is genuinely limited, not near 100%), even though the\n")
cat(" precise rate is DGP-specific and not expected to match their exact\n")
cat(" figure on a different synthetic design.)\n\n")

cat("=== Full test-pdc.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-pdc.R",
  reporter = "summary"
)
radf_pdc_validation.py 173 lines
"""Python replication script for dating_pdc() (Pang, Du & Chong 2021 /
Kurozumi & Skrobotov 2023 sequential sample-splitting bubble dating, both
the base OLS route and the WLS volatility correction), cross-checking
pyexuber's port against radf_pdc_validation.R and the two
radf_pdc_wls_*_mae.R scripts in this same folder.

RNG note: numpy's Generator, not R's RNG -- independent seeds, same
qualitative checks (see rootstamp_validation.py's module docstring for the
same convention). R-derived reference numbers come from:

    cd exuber-project && Rscript docs/replication/dating-and-root-inference/radf_pdc_validation.R
    cd exuber-project && Rscript docs/replication/dating-and-root-inference/radf_pdc_wls_homoskedastic_mae.R
    cd exuber-project && Rscript docs/replication/dating-and-root-inference/radf_pdc_wls_heteroskedastic_mae.R

R 4.6.1:
  - formula check: closed-form break_idx == brute-force break_idx, abs
    diff in rss = 1.28e-13
  - 3-regime low-noise limit: origination |err|=1, collapse |err|=1 (of 400/600)
  - 4-regime low-noise limit: all three breaks |err|=1 (of 300/450/650)
  - homoskedastic MAE: OLS origination=5.42, WLS=5.53 (WLS costs ~nothing)
  - heteroskedastic (volatility burst) MAE: OLS origination=13.05, WLS=2.33
    (WLS materially better)

Run standalone: uv run --project pyexuber python
docs/replication/dating-and-root-inference/radf_pdc_validation.py
"""

import numpy as np

from exuber.dating_pdc import _pdc_find_break, dating_pdc


def check_formula_exact() -> None:
    print("=== 1. Formula check: _pdc_find_break() vs brute-force RSS scan ===")
    rng = np.random.default_rng(9001)
    y = np.cumsum(rng.normal(size=120))
    trim = 0.05
    break_idx, rss = _pdc_find_break(y, trim)

    n1 = len(y) - 1
    ylag, ycur = y[:n1], y[1 : n1 + 1]
    k_min = max(2, int(np.ceil(trim * n1)))
    k_max = n1 - k_min

    def rss_ols(x, yv):
        beta = np.sum(x * yv) / np.sum(x * x)
        return np.sum((yv - beta * x) ** 2)

    ks = list(range(k_min, k_max + 1))
    rss_brute = [rss_ols(ylag[:k], ycur[:k]) + rss_ols(ylag[k:n1], ycur[k:n1]) for k in ks]
    brute_break = ks[int(np.argmin(rss_brute))]

    print(f"  closed-form break_idx: {break_idx}  brute-force break_idx: {brute_break}")
    print(f"  abs diff in rss: {abs(rss - min(rss_brute))}")
    assert break_idx == brute_break
    assert abs(rss - min(rss_brute)) < 1e-8


def _three_regime(rng, n1_len, n2_len, n3_len, scales=(1, 0.1, 0.5), rho3=0.5, c=1.08):
    s1, s2, s3 = scales
    regime1 = np.cumsum(rng.normal(size=n1_len, scale=s1))
    regime2 = regime1[-1] * c ** np.arange(1, n2_len + 1) + np.cumsum(rng.normal(size=n2_len, scale=s2))
    peak = regime2[-1]
    regime3 = np.zeros(n3_len)
    regime3[0] = rho3 * peak + rng.normal(scale=s3)
    for t in range(1, n3_len):
        regime3[t] = rho3 * regime3[t - 1] + rng.normal(scale=s3)
    return regime1, regime2, regime3


def check_3regime_consistency() -> None:
    print("\n=== 2. 3-regime consistency in the low-noise/long-series/strong-effect limit ===")
    rng = np.random.default_rng(4001)
    n1_len, n2_len, n3_len = 400, 200, 250
    regime1, regime2, regime3 = _three_regime(rng, n1_len, n2_len, n3_len)
    y = np.concatenate([regime1, regime2, regime3])

    out = dating_pdc(y, regimes=3, trim=0.05)
    true_o, true_c = n1_len, n1_len + n2_len
    print(f"  origination: true={true_o}  est={out.origination[0]}  |err|={abs(out.origination[0]-true_o)}")
    print(f"  collapse:    true={true_c}  est={out.collapse[0]}  |err|={abs(out.collapse[0]-true_c)}")
    assert abs(out.origination[0] - true_o) <= 2
    assert abs(out.collapse[0] - true_c) <= 2


def check_4regime_consistency() -> None:
    print("\n=== 3. 4-regime (KS extension) consistency in the same low-noise limit ===")
    rng = np.random.default_rng(4)
    n1_len, n2_len, n3_len, n4_len = 400, 200, 250, 250
    regime1, regime2, regime3 = _three_regime(rng, n1_len, n2_len, n3_len, scales=(0.3, 0.05, 0.5))
    regime4 = regime3[-1] + np.cumsum(rng.normal(size=n4_len, scale=0.3))
    y = np.concatenate([regime1, regime2, regime3, regime4])

    out = dating_pdc(y, regimes=4, trim=0.05)
    true_o, true_c, true_r = n1_len, n1_len + n2_len, n1_len + n2_len + n3_len
    print(f"  origination: true={true_o}  est={out.origination[0]}  |err|={abs(out.origination[0]-true_o)}")
    print(f"  collapse:    true={true_c}  est={out.collapse[0]}  |err|={abs(out.collapse[0]-true_c)}")
    print(f"  recovery:    true={true_r}  est={out.recovery[0]}  |err|={abs(out.recovery[0]-true_r)}")
    assert abs(out.origination[0] - true_o) <= 3
    assert abs(out.collapse[0] - true_c) <= 3
    assert abs(out.recovery[0] - true_r) <= 5


def check_wls_homoskedastic_mae() -> None:
    print("\n=== 4. WLS vs OLS MAE under homoskedasticity (should be close) ===")
    n1_len, n2_len, n3_len = 150, 80, 100

    def run(seed):
        rng = np.random.default_rng(seed)
        regime1, regime2, regime3 = _three_regime(
            rng, n1_len, n2_len, n3_len, scales=(0.5, 0.15, 0.5), c=1.07
        )
        y = np.concatenate([regime1, regime2, regime3])
        true_o, true_c = n1_len, n1_len + n2_len
        out_ols = dating_pdc(y, regimes=3, trim=0.05, type="ols")
        out_wls = dating_pdc(y, regimes=3, trim=0.05, type="wls")
        return (
            abs(out_ols.origination[0] - true_o), abs(out_ols.collapse[0] - true_c),
            abs(out_wls.origination[0] - true_o), abs(out_wls.collapse[0] - true_c),
        )

    res = np.array([run(s) for s in range(40)])
    mae = res.mean(axis=0)
    print(f"  Origination MAE: OLS={mae[0]:.2f}  WLS={mae[2]:.2f}")
    print(f"  Collapse MAE:     OLS={mae[1]:.2f}  WLS={mae[3]:.2f}")
    # WLS shouldn't be dramatically worse when there's no volatility signal
    assert mae[2] < 2 * mae[0] + 1


def check_wls_heteroskedastic_mae() -> None:
    print("\n=== 5. WLS vs OLS MAE under a volatility burst (WLS should win) ===")
    n1_len, n2_len, n3_len = 150, 80, 100
    burst_len = round(0.2 * n1_len)

    def run(seed):
        rng = np.random.default_rng(seed)
        e1 = np.concatenate(
            [rng.normal(size=burst_len, scale=4), rng.normal(size=n1_len - burst_len, scale=0.3)]
        )
        regime1 = np.cumsum(e1)
        regime2 = regime1[-1] * 1.07 ** np.arange(1, n2_len + 1) + np.cumsum(
            rng.normal(size=n2_len, scale=0.15)
        )
        peak = regime2[-1]
        rho3 = 0.5
        regime3 = np.zeros(n3_len)
        regime3[0] = rho3 * peak + rng.normal(scale=0.5)
        for t in range(1, n3_len):
            regime3[t] = rho3 * regime3[t - 1] + rng.normal(scale=0.5)
        y = np.concatenate([regime1, regime2, regime3])
        true_o = n1_len
        out_ols = dating_pdc(y, regimes=3, trim=0.05, type="ols")
        out_wls = dating_pdc(y, regimes=3, trim=0.05, type="wls")
        return abs(out_ols.origination[0] - true_o), abs(out_wls.origination[0] - true_o)

    res = np.array([run(s) for s in range(40)])
    mae_ols, mae_wls = res.mean(axis=0)
    print(f"  Origination MAE: OLS={mae_ols:.2f}  WLS={mae_wls:.2f}  (WLS better if smaller)")
    assert mae_wls < mae_ols


def main() -> None:
    check_formula_exact()
    check_3regime_consistency()
    check_4regime_consistency()
    check_wls_homoskedastic_mae()
    check_wls_heteroskedastic_mae()
    print("\nAll dating_pdc() checks passed.")


if __name__ == "__main__":
    main()
radf_pdc_wls_heteroskedastic_mae.R 55 lines
devtools::load_all("exuber", quiet = TRUE)

# DGP: 3-regime bubble (unit root -> explosive -> stationary collapse), with
# a volatility burst concentrated in the FIRST 20% of regime 1 (sd = high),
# dropping to a much smaller sd for the rest of regime 1. This mirrors the
# KS(2023) WLS paper's own finding that the correction helps most when a
# volatility break sits near the start/end of the sample: the noisy early
# segment inflates OLS's unweighted objective and can bias the origination
# split; WLS should downweight it via the estimated spot variance.
run_once <- function(seed) {
  set.seed(seed)
  n1_len <- 150; n2_len <- 80; n3_len <- 100
  burst_len <- round(0.2 * n1_len)

  e1 <- c(rnorm(burst_len, sd = 4), rnorm(n1_len - burst_len, sd = 0.3))
  regime1 <- cumsum(e1)
  regime2 <- regime1[n1_len] * 1.07^(1:n2_len) + cumsum(rnorm(n2_len, sd = 0.15))
  peak <- regime2[n2_len]
  rho3 <- 0.5
  regime3 <- numeric(n3_len)
  regime3[1] <- rho3 * peak + rnorm(1, sd = 0.5)
  for (t in 2:n3_len) regime3[t] <- rho3 * regime3[t - 1] + rnorm(1, sd = 0.5)

  y <- c(regime1, regime2, regime3)
  true_origination <- n1_len
  true_collapse <- n1_len + n2_len

  out_ols <- dating_pdc(y, regimes = 3L, trim = 0.05, type = "ols")
  out_wls <- dating_pdc(y, regimes = 3L, trim = 0.05, type = "wls")

  c(
    ols_orig_err = out_ols$origination - true_origination,
    ols_coll_err = out_ols$collapse - true_collapse,
    wls_orig_err = out_wls$origination - true_origination,
    wls_coll_err = out_wls$collapse - true_collapse
  )
}

res <- t(sapply(1:40, run_once))
cat("Per-seed errors (first 10 rows):\n")
print(head(res, 10))

mae <- colMeans(abs(res))
cat("\nMean absolute error across 40 seeds:\n")
print(mae)

cat(sprintf(
  "\nOrigination MAE: OLS=%.2f  WLS=%.2f  (WLS better if smaller)\n",
  mae["ols_orig_err"], mae["wls_orig_err"]
))
cat(sprintf(
  "Collapse MAE:     OLS=%.2f  WLS=%.2f  (WLS better if smaller)\n",
  mae["ols_coll_err"], mae["wls_coll_err"]
))
radf_pdc_wls_homoskedastic_mae.R 40 lines
devtools::load_all("exuber", quiet = TRUE)

run_once <- function(seed) {
  set.seed(seed)
  n1_len <- 150; n2_len <- 80; n3_len <- 100
  regime1 <- cumsum(rnorm(n1_len, sd = 0.5))
  regime2 <- regime1[n1_len] * 1.07^(1:n2_len) + cumsum(rnorm(n2_len, sd = 0.15))
  peak <- regime2[n2_len]
  rho3 <- 0.5
  regime3 <- numeric(n3_len)
  regime3[1] <- rho3 * peak + rnorm(1, sd = 0.5)
  for (t in 2:n3_len) regime3[t] <- rho3 * regime3[t - 1] + rnorm(1, sd = 0.5)
  y <- c(regime1, regime2, regime3)
  true_origination <- n1_len
  true_collapse <- n1_len + n2_len

  out_ols <- dating_pdc(y, regimes = 3L, trim = 0.05, type = "ols")
  out_wls <- dating_pdc(y, regimes = 3L, trim = 0.05, type = "wls")

  c(
    ols_orig_err = out_ols$origination - true_origination,
    ols_coll_err = out_ols$collapse - true_collapse,
    wls_orig_err = out_wls$origination - true_origination,
    wls_coll_err = out_wls$collapse - true_collapse
  )
}

res <- t(sapply(1:40, run_once))
mae <- colMeans(abs(res))
cat("Homoskedastic case -- mean absolute error across 40 seeds:\n")
print(mae)
cat(sprintf(
  "\nOrigination MAE: OLS=%.2f  WLS=%.2f  (should be close -- no volatility signal to exploit)\n",
  mae["ols_orig_err"], mae["wls_orig_err"]
))
cat(sprintf(
  "Collapse MAE:     OLS=%.2f  WLS=%.2f\n",
  mae["ols_coll_err"], mae["wls_coll_err"]
))
radf_recovery_validation.R 91 lines
# Validation script for radf_recovery()/radf_recovery_cv() (Phillips &
# Shi 2014, "Financial Bubble Implosion and Reverse Regression").
#
# Reports both what validates cleanly (f_r, the structural f_c<=f_r
# invariant, the reversal-calibrated CV genuinely differing from the
# forward one) and what doesn't (f_c's bias, the H0 false-detection
# rate) -- see docs/dating-and-root-inference.md,
# "Reverse-regression recovery dating", for the full write-up and honest
# accounting of these results. Run from the exuber-project/ root (or
# adjust the devtools::load_all() path below).

Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

n <- 100; minw <- 20; lvl_lab <- "95%"

cat("=== 1. Reversal-calibrated CV differs from forward CV ===\n")
cat("(paired Monte Carlo, same underlying draws, confirms Theorem 1's\n")
cat("endogeneity finding empirically rather than just asymptotically)\n\n")
nrep_cv_check <- 5000
set.seed(7)
res_fwd <- matrix(NA_real_, nrow = n - minw, ncol = nrep_cv_check)
res_rev <- matrix(NA_real_, nrow = n - minw, ncol = nrep_cv_check)
for (i in 1:nrep_cv_check) {
  y <- cumsum(rnorm(n))
  res_fwd[, i] <- exuber:::rls_gsadf(exuber:::unroot(y, lag = 0), min_win = minw, lag = 0)[1:(n - minw)]
  res_rev[, i] <- exuber:::rls_gsadf(exuber:::unroot(rev(y), lag = 0), min_win = minw, lag = 0)[1:(n - minw)]
}
cv_fwd <- t(apply(apply(res_fwd, 2, cummax), 1, quantile, probs = c(0.9, 0.95, 0.99)))
cv_rev <- t(apply(apply(res_rev, 2, cummax), 1, quantile, probs = c(0.9, 0.95, 0.99)))
cat("Max abs diff (95% col):", max(abs(cv_fwd[, "95%"] - cv_rev[, "95%"])), "\n")
cat("Mean abs diff (95% col):", mean(abs(cv_fwd[, "95%"] - cv_rev[, "95%"])), "\n\n")

cat("=== 2. radf_recovery_cv(), one stable estimate (nrep=1000) reused below ===\n\n")
cv <- radf_recovery_cv(n = n, minw = minw, nrep = 1000, seed = 99)
zadj <- minw

detect_once <- function(y, cv) {
  fit <- radf(rev(y), minw = minw)
  exceed <- fit$bsadf[, 1] > cv$bsadf_cv[, lvl_lab]
  g_e <- which(exceed)[1L]
  if (is.na(g_e)) return(c(detected = FALSE, censored = FALSE, f_c = NA, f_r = NA))
  after <- which(!exceed[g_e:length(exceed)])
  if (length(after) == 0L) {
    g_c <- length(exceed) + 1L; censored <- TRUE
  } else {
    g_c <- g_e + after[1L] - 1L; censored <- FALSE
  }
  f_r <- n + 1L - (g_e + zadj)
  f_c <- if (censored) NA else n + 1L - min(g_c + zadj, n)
  c(detected = TRUE, censored = censored, f_c = f_c, f_r = f_r)
}

cat("=== 3. Empirical false-detection rate under pure H0 (random walk) ===\n")
cat("(200 fresh draws against the one stable cv from step 2)\n\n")
set.seed(123)
res_h0 <- t(sapply(1:200, function(i) detect_once(cumsum(rnorm(n)), cv)))
cat(sprintf("False-detection rate: %.3f  (nominal level: %s; higher than\n", mean(res_h0[, "detected"] == 1), lvl_lab))
cat("comparable forward-test numbers elsewhere in this project)\n\n")

cat("=== 4. Detection accuracy on a synthetic collapse-then-recovery DGP ===\n")
cat("(smooth, continuous mean-reverting collapse; an abrupt level jump would\n")
cat("produce a spurious spike at the regime boundary)\n\n")
run_detect <- function(seed, cv) {
  set.seed(seed)
  n1 <- 40; n2 <- 25; n3 <- 35
  expansion <- 100 * 1.03^(1:n1) + cumsum(rnorm(n1, sd = 1))
  target <- expansion[n1] * 0.5
  collapse <- numeric(n2)
  collapse[1] <- expansion[n1] + rnorm(1, sd = 1)
  for (k in 2:n2) collapse[k] <- target + 0.9 * (collapse[k - 1] - target) + rnorm(1, sd = 1)
  recovery <- collapse[n2] + cumsum(rnorm(n3, sd = 1)) + (1:n3) * 0.5
  y <- c(expansion, collapse, recovery)
  out <- detect_once(y, cv)
  c(out, true_collapse = n1, true_recovery = n1 + n2)
}
res <- t(sapply(1:40, function(s) run_detect(s, cv)))
cat(sprintf("Detection rate: %.3f\n", mean(res[, "detected"] == 1)))
det <- res[res[, "detected"] == 1 & res[, "censored"] == 0, , drop = FALSE]
cat(sprintf("n detected & uncensored = %d\n", nrow(det)))
cat(sprintf("mean f_c bias = %.2f, mean f_r bias = %.2f\n",
            mean(det[, "f_c"] - det[, "true_collapse"]), mean(det[, "f_r"] - det[, "true_recovery"])))
cat(sprintf("mean |f_c bias| = %.2f, mean |f_r bias| = %.2f  (f_r matches the\n",
            mean(abs(det[, "f_c"] - det[, "true_collapse"])), mean(abs(det[, "f_r"] - det[, "true_recovery"]))))
cat("paper's own ~6-observation-early finding; f_c's bias is materially\n")
cat("larger and not fully resolved -- see taxonomy file for discussion)\n\n")

cat("=== 5. Structural invariant: f_c <= f_r whenever both identified & uncensored ===\n")
cat("all TRUE:", all(det[, "f_c"] <= det[, "f_r"]), "\n")
radf_recovery_validation.py 121 lines
"""Python replication script for radf_recovery()/radf_recovery_cv()
(Phillips & Shi 2014 reverse-regression crisis-origination/market-recovery
dating), cross-checking pyexuber's port against radf_recovery_validation.R
in this same folder.

Environment note: unlike rootstamp()/dating_pdc() (pure numpy),
radf_recovery() calls radf() itself, which needs pyexuber's compiled C++
extension (exuber._core). The script is run by
pyexuber/tests/test_dating_validation.py in CI, which builds the extension
on ubuntu, macos and windows. Its assertions are structural and loose
(invariants and sane ranges), not exact-number matches.

RNG note: numpy's Generator, not R's RNG (see rootstamp_validation.py's
module docstring for the same convention elsewhere in this port).

R's own script (docs/dating-and-root-inference.md,
"Reverse-regression recovery dating"), reports for context (not asserted
here bit-for-bit): max abs CV diff ~0.11, mean abs CV diff ~0.04 at the
95% level; H0 false-detection rate ~29% (n=100, minw=20); f_r mean
|bias| ~a few observations (paper's own ~6-early finding); f_c's bias
materially larger.

Run standalone (needs the built extension):
uv run --project pyexuber python
docs/replication/dating-and-root-inference/radf_recovery_validation.py
"""

import warnings

import numpy as np

from exuber.cv import radf_mc_cv
from exuber.radf_recovery import radf_recovery, radf_recovery_cv


def check_reversal_calibrated_cv_differs_from_forward() -> None:
    print("=== 1. Reversal-calibrated CV differs from forward CV ===")
    print("(confirms Theorem 1's endogeneity finding: the reverse-time")
    print(" regression has no forward-regression analogue, so its null")
    print(" distribution -- and hence critical values -- genuinely differ)\n")
    n, minw = 100, 20
    fwd = radf_mc_cv(n, minw=minw, nrep=2000, seed=7)
    rev = radf_recovery_cv(n, minw=minw, nrep=2000, seed=7)
    diff_95 = np.abs(fwd.bsadf_cv[:, 1] - rev.bsadf_cv[:, 1])
    print(f"  max abs diff (95% col):  {diff_95.max()}")
    print(f"  mean abs diff (95% col): {diff_95.mean()}")
    # a genuinely different distribution shows up as a nontrivial gap --
    # not asserting the exact R figures (different RNG/nrep), just that
    # the two boundaries aren't the same (would be ~0 diff if they were).
    assert diff_95.mean() > 0.005


def check_h0_false_detection_rate() -> None:
    print("\n=== 2. False-detection rate under pure H0 (random walk) ===")
    n, minw = 100, 20
    cv_nrep = 500  # smaller than R's 1000/5000 to keep CI runtime reasonable
    rng = np.random.default_rng(123)
    detected = []
    for _ in range(60):
        y = np.cumsum(rng.normal(size=n))
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            out = radf_recovery(y, minw=minw, nrep=cv_nrep, seed=1)
        detected.append(bool(out.detected[0]))
    rate = np.mean(detected)
    print(f"  False-detection rate: {rate:.3f} (R's own run: ~0.29 -- noisier")
    print("  than comparable forward-test numbers; flagged honestly as")
    print("  unresolved, see docs/dating-and-root-inference.md)")
    # loose sanity bound only, matching R test-recovery.R's own "<= 0.5" check
    assert rate <= 0.6


def check_detection_accuracy_and_invariant() -> None:
    print("\n=== 3. Detection accuracy + f_c <= f_r invariant on a synthetic")
    print("    collapse-then-recovery DGP ===")
    n1, n2, n3 = 40, 25, 35
    true_collapse, true_recovery = n1, n1 + n2

    def run(seed):
        rng = np.random.default_rng(seed)
        expansion = 100 * 1.03 ** np.arange(1, n1 + 1) + np.cumsum(rng.normal(size=n1))
        target = expansion[-1] * 0.5
        collapse = np.empty(n2)
        collapse[0] = expansion[-1] + rng.normal(scale=1)
        for k in range(1, n2):
            collapse[k] = target + 0.9 * (collapse[k - 1] - target) + rng.normal(scale=1)
        recovery = collapse[-1] + np.cumsum(rng.normal(size=n3)) + np.arange(1, n3 + 1) * 0.5
        y = np.concatenate([expansion, collapse, recovery])
        with warnings.catch_warnings():
            warnings.simplefilter("ignore")
            out = radf_recovery(y, minw=15, nrep=200, seed=1)
        return out

    results = [run(s) for s in range(20)]
    detected = [r for r in results if r.detected[0]]
    uncensored = [r for r in detected if not r.censored[0]]
    print(f"  Detection rate: {len(detected) / len(results):.3f}")
    print(f"  n detected & uncensored = {len(uncensored)}")
    if uncensored:
        f_c_bias = [r.f_c[0] - true_collapse for r in uncensored]
        f_r_bias = [r.f_r[0] - true_recovery for r in uncensored]
        print(f"  mean f_c bias = {np.mean(f_c_bias):.2f}, mean f_r bias = {np.mean(f_r_bias):.2f}")
        print(
            f"  mean |f_c bias| = {np.mean(np.abs(f_c_bias)):.2f}, "
            f"mean |f_r bias| = {np.mean(np.abs(f_r_bias)):.2f}"
        )
        print("  (R's own run: f_r matches the paper's ~6-observation-early")
        print("  finding; f_c's bias is materially larger, not fully resolved)")
        assert all(r.f_c[0] <= r.f_r[0] for r in uncensored)


def main() -> None:
    check_reversal_calibrated_cv_differs_from_forward()
    check_h0_false_detection_rate()
    check_detection_accuracy_and_invariant()
    print("\nAll radf_recovery() structural checks passed.")


if __name__ == "__main__":
    main()
rootstamp_validation.R 67 lines
# Replication script for rootstamp() (root inference: the Guo, Sun & Wang (2019)
# normal-t interval and the Phillips-Magdalinos (2007) Cauchy interval).
# See docs/dating-and-root-inference.md, "Root inference". The script checks
# the Cauchy percentiles, the point estimate and the empirical coverage.
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Cauchy percentiles (Skrobotov 2023 review's footnote 17) ===\n")
cat("Published: C_0.10=6.315, C_0.05=12.7, C_0.01=63.65674\n")
cat("qt(0.95, df=1) =", qt(0.95, df = 1), "\n")
cat("qt(0.975, df=1) =", qt(0.975, df = 1), "\n")
cat("qt(0.995, df=1) =", qt(0.995, df = 1), "\n\n")

cat("=== 2. Point estimate: super-consistency at rho=1.05, n=200 ===\n")
set.seed(8675309)
n <- 200
y <- numeric(n)
e <- rnorm(n)
for (t in 2:n) y[t] <- 1.05 * y[t - 1] + e[t]
ci <- rootstamp(y)
cat("rho_hat =", round(ci$rho, 4), "vs rho_true = 1.0500\n")
cat("95% CI:", round(ci$rho_ci, 6), "\n\n")

cat("=== 3. Coverage (rho=1.05, n=200, 800 reps, seed=24601) ===\n")
cat("Published: 94.6% coverage of nominal 95%\n")
set.seed(24601)
covered <- replicate(800, {
  y <- numeric(200)
  e <- rnorm(200)
  for (t in 2:200) y[t] <- 1.05 * y[t - 1] + e[t]
  ci <- rootstamp(y)
  ci$rho_ci[1] <= 1.05 && 1.05 <= ci$rho_ci[2]
})
cat("Own coverage:", mean(covered), "\n\n")

cat("=== 4. Cauchy-type CI (eq. 27, Phillips-Magdalinos 2007) formula-exact check ===\n")
set.seed(11)
y2 <- numeric(150)
e2 <- rnorm(150)
for (t in 2:150) y2[t] <- 1.04 * y2[t - 1] + e2[t]
ci_cauchy <- rootstamp(y2, type = "cauchy", sig_lvl = 95)
q <- qt(0.975, df = 1)
rho_hat <- ci_cauchy$rho
n2 <- ci_cauchy$n
half_width_formula <- q * (rho_hat^2 - 1) / rho_hat^n2
cat("rootstamp(type='cauchy') half-width:", ci_cauchy$rho_ci[2] - ci_cauchy$rho, "\n")
cat("eq. 27 formula half-width:          ", half_width_formula, "\n\n")

cat("=== 5. rootstamp.radf_obj() end-to-end vs calling rootstamp.default() directly ===\n")
set.seed(42)
normal_part <- cumsum(rnorm(90))
expl_part <- normal_part[90] * 1.04^(1:60) + cumsum(rnorm(60, sd = 0.5))
y3 <- c(normal_part, expl_part)
r <- radf(y3, minw = 20)
cv <- radf_mc_cv(length(y3), minw = 20, nrep = 500, seed = 1)
ds <- datestamp(r, cv = cv)
rc <- rootstamp(r, ds)
cat("rootstamp(r, ds) episodes found:", nrow(rc[[1]]), "\n")
print(rc)

cat("\n=== Full test-rootstamp.R suite ===\n")
testthat::test_file(
  "exuber/tests/testthat/test-rootstamp.R",
  reporter = "summary"
)
rootstamp_validation.py 100 lines
"""Python replication script for rootstamp() (root inference: Guo, Sun &
Wang 2019 normal-t CI + Phillips-Magdalinos 2007 Cauchy CI), cross-checking
pyexuber's port against rootstamp_validation.R in this same folder.

RNG note: pyexuber uses numpy's Generator, not R's RNG, so this does not
reproduce the R script's draws bit-for-bit (see cv.py's module docstring
for the same convention elsewhere in this port) -- it independently
re-derives the same qualitative findings with its own seeds. The
R-derived reference numbers hardcoded below come from:

    cd exuber-project && Rscript docs/replication/dating-and-root-inference/rootstamp_validation.R

R 4.6.1:
  - qt(0.95,1)=6.313752, qt(0.975,1)=12.7062, qt(0.995,1)=63.65674
  - coverage (rho=1.05, n=200, 800 reps, seed=24601): 0.94625

Run standalone: uv run --project pyexuber python
docs/replication/dating-and-root-inference/rootstamp_validation.py
"""

import math

import numpy as np

from exuber.rootstamp import rootstamp


def check_cauchy_percentiles() -> None:
    print("=== 1. Cauchy percentiles (Skrobotov 2023 review's footnote 17) ===")
    published = {"10%": 6.313752, "5%": 12.7062, "1%": 63.65674}
    for label, p in [("10%", 0.95), ("5%", 0.975), ("1%", 0.995)]:
        q = math.tan(math.pi * (p - 0.5))  # standard Cauchy quantile, == qt(p, df=1)
        print(f"  {label}: computed={q:.6f}  R's qt()={published[label]:.6f}")
        assert abs(q - published[label]) < 1e-4


def check_point_estimate() -> None:
    print("\n=== 2. Point estimate: super-consistency at rho=1.05, n=200 ===")
    rng = np.random.default_rng(8675309)
    n = 200
    y = np.zeros(n)
    e = rng.normal(size=n)
    for t in range(1, n):
        y[t] = 1.05 * y[t - 1] + e[t]
    fit = rootstamp(y)
    print(f"  rho_hat={fit.rho:.4f} vs rho_true=1.0500")
    print(f"  95% CI: [{fit.rho_ci[0]:.6f}, {fit.rho_ci[1]:.6f}]")
    assert abs(fit.rho - 1.05) < 0.001


def check_coverage() -> None:
    print("\n=== 3. Coverage (rho=1.05, n=200, 800 reps, seed=24601) ===")
    print("R's own run (rootstamp_validation.R, different RNG): 0.94625")
    rng = np.random.default_rng(24601)
    n = 200
    covered = 0
    for _ in range(800):
        y = np.zeros(n)
        e = rng.normal(size=n)
        for t in range(1, n):
            y[t] = 1.05 * y[t - 1] + e[t]
        ci = rootstamp(y)
        if ci.rho_ci[0] <= 1.05 <= ci.rho_ci[1]:
            covered += 1
    coverage = covered / 800
    print(f"  Python coverage: {coverage}")
    # loose band, not an exact match -- different RNG, same qualitative
    # finite-sample-undercoverage finding documented in
    # docs/dating-and-root-inference.md's "Root inference" section.
    assert 0.85 < coverage < 1.0


def check_cauchy_formula_exact() -> None:
    print("\n=== 4. Cauchy-type CI (eq. 27, Phillips-Magdalinos 2007) formula-exact check ===")
    rng = np.random.default_rng(11)
    n = 150
    y = np.zeros(n)
    e = rng.normal(size=n)
    for t in range(1, n):
        y[t] = 1.04 * y[t - 1] + e[t]
    ci = rootstamp(y, type="cauchy", sig_lvl=95)
    q = math.tan(math.pi * (0.975 - 0.5))
    half_width_formula = q * (ci.rho**2 - 1) / ci.rho**ci.n
    half_width_fn = ci.rho_ci[1] - ci.rho
    print(f"  rootstamp(type='cauchy') half-width: {half_width_fn}")
    print(f"  eq. 27 formula half-width:           {half_width_formula}")
    assert abs(half_width_fn - half_width_formula) < 1e-10


def main() -> None:
    check_cauchy_percentiles()
    check_point_estimate()
    check_coverage()
    check_cauchy_formula_exact()
    print("\nAll rootstamp() checks passed.")


if __name__ == "__main__":
    main()