Skip to content
exuber

Replication

Real-time monitoring for bubbles

Sequential and real-time detection: training-vs-monitoring orchestration, CUSUM families, and closed-form boundaries.

This page is the technical record behind the methods. To learn how to run them, start with Real-time monitoring for bubbles in the guide.

Monitoring asks a different question from the retrospective tests. Instead of testing a complete sample, it fixes a training window that is assumed to be free of bubbles and then checks each new observation as it arrives, so that the first alarm comes as soon as the series turns explosive. The methods differ in the detector they use and in how they control the false-alarm rate. There are two families. Family A is the recursive training-maximum detector of Phillips & Shi (2020), which reuses the BSADF sequence of radf(). Family B is the CUSUM family of Homm & Breitung (2012) and its relatives.

Status labels are the ones used in volatility-robustness.md.

MethodPaperStatus
Recursive monitoring, Family A: monitor()Phillips & Shi (2020)done
CUSUM and CUSUMV: monitor_cusum()Homm & Breitung (2012); Astill et al. (2023)done, including the finite-sample boundary of Homm & Breitung
FLUC statistic: monitor(boundary = "fluc")Homm & Breitung (2012)done
Closed-form SADF and GSADF boundariesKurozumi (2020)done: monitor(boundary = "kurozumi", s0 = 0/0.4/0.8)
LBI test and sequential monitoringBreitung & Diegel (2025)done: lbi_test(), monitor_lbi() with the constant boundary mCUSUM/wCUSUM
Robust Chebyshev-type monitoring (RCA)Horváth & Trapani (2023/2026)evaluated, not implemented
Delay-time paperKurozumi (2021)evaluated, not implemented

All papers are listed in references.md.

Sources

  1. Homm, U. & Breitung, J. (2012). Testing for speculative bubbles in stock markets: a comparison of alternative methods. Journal of Financial Econometrics, 10(1), 198–231, doi:10.1093/jjfinec/nbr009.
  2. Astill, S., Harvey, D. I., Leybourne, S. J., Taylor, A. M. R. & Zu, Y. (2023). CUSUM-Based Monitoring for Explosive Episodes in Financial Data in the Presence of Time-Varying Volatility. Journal of Financial Econometrics, 21(1), 187–227, doi:10.1093/jjfinec/nbab009 (“AHLTZ”). The earlier companion, Astill, Harvey, Leybourne, Sollis & Taylor (2018), Real-Time Monitoring for Explosive Financial Bubbles, JTSA, 39, 863–891 (“AHLST”), compares each monitoring statistic with the maximum of the training sample and not with a CUSUM boundary.
  3. Whitehouse, E. J., Harvey, D. I. & Leybourne, S. J. (2025). Real-time monitoring procedures for early detection of bubbles. International Journal of Forecasting, 41(3), 1260–1277, doi:10.1016/j.ijforecast.2024.12.005. Open access. It supplies the AHLST decision rule and false-positive-rate formula below.
  4. Kurozumi, E. (2020). Asymptotic properties of bubble monitoring tests. Econometric Reviews, 39(5), 510–538, doi:10.1080/07474938.2019.1697086. It extends SADF and GSADF to a monitoring scheme, studies a CUSUM detector next to them and derives monitoring-period critical values under moderate-deviation and local-to-unity asymptotics. Kurozumi, E. (2021). Asymptotic Behavior of Delay Times of Bubble Monitoring Tests. JTSA, 42(3), 314–337, doi:10.1111/jtsa.12569, concerns the stochastic order of the detection delay.
  5. Horváth, L. & Trapani, L. (2026). Real-time monitoring with RCA models. Econometric Theory, 42, 514–547. Working paper arXiv:2312.11710.
  6. Astill, S., Taylor, A. M. R. & Zu, Y. (2026, forthcoming). Covariate Augmented CUSUM Bubble Monitoring Procedures. Econometric Theory. Essex Finance Centre Working Paper No. 94. Section 3 restates the CUSUM statistic and boundary of Homm & Breitung and the volatility-robust modification of Astill et al., with equation numbers and page references.
  7. Breitung, J. & Diegel, M. (2025). Sequential Detector Statistics for Speculative Bubbles. JTSA, 46(5).

The two families

Family A compares a training maximum with the monitoring statistic (AHLST, Whitehouse et al., Phillips & Shi). The sample is split into a training period t=1,…,T∗t = 1, \dots, T^*, assumed free of bubbles, and a monitoring period t=T∗+1,…,Tt = T^*+1, \dots, T. A recursive statistic Ae,kA_{e,k} is computed over sub-samples of fixed length kk in both periods. The training maximum Amax⁡∗=max⁡Ae,kA^*_{\max} = \max A_{e,k} becomes a fixed critical value, and monitoring rejects H0H_0 at the first ee with Ae,k>Amax⁡∗A_{e,k} > A^*_{\max}. AHLST prove a closed-form asymptotic false-positive rate (FPR) that depends only on the ratio of training to monitoring length (eq. 6 below). Phillips & Shi use a bootstrap in place of the closed form.

Family B uses CUSUM and Page-CUSUM detectors (Homm & Breitung, Astill et al., Kurozumi’s CUSUM variant, Horváth & Trapani, Breitung & Diegel). A partial sum of standardised first differences Δyt\Delta y_t is accumulated from the end of the training sample and compared with a boundary that grows with tt, for example cttc_t \sqrt t. The boundary is chosen so that the cumulative false-alarm probability over the whole, possibly infinite, monitoring horizon stays below α\alpha. This is a running standardised sum, not a recursive ADF regression. The volatility-robust variants replace the standardisation by a kernel spot-variance estimate, which resembles the kernel machinery of SBZ. Horváth & Trapani extend the idea to random-coefficient autoregressions (RCA) with weighted CUSUM and Page-CUSUM detectors that work for transitions in both directions between stationary and explosive regimes.

Kurozumi (2020) places SADF/GSADF-type (Family A) and CUSUM-type (Family B) monitoring in one asymptotic framework, derives monitoring-period critical values for both and studies the detection delay separately. CUSUM detects an early, short bubble faster, and ADF/BSADF-type detectors detect a middle-to-late bubble faster. A union of rejections that combines BSADF and CUSUM is possible, and is the monitoring counterpart of the SBZ union statistic.

Recursive monitoring: monitor()

Status: done. monitor(data, r_star = 0.5, minw, nboot, level, adflag, type, seed) is in exuber/R/monitor.R.

Two facts make it a thin layer over existing code.

  1. radf_wb_ps_cv(..., tb = T*) computes the training-window wild-bootstrap critical value that monitoring needs and broadcasts it as a constant boundary across the monitoring horizon.
  2. The BSADF statistic of radf() at calendar time tt depends only on data up to tt and equals the last BSADF value of a fresh radf(y[1:t]). The whole monitoring path therefore comes from one full-sample radf() call, which is O(T)O(T).

To avoid look-ahead, monitor() calls radf_wb_ps_cv() on data[1:T*] only. Inside radf_wb_ps_cv() the null-model fit (adf_res() in radf_wb.R) uses all the data it receives to estimate the bootstrap residuals and coefficients, and tb only truncates the simulated bootstrap sample. Passing the full series would let post-T∗T^*, possibly explosive, data influence the training-window calibration.

Validation.

  1. A basic run returns a well-formed object, with T_star and a bsadf row count of n - minw. An alarm always falls strictly after T∗T^* (checked over 10 seeds with a post-training bubble).
  2. Under H0H_0 (no bubble, 40 replications, 75-observation monitoring horizon, 95% per-point threshold) the false-alarm rate is 10%. It exceeds the 5% per-point level because it is a cumulative probability over 75 sequential comparisons against a fixed boundary, and it grows with the horizon. The AHLST formula (eq. 6 below) documents the same property.
  3. For a bubble that starts after T∗T^* (15 replications) the detection rate is 86.7%. The alarm delay, the alarm date minus the true origination date, is always positive: minimum 6, median 19 and maximum 33 observations.

Tests are in test-monitor.R. Replication script: replication/monitoring/radf_monitor_validation.R.

Not implemented for Family A: a closed-form or simulated false-alarm-versus-horizon boundary function (AHLST eq. 4–6), a union of rejections across families and date-stamping methods specific to a monitoring result.

CUSUM: monitor_cusum()

Status: done, as monitor_cusum(data, r_star, b_alpha, type, boundary).

The CUSUM procedure of Homm & Breitung (Section 3, eq. 26–30) is a standardised running sum of first differences compared with a closed-form asymptotic boundary derived from an inequality of Chu, Stinchcombe & White (1996). It needs no bootstrap and no simulation. For a training window ending at T∗T^* and a monitoring point t>T∗t > T^*,

St=yt−yT∗σ^t,σ^t2=1t−1∑j=2t(Δyj)2,S_t = \frac{y_t - y_{T^*}}{\hat\sigma_t}, \qquad \hat\sigma_t^2 = \frac{1}{t-1} \sum_{j=2}^{t} (\Delta y_j)^2,

where the numerator telescopes the post-training first differences and σ^t2\hat\sigma_t^2 is the recursive variance of all differences up to tt. The boundary is

ct=bα+log⁡(t/T∗),boundaryt=ctt,c_t = \sqrt{b_\alpha + \log(t / T^*)}, \qquad \text{boundary}_t = c_t \sqrt t ,

and the alarm is the first tt with St>boundarytS_t > \text{boundary}_t. The constant bα=4.6b_\alpha = 4.6 is the one-sided asymptotic calibration for a 5% level. Because σ^t\hat\sigma_t uses only data up to the current monitoring point, there is no look-ahead.

Homm & Breitung propose two statistics. CUSUM is monitor_cusum(). FLUC is monitor(..., boundary = "fluc"), described below. The code is an internal cusum_stat_path() and the exported monitor_cusum(), which returns a radf_cusum_obj. It shares no code with monitor(), radf() or exubercore.

Finite-sample boundary

boundary = "finite" replaces bα=4.6b_\alpha = 4.6 with the finite-sample constant of Homm & Breitung’s Table 8 (“without drift estimation”, which matches the raw-first-difference construction here). It is indexed by training length, significance level and the horizon ratio k=N/T∗k = N/T^*. The values are transcribed from the table and no simulation is run. The option applies to both type = "standard" and type = "kernel", because Corollary 1 of Astill et al. shows that the same boundary function serves both statistics.

CUSUMV: the volatility-robust variant

Status: done, as monitor_cusum(..., type = "kernel").

Astill et al. (2023) allow for time-varying volatility, which can “heavily inflate the false positive rate (FPR) of the CUSUM-based procedure”. Their eq. 6–7 standardise each first difference individually by a one-sided Nadaraya–Watson estimate of the spot variance before cumulating. One-sided means that it uses only current and past lags, as real-time monitoring requires:

SVt=∑j=T∗+1tΔyjσ^j,N,σ^j,N2=∑s=0Nws (Δyj−s)2,ws=K(s/N)∑s=0NK(s/N).SV_t = \sum_{j=T^*+1}^{t} \frac{\Delta y_j}{\hat\sigma_{j,N}}, \qquad \hat\sigma^2_{j,N} = \sum_{s=0}^{N} w_s\, (\Delta y_{j-s})^2, \qquad w_s = \frac{K(s/N)}{\sum_{s=0}^{N} K(s/N)} .

Corollary 1 shows that the same boundary cttc_t \sqrt t still controls the asymptotic false-alarm rate under time-varying volatility.

one_sided_kernel_spot_vol() builds a fixed causal kernel weight vector with stats::filter(..., sides = 1), with σ^j,N2:=1\hat\sigma^2_{j,N} := 1 for j≤Nj \le N as in the paper, and cusum_stat_path_kernel() forms the statistic. The default bandwidth is N=20N = 20, the value that AHLTZ recommend (“setting H = 20 delivered a procedure with the best trade-off” between FPR robustness and power). The data-driven cross-validated bandwidth of their eq. 8–9 is not implemented.

Validation

  1. Formulas. cusum_stat_path(), one_sided_kernel_spot_vol() and cusum_stat_path_kernel() match brute-force loop recomputations to floating-point precision.
  2. False alarms under a homoskedastic H0H_0 (pure random walk, 100 replications, 75-observation horizon). The asymptotic CUSUM rate is 0%, as expected of a conservative bound (eq. 28, via Chu et al.). standard and kernel agree, as in Remark 8 of AHLTZ.
  3. False alarms under a heteroskedastic H0H_0 (volatility jumping from 1 to 8 during the monitoring region, 60 replications). standard CUSUM rises to 8.3%, and kernel CUSUMV stays at 0%, which is the failure mode that AHLTZ describe.
  4. Power under the post-training bubble DGP used for monitor() (30 replications): 30.0% for standard and 36.7% for kernel, with a median alarm delay of 27 observations for standard. This is well below the 86.7% detection rate and 19-observation median delay of monitor() on the same DGP. The DGP starts the bubble about 65% of the way into the sample, the middle-to-late regime in which Kurozumi (2020, 2021) finds CUSUM-type detectors lag ADF-type ones. It illustrates why a union of both families is recommended.
  5. Finite-sample boundary at T∗=75T^* = 75, n=150n = 150 (k=2k = 2, with T∗T^* snapped to the tabulated n=50n = 50): the false-alarm rate is 9.0% under H0H_0, closer to the nominal 5% than the 0% of the asymptotic bound, and detection is 36.7% against 30.0%.

Tests are in test-cusum.R. Replication scripts: replication/monitoring/radf_cusum_validation.R, radf_cusum_finite_boundary_validation.R, radf_cusumv_kernel_validation.R.

FLUC: monitor(boundary = "fluc")

Status: done.

Homm & Breitung’s second statistic (eq. 27) is Zt=(ρ^t−1)/σ^ρ^t=DFt/nZ_t = (\hat\rho_t - 1)/\hat\sigma_{\hat\rho_t} = \mathrm{DF}_{t/n}, the ordinary expanding-window OLS ADF tt-statistic on {y0,…,yt}\{y_0, \dots, y_t\}. This is the badf sequence of radf(), so no new statistic is needed. The rejection rule (eq. 29/31) is DFt/n>κt\mathrm{DF}_{t/n} > \kappa_t with κt=bk,α+log⁡(t/n)\kappa_t = \sqrt{b_{k,\alpha} + \log(t/n)}. The constant bk,αb_{k,\alpha} comes from simulation in the paper, and the paper publishes it (Table 7, part i, “without detrending”, which matches the no-trend default of radf()). It is tabulated by training length n∈{20,50,100}n \in \{20, 50, 100\}, level α∈{0.10,0.05,0.01}\alpha \in \{0.10, 0.05, 0.01\} and horizon ratio k=N/n∈{2,3,4,5,6,8,10}k = N/n \in \{2, 3, 4, 5, 6, 8, 10\}, where NN is the total sample including training.

hb_fluc_table is the 9×79 \times 7 table and hb_fluc_q(level, n_train, k) snaps n_train to the nearest of {20,50,100}\{20, 50, 100\} and kk to the nearest of {2,…,10}\{2, \dots, 10\}, and requires level to be one of the three tabulated values. It follows the pattern of kurozumi_sadf_q() and is the third boundary option of monitor(), next to "bootstrap" and "kurozumi". The table covers training lengths only up to n=100n = 100, so larger T∗T^* are snapped to the n=100n = 100 row.

Validation. The lookups match Table 7 exactly (6 cells, including a tie-breaking snap). Alarms never fire before T∗T^* (10 of 10 replications). Under H0H_0 (n=150n = 150, T∗=75T^* = 75, k=2k = 2, the smallest and most conservative horizon ratio, 100 replications) the false-alarm rate is 0%. Detection on the post-training bubble DGP (30 replications) is 56.7%, below the 80% of boundary = "kurozumi" and the 90% of "bootstrap". This agrees with Homm & Breitung’s finding that FLUC and CUSUM generally have less power than a supDF-style test, although FLUC beats their CUSUM.

Tests extend test-monitor.R. Replication script: replication/monitoring/radf_monitor_fluc_boundary_validation.R.

Kurozumi (2020, 2021): SADF and GSADF boundaries

Status: done, as monitor(..., boundary = "kurozumi", s0 = ...) for SADF\mathrm{SADF} (s0=0s_0 = 0) and GSADFs0\mathrm{GSADF}_{s_0} (s0=0.4s_0 = 0.4 or 0.80.8).

Kurozumi’s SADF(k):=ADF1m+k\mathrm{SADF}(k) := \mathrm{ADF}_1^{m+k} is the badf sequence of radf(), which was confirmed bit for bit against a from-scratch OLS ADF tt-statistic at three check points (tolerance 10−810^{-8}). His

GSADFs0(k):=max⁡1≤k1≤⌊ms0⌋ADFk1m+k\mathrm{GSADF}_{s_0}(k) := \max_{1 \le k_1 \le \lfloor m s_0 \rfloor} \mathrm{ADF}_{k_1}^{m+k}

differs from radf()$bsadf. In bsadf the range of the window start grows with the current point tt (from 1 to t−minwt - \mathrm{minw}). Here it is capped at a fixed fraction of the training length mm whatever tt is, so it needs only a bounded band of start points and no recursion. In both cases a published, table-based threshold replaces the wild-bootstrap boundary of monitor().

Boundary functions and Table 1

The boundary functions are

SADF:g0df(k/m)=q0df(constant),GSADF:gs0df(k/m)=qs0df (as0+bs0log⁡(cs0+k/m)),{a,b,c}={0.76,0.02,0.34} for s0=0.4,{0.73,0.03,0.90} for s0=0.8,CS:gγcs(k/m)=qγcs (1+k/m)1−γ (k/m)γ.\begin{aligned} \mathrm{SADF}: &\quad g_0^{df}(k/m) = q_0^{df} \quad \text{(constant)},\\ \mathrm{GSADF}: &\quad g_{s_0}^{df}(k/m) = q_{s_0}^{df}\,\bigl(a_{s_0} + b_{s_0} \log(c_{s_0} + k/m)\bigr),\\ &\quad \{a, b, c\} = \{0.76, 0.02, 0.34\} \text{ for } s_0 = 0.4, \quad \{0.73, 0.03, 0.90\} \text{ for } s_0 = 0.8,\\ \mathrm{CS}: &\quad g_\gamma^{cs}(k/m) = q_\gamma^{cs}\,(1 + k/m)^{1-\gamma}\,(k/m)^{\gamma}. \end{aligned}

Table 1 gives the scaling constants qq by significance level β\beta and monitoring-horizon ratio sˉ=kˉ/m\bar s = \bar k / m, where monitoring runs kˉ\bar k observations past the training length mm. It covers only sˉ∈{1,3,5}\bar s \in \{1, 3, 5\}.

sˉ\bar sβ\betaq0dfq_0^{df}q0.4dfq_{0.4}^{df}q0.8dfq_{0.8}^{df}q0.25csq_{0.25}^{cs}q0.45csq_{0.45}^{cs}
10.100.69461.39691.93691.50712.1300
10.051.03811.80812.33301.76462.3948
10.011.64742.59273.09412.24052.9265
30.101.02991.70882.13151.67722.1958
30.051.33302.07372.49441.96192.4638
30.011.89782.76773.21362.49553.0163
50.101.13081.79882.17941.73262.2057
50.051.42552.14802.53692.01822.4844
50.011.97352.82763.26162.58843.0476

CS(k)\mathrm{CS}(k) is the CUSUM statistic of Homm & Breitung written in Kurozumi’s notation. The qcsq^{cs} columns are finite-sample simulated alternatives to the asymptotic bα=4.6b_\alpha = 4.6 of monitor_cusum() for γ∈{0.25,0.45}\gamma \in \{0.25, 0.45\}. Kurozumi obtained them by simulation (50,000 replications, with Brownian motion approximated by normalised i.i.d. sums over increments of 1/10001/1000).

SADF case (s0=0s_0 = 0)

monitor() has a boundary = c("bootstrap", "kurozumi") argument. With "kurozumi", kurozumi_sadf_q(level, s_bar) looks up q0dfq_0^{df}, snapping sˉ=(n−T∗)/T∗\bar s = (n - T^*)/T^* to the nearest of {1,3,5}\{1, 3, 5\} and requiring level in {0.90,0.95,0.99}\{0.90, 0.95, 0.99\}. The value is compared with radf()$badf over the monitoring window. There is no bootstrap, and nboot, type, adflag and seed are ignored. The stat field of the returned list holds bsadf for boundary = "bootstrap" and badf for boundary = "kurozumi".

Validation. The lookups match Table 1 exactly (6 values, s_bar snapping and the error for an invalid level). With n=150n = 150, T∗=75T^* = 75, sˉ=1\bar s = 1 and 100 replications, the false-alarm rate is 4.0% against a nominal 5%, while the wild-bootstrap boundary gives 7.0%. Detection on a post-training bubble (30 replications) is 80% for kurozumi and 90% for bootstrap. The two boundaries calibrate different statistics (badf and bsadf). Alarms never fire before T∗T^* (10 of 10 replications). Replication script: replication/monitoring/radf_monitor_kurozumi_boundary_validation.R.

GSADF case (s0=0.4,0.8s_0 = 0.4, 0.8)

Because ⌊ms0⌋\lfloor m s_0 \rfloor is a small fixed cap, GSADFs0(k)\mathrm{GSADF}_{s_0}(k) needs ADFk1t\mathrm{ADF}_{k_1}^{t} only for k1k_1 in a bounded band. Each is a with-intercept OLS ADF tt-statistic on a fixed window, computed from cumulative-sum differences as in hls_segment_ssr(). kurozumi_gsadf_stat() in exuber/R/monitor.R computes the band with outer()-vectorised differences, with an intercept, unlike the no-intercept gls_dfstat_grid(). s0 = 0.4 or 0.8 (the only values with tabulated aa, bb, cc) switches from the flat SADF boundary to the kk-varying gs0dfg_{s_0}^{df} above, with qs0dfq_{s_0}^{df} from the q04_df or q08_df column. The default s0 = 0 reproduces the SADF behaviour exactly.

Validation. kurozumi_gsadf_stat() matches radf()$badf to machine precision at k1_max = 1, and matches a brute-force lm() search over the restricted start band (|diff| < 1e-14) at three monitoring points on a 150-observation series. The q04_df and q08_df lookups are exact, with the expected tie-breaking snap (s0=0.6s_0 = 0.6 goes to the lower value, 0.40.4). Alarms never fire before T∗T^* (30 of 30 replications). Under H0H_0 (300 replications, n=150n = 150, T∗=75T^* = 75) the false-alarm rate is 4.3%, 5.3% and 4.3% for SADF, GSADF0.4\mathrm{GSADF}_{0.4} and GSADF0.8\mathrm{GSADF}_{0.8}, against a nominal 5%. Detection on a post-training bubble (60 replications) is 70.0%, 73.3% and 66.7%. The modest edge of s0=0.4s_0 = 0.4 over SADF echoes Kurozumi’s finding that GSADF works better than SADF in many cases. The weaker result for s0=0.8s_0 = 0.8 is specific to this DGP, since a wider start range dilutes power against some alternatives. Nine tests extend test-monitor.R. Replication script: replication/monitoring/radf_monitor_gsadf_s0_validation.R.

Kurozumi (2021)

Kurozumi (2021) studies the stochastic order of the detection delay for the same detector families. It supports the split cited above: early or short bubbles favour CUSUM, and middle or late bubbles favour ADF. monitor_cusum() and monitor() reproduce that split on the post-training bubble DGP. The paper adds dating and inference on top of detection and is not a new detector, so it is not implemented.

Breitung & Diegel (2025): LBI test and sequential extension

Status: done. lbi_test() is the static test for a bubble window that spans the full sample, and monitor_lbi() is the sequential extension.

Static LBI test

The paper proposes a locally best invariant (LBI) statistic. It is robust to heteroskedasticity by construction, through the invariance result of Cavaliere (2005), so it needs no wild bootstrap, and its limiting null distribution is standard normal. For a bubble that spans the whole sample (y0=0y_0 = 0), eq. 4 gives the telescoping identity

2∑Δyt yt−1=yT2−Tσ~2,σ~2=1T∑Δyt2,2 \sum \Delta y_t\, y_{t-1} = y_T^2 - T \tilde\sigma^2, \qquad \tilde\sigma^2 = \frac1T \sum \Delta y_t^2,

and substituting it into the numerator of the naive DF-type statistic gives eq. 5, LBIT2=yT2/(σ~2T)\mathrm{LBI}_T^2 = y_T^2/(\tilde\sigma^2 T). The statistic is the standardised sample endpoint:

LBIT=yT−y1σ~T−1.\mathrm{LBI}_T = \frac{y_T - y_1}{\tilde\sigma \sqrt{T-1}} .

It is compared with a standard normal quantile (for example 1.6451.645 at 5%). The test is one-sided, because the paper targets positive bubbles only, on the grounds that negative bubbles are economically implausible for a risky asset. It needs no regression, no recursion, no table and no boundary function.

lbi_test(data, level = 0.95) is in exuber/R/lbi_test.R and is tested in exuber/tests/testthat/test-lbi.R. The telescoping identity of eq. 4 holds exactly on a simulated random walk. In a Monte Carlo under H0H_0 (500 replications) the mean and standard deviation of the statistic are 0.0230.023 and 0.9760.976 (theory: 0 and 1), and the false-alarm rate at the 95% level is 0.0500.050. A Kolmogorov–Smirnov test against N(0,1)N(0,1) gives p=0.849p = 0.849. Detection under an explosive alternative (60 replications) is 100%, the same as a standard SADF test on the same DGP. Replication script: replication/monitoring/radf_lbi_validation.R.

Sequential extension

The authors report that “the exponentially weighted CUSUM detector with a constant boundary function turns out to be most powerful” (Section 4.1, eq. 12 and 15, Table 1).

  • Statistic. Normalise the index to the monitoring period, r=j/Tmr = j/T_m for j=1,…,Tmj = 1, \dots, T_m, where TmT_m is the monitoring horizon fixed in advance, and form the weighted partial sum LBI[rT]=1σ~Tm∑t=1[rTm]wt Δyt⇒W(r),\mathrm{LBI}_{[rT]} = \frac{1}{\tilde\sigma \sqrt{T_m}} \sum_{t=1}^{[r T_m]} w_t\, \Delta y_t \Rightarrow W(r), a standard Brownian motion under H0H_0. The Chu–Stinchcombe–White boundary of monitor_cusum() grows like t\sqrt t. Normalising by the fixed TmT_m lets a single constant boundary control the size uniformly over the monitoring window. The paper calls this variant mCUSUM. It is more powerful than the classical time-varying-boundary CUSUM of Brown et al. (1975), because under an explosive alternative the detector tends to be largest near the end of the window, which a boundary with shrinking relative tolerance penalises.
  • Weights. Eq. 12 is wrcˉ=2cˉ/Tm /e2cˉ−1  ecˉrw_r^{\bar c} = \sqrt{2\bar c/T_m}\,/\sqrt{e^{2\bar c} - 1}\; e^{\bar c r}, where cˉ≥0\bar c \ge 0 up-weights later, more bubble-like observations. cˉ=0\bar c = 0 gives flat weights (mCUSUM), and cˉ>0\bar c > 0 gives wCUSUM. The authors suggest cˉ≈2\bar c \approx 2.
  • Critical values. Table 1 (page 7, 1,000,000 replications at T=10,000T = 10{,}000) gives one-sided asymptotic critical values. The running maximum of a time-changed Brownian motion has the same distribution whatever the time change, sup⁡rW∗(η(r))=dsup⁡rW(r)\sup_r W^*(\eta(r)) =_d \sup_r W(r), so one set of values covers every cˉ\bar c: 1.641.64, 1.951.95, 2.242.24, 2.572.57 and 2.802.80 at the 10%, 5%, 2.5%, 1% and 0.5% levels.
  • Variance. σ~2\tilde\sigma^2 is estimated from the training window only (Section 4.2). lbi_test() uses the full-sample σ~2\tilde\sigma^2 in the static case.

monitor_lbi(data, r_star = 0.5, c_bar = 0, level = 0.95) is in exuber/R/lbi_test.R, with tests in test-lbi.R.

Validation.

  • The sum of squares of the flat weight vector (cˉ=0\bar c = 0) equals 1 exactly, as in the discrete form of eq. 12. For cˉ>0\bar c > 0 it equals 1 up to the expected Riemann-sum error (<0.5%< 0.5\% at Tm=500T_m = 500).
  • The final-point statistic under mCUSUM matches a hand-computed telescoped value with the training-window σ~2\tilde\sigma^2 to machine precision.
  • The table lookups match Table 1 exactly, with a clean error for an untabulated level, and alarms never fire before the end of the training window (50 of 50 replications).
  • The false-alarm rate under H0H_0 (1000 replications, n=200n = 200, T∗=100T^* = 100) is 3.7% for mCUSUM and 4.0% for wCUSUM, against a nominal 5%.
  • Detection on a post-training bubble (60 replications) is 41% for mCUSUM and 44% for wCUSUM, above the 31% of monitor_cusum(type = "standard") on the same DGP. This confirms the paper’s claim that the constant-boundary LBI detector is more powerful than the classical CUSUM, and wCUSUM is at least as powerful as mCUSUM.

Replication script: replication/monitoring/radf_lbi_monitor_validation.R.

Not implemented: the Table 1 row for the classical time-varying-boundary CUSUM of Brown et al. (1975), which the paper uses as a comparison, and the DF-statistic monitoring variant of Section 4.2, which is a badf-based analogue with a different boundary and addresses the same problem as monitor().

Whitehouse, Harvey & Leybourne (2025): AHLST decision rule and FPR

The DGP is yt=μ+uty_t = \mu + u_t, with ut=ut−1+εtu_t = u_{t-1} + \varepsilon_t for t≤⌊τT⌋t \le \lfloor \tau T \rfloor and ut=(1+δ)ut−1+εtu_t = (1+\delta) u_{t-1} + \varepsilon_t afterwards. The statistic (eq. 2) is

Ae,k=Be,kCe,k,Be,k=∑t=e−k+1e(t−e+k) Δyt,Ce,k=∑t=e−k+1e{(t−e+k) Δyt}2.A_{e,k} = \frac{B_{e,k}}{\sqrt{C_{e,k}}}, \qquad B_{e,k} = \sum_{t=e-k+1}^{e} (t - e + k)\,\Delta y_t, \qquad C_{e,k} = \sum_{t=e-k+1}^{e} \bigl\{(t - e + k)\,\Delta y_t\bigr\}^2 .

The training-sample maximum Amax⁡∗=max⁡e∈[k+1,T∗]Ae,kA^*_{\max} = \max_{e \in [k+1, T^*]} A_{e,k} is the critical value, and the rule “reject H0H_0 at time ee if Ae,k>Amax⁡∗A_{e,k} > A^*_{\max}” defines the AMAX(k)\mathrm{AMAX}(k) procedure. Under H0H_0, for a monitoring point T′T' (eq. 4–5),

lim⁡T→∞P(max⁡e∈[T∗+k,T′]Ae,k>max⁡e∈[k+1,T∗]Ae,k)=τ=lim⁡T′−T∗T′.\lim_{T \to \infty} P\Bigl(\max_{e \in [T^*+k, T']} A_{e,k} > \max_{e \in [k+1, T^*]} A_{e,k}\Bigr) = \tau = \lim \frac{T' - T^*}{T'} .

The approximate FPR at T′T' (eq. 6) is

α≈T′−T∗−k+1T′−2k+1,\alpha \approx \frac{T' - T^* - k + 1}{T' - 2k + 1},

so monitoring can run until T′≈(T∗+k−1−α(2k−1))/(1−α)T' \approx (T^* + k - 1 - \alpha(2k-1))/(1-\alpha) at a chosen FPR α\alpha. In contrast to CUSUM-based approaches (Homm & Breitung, Astill et al., Horváth & Trapani), this gives an exact, usable FPR with no asymptotic boundary and no conservatism, but the FPR necessarily grows with the monitoring horizon, so it suits short-range monitoring. CUSUM-style methods can hold a fixed FPR (for example 0.05) over an arbitrarily long horizon, at the cost of lower power (a lower true positive rate).

Table 1 (k=10k = 10, NIID and GARCH(1,1) errors) reports an empirical FPR for the baseline AMAX(k)\mathrm{AMAX}(k) of 0.006 (NIID) at T′=200T' = 200, rising monotonically to 0.147 at T′=230T' = 230. The two variance-standardised variants, AAR,max⁡(k)A^{AR,\max}(k) and AT,max⁡(k)A^{T,\max}(k), run a little higher (0.015 and 0.013 at T′=200T' = 200), so they are less conservative, which is the design goal of Theorem 1 (the same asymptotic FPR with different finite-sample behaviour).

In the empirical application, AAR,max⁡(k)A^{AR,\max}(k) detects the bubble in the US house price-to-rent ratio that preceded the 2007/08 financial crisis as early as 1999:Q1, against 2000:Q1 for AMAX(k)\mathrm{AMAX}(k), an improvement of four quarters (Table 2).

Horváth & Trapani (2023/2026): RCA monitoring

Status: evaluated, not implemented.

The WLS-residual CUSUM detector (eq. 2.4) over a training window of length mm is

Zm(k)=∑i=m+1m+k(yi−θ^myi−1) yi−11+yi−12,k≥1.Z_m(k) = \sum_{i=m+1}^{m+k} \frac{(y_i - \hat\theta_m y_{i-1})\, y_{i-1}}{1 + y_{i-1}^2}, \qquad k \ge 1 .

For the open-ended or long-horizon case the boundary function (eq. 2.5/2.9) is

gm,γ(k)=cγ,α s m1/2(1+km)(km+k)γ,0≤γ<12,g_{m,\gamma}(k) = c_{\gamma,\alpha}\, s\, m^{1/2} \left(1 + \frac{k}{m}\right) \left(\frac{k}{m+k}\right)^{\gamma}, \qquad 0 \le \gamma < \tfrac12,

and a short-horizon variant (eq. 2.10) is gm,γ(k)=cγ,α s m1/2−γkg_{m,\gamma}(k) = c_{\gamma,\alpha}\, s\, m^{1/2 - \gamma} k, used when the monitoring horizon m′m' is o(m)o(m). The stopping time is τm,γ=inf⁡{k≥1:Zm(k)≥gm,γ(k)}\tau_{m,\gamma} = \inf\{k \ge 1 : Z_m(k) \ge g_{m,\gamma}(k)\}. The constant cγ,αc_{\gamma,\alpha} controls size, like bαb_\alpha of Homm & Breitung. The paper also defines a Page-CUSUM variant for a shorter detection delay.

Table 5.4 (median detection delay, no covariates, m=200m = 200): in Case I (δ0=0.5\delta_0 = 0.5) the standard weighted CUSUM (γ=0\gamma = 0) has a median delay of 54 and the standardised CUSUM (cγ,0.5c_{\gamma,0.5}) has 37, a reduction of about 30%, at the price of lower empirical power (a rejection frequency of 0.465 against 0.705). In the application to Los Angeles daily housing prices (m,m′∈{100,200}m, m' \in \{100, 200\}), the ex-post analysis dates the break at 4 February 2009. The real-time procedure with no covariates flags a change point on 2–15 June 2009, depending on the windows, a delay of about four months. Adding covariates (interest-rate proxies, VXO, the Weekly Economic Indicator) moves the flag to 18 May 2009 in the richest specification (Table 6.2).

Why it is not implemented. The statistic Zm(k)Z_m(k) is a single cumsum() after an OLS coefficient θ^m\hat\theta_m from the training window, so it is as cheap as monitor_cusum(). The obstacles are the critical values and a nuisance parameter.

  • Several boundary regimes need their own critical values: open-ended (eq. 2.5), closed-ended long-horizon (eq. 2.9) and closed-ended short-horizon (eq. 2.10).
  • For γ<1/2\gamma < 1/2 the critical value solves a Brownian-motion sup-norm probability, P(sup⁡∣W(u)∣<cγ)P(\sup |W(u)| < c_\gamma) (eq. 3.4). For γ=1/2\gamma = 1/2 it follows a Darling–Erdős extreme-value asymptotic (eq. 3.5–3.6), cα,0.5=[x+b(log⁡m)]/a(log⁡m)c_{\alpha,0.5} = [x + b(\log m)]/a(\log m) with a(x)=2log⁡xa(x) = \sqrt{2 \log x}, b(x)=2log⁡x+12log⁡log⁡x−12log⁡πb(x) = 2 \log x + \tfrac12 \log\log x - \tfrac12 \log \pi and xx solved from exp⁡(−exp⁡(−x))=1−α\exp(-\exp(-x)) = 1 - \alpha. The authors note that these asymptotic values are “bound to be inaccurate due to the slow convergence to the Extreme Value distribution… leading to low power”, and propose a finite-sample correction (eq. 3.7–3.8) that requires solving an implicit equation for cc with a tuning parameter hmh_m (recommended hm=log⁡mh_m = \sqrt{\log m}).
  • At γ=0\gamma = 0, the limiting probabilities of Theorems 3.1 and 3.3 reduce to P{sup⁡0<u≤1∣W(u)∣<cα,0}P\{\sup_{0 < u \le 1} |W(u)| < c_{\alpha,0}\}, the classical sup-norm distribution behind the two-sided Kolmogorov–Smirnov statistic, which has a closed-form alternating series and can be inverted with uniroot(). The general case γ∈(0,1/2)\gamma \in (0, 1/2) has no such form, and the paper obtains its critical values “by simulation”.
  • The normalising constant s2\mathfrak s^2 of the boundary (eq. 2.6) is defined by a case split on the Lyapunov-type exponent E[log⁡∣β0+ε0,1∣]E[\log |\beta_0 + \varepsilon_{0,1}|]. For a plain unit root (β0=1\beta_0 = 1, i.i.d. innovations) the exponent is negative (the paper’s Case III gives −0.007-0.007, “the STUR model”), and then s2=a1σ12+a2σ22\mathfrak s^2 = a_1\sigma_1^2 + a_2\sigma_2^2 with a1=E[(yˉ02/(1+yˉ02))2]a_1 = E[(\bar y_0^2/(1 + \bar y_0^2))^2] and a2=E[(yˉ0/(1+yˉ02))2]a_2 = E[(\bar y_0/(1 + \bar y_0^2))^2], expectations over the stationary distribution of the RCA(1) process, which has no general closed form. The Monte Carlo of the paper computes critical values from the true DGP parameters (Theorems 3.2/3.6), and the paper gives no estimator that works on data with unknown parameters. A usable implementation needs such an estimator.

Remaining items

  • A closed-form false-alarm-versus-horizon boundary for the wild-bootstrap route of monitor(), which still recalibrates by simulation.
  • A non-bootstrap (asymptotic or Monte Carlo) training critical value for monitor(), which radf_mc_cv() could supply.
  • The Page-CUSUM family of Horváth & Trapani.

Replication scripts

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

radf_cusum_finite_boundary_validation.R 49 lines
# Validation script for monitor_cusum(..., boundary = "finite") (Homm &
# Breitung 2012's finite-sample CUSUM boundary, their Table 8). See
# docs/monitoring.md, "Implementation (CUSUM)", 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. Table lookup sanity checks (Homm & Breitung Table 8(i)) ===\n")
cat("sig_lvl=95, n=100, k=2 (expect 1.51):", exuber:::hb_cusum_finite_q(95, 100, 2), "\n")
cat("sig_lvl=95, n=100, k=10 (expect 3.36):", exuber:::hb_cusum_finite_q(95, 100, 10), "\n")
cat("sig_lvl=90, n=20, k=2 (expect 0.81):", exuber:::hb_cusum_finite_q(90, 20, 2), "\n")
tryCatch(exuber:::hb_cusum_finite_q(93, 100, 2), error = function(e) cat("sig_lvl=93 correctly errors:", conditionMessage(e), "\n"))

cat("\n=== 2. Basic run with boundary='finite' ===\n")
set.seed(1)
y <- cumsum(rnorm(150))
out <- monitor_cusum(y, r_star = 0.5, boundary = "finite", sig_lvl = 95)
print(out)
cat("b_alpha used:", attr(out, "b_alpha"), "(vs asymptotic default 4.6)\n")

cat("\n=== 3. False-alarm rate and detection power: asymptotic vs finite ===\n")
run_null <- function(seed, boundary) {
  set.seed(seed)
  y <- cumsum(rnorm(150))
  out <- monitor_cusum(y, r_star = 0.5, boundary = boundary)
  !is.na(out$alarm)
}
run_detect <- function(seed, boundary) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor_cusum(y, r_star = n1 / length(y), boundary = boundary)
  !is.na(out$alarm)
}
cat(sprintf("False-alarm rate, asymptotic: %.3f\n", mean(sapply(1:100, function(s) run_null(s, "asymptotic")))))
cat(sprintf("False-alarm rate, finite:     %.3f\n\n", mean(sapply(1:100, function(s) run_null(s, "finite")))))
cat(sprintf("Detection rate, asymptotic: %.3f\n", mean(sapply(1:30, function(s) run_detect(s, "asymptotic")))))
cat(sprintf("Detection rate, finite:     %.3f\n", mean(sapply(1:30, function(s) run_detect(s, "finite")))))

cat("\n=== 4. boundary='finite' also works with type='kernel' (CUSUMV), ===\n")
cat("    extending Corollary 1's shared-boundary result to the table ===\n")
out_k <- monitor_cusum(y, r_star = 0.5, boundary = "finite", type = "kernel")
print(out_k)
radf_cusum_finite_boundary_validation.py 58 lines
"""Python cross-check of exuber's monitor_cusum(..., boundary = "finite")
(Homm & Breitung 2012's finite-sample CUSUM boundary, their Table 8),
mirroring radf_cusum_finite_boundary_validation.R's table-lookup and
basic-run checks.

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_cusum_finite_boundary_validation.py

Reference values: Homm & Breitung (2012) Table 8(i), already transcribed
into both exuber/R/monitor_cusum.R's hb_cusum_finite_table and
pyexuber/src/exuber/monitor.py's _HB_CUSUM_FINITE_TABLE. b_alpha/stat
tail from the same R run cited in radf_cusum_validation.py, with
`boundary="finite"` substituted.
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor_cusum import _hb_cusum_finite_q, monitor_cusum  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Table lookup sanity checks (Table 8(i)) ===")
    checks = [(95, 40, 2, 1.43), (95, 100, 2, 1.51), (90, 20, 10, 2.02), (99, 100, 2, 2.86)]
    for sig_lvl, n_train, k, expected in checks:
        q = _hb_cusum_finite_q(sig_lvl, n_train, k)
        print(f"sig_lvl={sig_lvl}, n_train={n_train}, k={k} (expect {expected}): {q}")
        assert q == expected

    print("\n=== 2. Basic run, boundary='finite' -- cross-check vs R ===")
    res = monitor_cusum(Y42, r_star=0.5, boundary="finite", sig_lvl=95)
    assert res.t_star == 40
    assert res.b_alpha == 1.43
    expected_s_tail = np.array(
        [3.6727107022, 4.4016203054, 4.8602235379, 4.0370328378, 3.0016493394]
    )
    np.testing.assert_allclose(res.stat[-5:, 0], expected_s_tail, atol=1e-8)
    print(f"T_star={res.t_star}, b_alpha={res.b_alpha}, S[-5:]={res.stat[-5:, 0]}")

    # The finite-sample boundary is a stricter (smaller) constant than the
    # conservative asymptotic default at this (n, k) -- same qualitative
    # finding as docs/monitoring.md's own R-side validation.
    res_asy = monitor_cusum(Y42, r_star=0.5, b_alpha=4.6, boundary="asymptotic")
    assert res.b_alpha < res_asy.b_alpha
    print(f"finite b_alpha ({res.b_alpha}) < asymptotic b_alpha ({res_asy.b_alpha}): OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_cusum_validation.R 59 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula check: cusum_stat_path() vs independent brute-force loop ===\n")
set.seed(1)
n <- 80
T_star <- 40
y <- cumsum(rnorm(n))
b_alpha <- 4.6
res <- exuber:::cusum_stat_path(y, T_star, b_alpha)

S_brute <- numeric(n - T_star)
bnd_brute <- numeric(n - T_star)
for (k in seq_len(n - T_star)) {
  t <- T_star + k
  dy <- diff(y[1:t])
  sigma2_t <- sum(dy^2) / (t - 1)
  S_brute[k] <- (y[t] - y[T_star]) / sqrt(sigma2_t)
  c_t <- sqrt(b_alpha + log(t / T_star))
  bnd_brute[k] <- c_t * sqrt(t)
}
cat("max abs diff S:", max(abs(res$S - S_brute)), "\n")
cat("max abs diff boundary:", max(abs(res$boundary - bnd_brute)), "\n\n")

cat("=== 2. Empirical size under H0 (pure random walk, no bubble anywhere) ===\n")
run_null <- function(seed) {
  set.seed(seed)
  n <- 150
  y <- cumsum(rnorm(n))
  out <- monitor_cusum(y, r_star = 0.5, b_alpha = 4.6)
  !is.na(out$alarm)
}
false_alarm_rate <- mean(sapply(1:100, run_null))
cat(sprintf("Cumulative false-alarm rate (100 reps, H0 throughout, 75 monitoring obs): %.3f\n",
            false_alarm_rate))
cat("(This is HB's own asymptotic UPPER BOUND on the cumulative FPR at b_alpha=4.6,\n",
    " i.e. their eq 28's exp(-b/2) bound for the whole monitoring horizon, not a\n",
    " per-point 5% level -- so a rate notably below 5% would be consistent with a\n",
    " genuinely conservative bound; well above 5% would be a red flag.)\n\n")

cat("=== 3. Detection power under a genuine post-training bubble ===\n")
run_detect <- function(seed) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor_cusum(y, r_star = n1 / length(y), b_alpha = 4.6)
  c(alarm = unname(out$alarm), true_origination = n1)
}
res2 <- t(sapply(1:30, run_detect))
detected <- !is.na(res2[, "alarm"])
cat("Detection rate:", mean(detected), "\n")
if (any(detected)) {
  cat("Alarm delay (alarm - true origination) among detections:\n")
  print(summary(res2[detected, "alarm"] - res2[detected, "true_origination"]))
}
radf_cusum_validation.py 78 lines
"""Python cross-check of exuber's monitor_cusum() (Homm & Breitung 2012's
CUSUM real-time monitoring, both the "standard" statistic and Astill,
Harvey, Leybourne, Taylor & Zu (2023)'s volatility-robust "kernel"
variant). Fully deterministic (no bootstrap, no simulation at all), so
every check here is a bit-for-bit cross-check against R, not just a
Monte Carlo size/power reproduction -- unlike radf_cusum_validation.R's
own size/power sections (different RNGs make those non-reproducible
across languages; docs/monitoring.md already records the R-side numbers).

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_cusum_validation.py

Reference values: a direct R run from the exuber-project/ root,
    Rscript -e 'Sys.setenv(NOT_CRAN="true");
    devtools::load_all("exuber", quiet=TRUE); set.seed(42);
    y <- cumsum(rnorm(80));
    mc <- monitor_cusum(y, r_star=0.5, b_alpha=4.6, boundary="asymptotic");
    dput(mc$T_star); dput(round(tail(as.numeric(mc$S), 5), 10));
    dput(round(tail(as.numeric(mc$boundary), 5), 10))'
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor_cusum import monitor_cusum  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Formula-exact check vs brute-force recomputation ===")
    t_star = 40
    dy = np.diff(Y42)
    expected_s, expected_b = [], []
    for t in range(t_star, len(Y42)):
        sigma2 = np.sum(dy[:t] ** 2) / t
        s_t = (Y42[t] - Y42[t_star - 1]) / np.sqrt(sigma2)
        c_t = np.sqrt(4.6 + np.log((t + 1) / t_star))
        expected_s.append(s_t)
        expected_b.append(c_t * np.sqrt(t + 1))
    res = monitor_cusum(Y42, r_star=0.5, b_alpha=4.6, boundary="asymptotic")
    np.testing.assert_allclose(res.stat[:, 0], expected_s, atol=1e-10)
    np.testing.assert_allclose(res.boundary[:, 0], expected_b, atol=1e-10)
    print(f"max |diff| S: {np.max(np.abs(res.stat[:, 0] - expected_s)):.2e}, "
          f"boundary: {np.max(np.abs(res.boundary[:, 0] - expected_b)):.2e}")

    print("\n=== 2. Basic run -- cross-check vs R ===")
    assert res.t_star == 40
    expected_s_tail = np.array(
        [3.6727107022, 4.4016203054, 4.8602235379, 4.0370328378, 3.0016493394]
    )
    expected_b_tail = np.array(
        [19.9594813397, 20.1153995614, 20.2704388473, 20.4246151364, 20.5779438828]
    )
    np.testing.assert_allclose(res.stat[-5:, 0], expected_s_tail, atol=1e-8)
    np.testing.assert_allclose(res.boundary[-5:, 0], expected_b_tail, atol=1e-8)
    print(f"T_star={res.t_star}, S[-5:]={res.stat[-5:, 0]}")
    assert np.isnan(res.alarm[0])

    print("\n=== 3. Structural check: alarm never before T_star ===")
    rng = np.random.default_rng(1)
    for _ in range(10):
        y = np.cumsum(rng.normal(size=150))
        out = monitor_cusum(y, r_star=0.5)
        if not np.isnan(out.alarm[0]):
            assert out.alarm[0] >= out.t_star
    print("all alarms (if any) fired at/after T_star: OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_cusumv_kernel_validation.R 89 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Formula check: one_sided_kernel_spot_vol() vs brute-force loop ===\n")
set.seed(1)
n <- 60
dy <- rnorm(n)
N <- 10
res <- exuber:::one_sided_kernel_spot_vol(dy, N = N, kernel = "gaussian")

sigma2_brute <- numeric(n)
kern <- function(u) dnorm(u)
w_full <- kern((0:N) / N); w_full <- w_full / sum(w_full)
for (j in seq_len(n)) {
  if (j <= N) {
    sigma2_brute[j] <- 1  # paper's own convention
  } else {
    idx <- (j - N):j  # s = 0..N -> dy[j], dy[j-1], ..., dy[j-N]
    ww <- rev(w_full)  # align so w_full[1] (s=0) multiplies dy[j] (last in idx)
    sigma2_brute[j] <- sum(ww * dy[idx]^2)
  }
}
cat("max abs diff (j > N only):", max(abs(res[(N+1):n] - sigma2_brute[(N+1):n])), "\n")
cat("all j<=N equal to 1:", all(res[1:N] == 1), "\n\n")

cat("=== 2. Formula check: cusum_stat_path_kernel() vs independent brute-force ===\n")
set.seed(2)
n2 <- 100
T_star <- 50
y <- cumsum(rnorm(n2))
b_alpha <- 4.6
res2 <- exuber:::cusum_stat_path_kernel(y, T_star, b_alpha, N = 20, kernel = "gaussian")

dy2 <- diff(y)
sigma2_dy <- exuber:::one_sided_kernel_spot_vol(dy2, N = 20, kernel = "gaussian")
SV_brute <- numeric(n2 - T_star)
for (k in seq_len(n2 - T_star)) {
  t <- T_star + k
  js <- (T_star + 1):t  # Delta y_j for j = T_star+1..t -> dy index (j-1)
  SV_brute[k] <- sum(dy2[js - 1] / sqrt(sigma2_dy[js - 1]))
}
cat("max abs diff SV:", max(abs(res2$S - SV_brute)), "\n\n")

cat("=== 3. Under HOMOSKEDASTIC H0: standard vs kernel CUSUM should have",
    "comparable (not necessarily identical) empirical false-alarm rates ===\n")
run_null_homo <- function(seed, type) {
  set.seed(seed)
  y <- cumsum(rnorm(150))
  out <- monitor_cusum(y, r_star = 0.5, b_alpha = 4.6, type = type)
  !is.na(out$alarm)
}
rate_std_homo <- mean(sapply(1:60, function(s) run_null_homo(s, "standard")))
rate_ker_homo <- mean(sapply(1:60, function(s) run_null_homo(s, "kernel")))
cat(sprintf("Standard CUSUM false-alarm rate (homoskedastic): %.3f\n", rate_std_homo))
cat(sprintf("Kernel CUSUMV false-alarm rate (homoskedastic):  %.3f\n\n", rate_ker_homo))

cat("=== 4. Under HETEROSKEDASTIC H0: standard CUSUM should become",
    "OVERSIZED, kernel CUSUMV should stay controlled -- this is the",
    "paper's whole selling point ===\n")
run_null_hetero <- function(seed, type) {
  set.seed(seed)
  n <- 150
  # sharp one-time volatility jump partway through the monitoring region
  vol <- c(rep(1, 90), rep(8, 60))
  y <- cumsum(rnorm(n) * vol)
  out <- monitor_cusum(y, r_star = 0.5, b_alpha = 4.6, type = type)
  !is.na(out$alarm)
}
rate_std_hetero <- mean(sapply(1:60, function(s) run_null_hetero(s, "standard")))
rate_ker_hetero <- mean(sapply(1:60, function(s) run_null_hetero(s, "kernel")))
cat(sprintf("Standard CUSUM false-alarm rate (heteroskedastic, vol jump 1->8): %.3f\n", rate_std_hetero))
cat(sprintf("Kernel CUSUMV false-alarm rate (heteroskedastic, vol jump 1->8):  %.3f\n\n", rate_ker_hetero))

cat("=== 5. Detection power under a genuine post-training bubble (homoskedastic) ===\n")
run_detect <- function(seed, type) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor_cusum(y, r_star = n1 / length(y), type = type)
  !is.na(out$alarm)
}
power_std <- mean(sapply(1:30, function(s) run_detect(s, "standard")))
power_ker <- mean(sapply(1:30, function(s) run_detect(s, "kernel")))
cat(sprintf("Detection rate, standard: %.3f\n", power_std))
cat(sprintf("Detection rate, kernel:   %.3f\n", power_ker))
radf_cusumv_kernel_validation.py 60 lines
"""Python cross-check of exuber's monitor_cusum(..., type = "kernel")
(Astill, Harvey, Leybourne, Taylor & Zu (2023)'s volatility-robust
"CUSUMV" variant), mirroring radf_cusumv_kernel_validation.R's
formula-exact and basic-run checks.

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_cusumv_kernel_validation.py

Reference values: stat tail from the same R run cited in
radf_cusum_validation.py, with `type="kernel", h=20,
kernel="gaussian"` substituted.
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor_cusum import _one_sided_kernel_spot_vol, monitor_cusum  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Kernel spot-variance: causal + starts-at-1 checks ===")
    rng = np.random.default_rng(0)
    dy = rng.normal(size=50)
    sigma2 = _one_sided_kernel_spot_vol(dy, h=20)
    np.testing.assert_allclose(sigma2[:20], 1.0)
    print("sigma2_j == 1 for j <= h: OK (AHLTZ's own convention)")

    dy2 = dy.copy()
    dy2[30] += 100.0
    sigma2_2 = _one_sided_kernel_spot_vol(dy2, h=20)
    np.testing.assert_allclose(sigma2[:30], sigma2_2[:30])
    print("perturbing a future observation leaves earlier spot-variance estimates unchanged: OK "
          "(one-sided/causal, matching real-time monitoring's information set)")

    print("\n=== 2. Basic run, type='kernel' -- cross-check vs R ===")
    res = monitor_cusum(Y42, r_star=0.5, type="kernel", h=20, kernel="gaussian")
    expected_tail = np.array(
        [4.2409795835, 5.0516103362, 5.5494862292, 4.607070108, 3.2258025215]
    )
    np.testing.assert_allclose(res.stat[-5:, 0], expected_tail, atol=1e-8)
    print(f"S[-5:]={res.stat[-5:, 0]}")
    assert np.isnan(res.alarm[0])

    print("\n=== 3. Corollary 1: standard and kernel share the same boundary formula ===")
    res_std = monitor_cusum(Y42, r_star=0.5)
    np.testing.assert_allclose(res.boundary, res_std.boundary)
    print("boundary paths identical for type='standard' and type='kernel': OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_lbi_monitor_validation.R 100 lines
# Validation of monitor_lbi() -- Breitung & Diegel (2025)'s sequential
# (constant-boundary mCUSUM/wCUSUM) monitoring extension of lbi_test().
# See docs/monitoring.md, "Breitung & Diegel (2025) -- static
# LBI test AND sequential extension both done", for the full writeup.
#
# Run from the exuber-project/ root.

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

cat("=== 1. Weight normalization (eq. 12) ===\n")
Tm <- 500
for (cb in c(0, 1, 2, 5)) {
  w <- exuber:::bd_cusum_weights(Tm, cb)
  cat(sprintf(
    "c_bar=%.1f  sum(w^2)=%.6f (expect exactly 1 at c_bar=0, ~1 otherwise)  length=%d\n",
    cb, sum(w^2), length(w)
  ))
}

cat("\n=== 2. mCUSUM final-point statistic is formula-exact ===\n")
cat("(matches a hand-telescoped computation using training-window sigma_tilde)\n")
set.seed(1)
n <- 300
T_star <- 150
y <- cumsum(rnorm(n))
out <- monitor_lbi(y, r_star = T_star, c_bar = 0, sig_lvl = 95)
final_stat <- out$stat[nrow(out$stat), 1]
dy <- diff(y)
sigma2_tilde <- mean(dy[seq_len(T_star - 1L)]^2)
manual <- (y[n] - y[T_star]) / sqrt(sigma2_tilde * (n - T_star))
cat(sprintf(
  "monitor_lbi: %.8f   manual telescoped: %.8f   |diff|: %.2e\n",
  final_stat, manual, abs(final_stat - manual)
))

cat("\n=== 3. Table 1 lookup (exact, both mCUSUM and wCUSUM share it) ===\n")
for (lv in c(90, 95, 97.5, 99, 99.5)) {
  cat(sprintf("sig_lvl=%g -> b_alpha=%.2f\n", lv, exuber:::bd_cusum_q(lv)))
}
res <- tryCatch(exuber:::bd_cusum_q(80), error = function(e) "ERROR (expected)")
cat("untabulated sig_lvl=80:", res, "\n")

cat("\n=== 4. Alarms never fire before the training window ends ===\n")
set.seed(3)
ok <- TRUE
for (i in 1:50) {
  y <- cumsum(rnorm(200))
  om <- monitor_lbi(y, r_star = 100, c_bar = 0, sig_lvl = 95)
  if (!is.na(om$alarm) && om$alarm <= 100) ok <- FALSE
}
cat("all alarms strictly after T_star (50 reps):", ok, "\n")

cat("\n=== 5. Empirical false-alarm rate under H0 (pure random walk, 1,000 reps) ===\n")
set.seed(42)
nrep <- 1000
n <- 200
T_star <- 100
fa_mcusum <- fa_wcusum <- 0
for (i in seq_len(nrep)) {
  y <- cumsum(rnorm(n))
  om <- monitor_lbi(y, r_star = T_star, c_bar = 0, sig_lvl = 95)
  ow <- monitor_lbi(y, r_star = T_star, c_bar = 2, sig_lvl = 95)
  if (!is.na(om$alarm)) fa_mcusum <- fa_mcusum + 1
  if (!is.na(ow$alarm)) fa_wcusum <- fa_wcusum + 1
}
cat(sprintf("mCUSUM (c_bar=0) false-alarm rate: %.4f (nominal 0.05)\n", fa_mcusum / nrep))
cat(sprintf("wCUSUM (c_bar=2) false-alarm rate: %.4f (nominal 0.05)\n", fa_wcusum / nrep))

cat("\n=== 6. Detection power vs. monitor_cusum(type = 'standard') on the same DGP ===\n")
set.seed(4)
nrep <- 100
n <- 200
T_star <- 100
make_bubble_series <- function(n, T_star, bubble_start_frac = 0.65, rho = 1.03) {
  y <- numeric(n)
  y[seq_len(T_star)] <- cumsum(rnorm(T_star))
  bstart <- T_star + round((n - T_star) * (bubble_start_frac - T_star / n) / (1 - T_star / n))
  bstart <- max(bstart, T_star + 5)
  for (t in (T_star + 1):n) {
    y[t] <- if (t < bstart) y[t - 1] + rnorm(1) else rho * y[t - 1] + rnorm(1)
  }
  y
}

det_mcusum <- det_wcusum <- det_cusum_std <- 0
for (i in seq_len(nrep)) {
  y <- make_bubble_series(n, T_star)
  om <- monitor_lbi(y, r_star = T_star, c_bar = 0, sig_lvl = 95)
  ow <- monitor_lbi(y, r_star = T_star, c_bar = 2, sig_lvl = 95)
  oc <- monitor_cusum(y, r_star = T_star / n, b_alpha = 4.6)
  if (!is.na(om$alarm)) det_mcusum <- det_mcusum + 1
  if (!is.na(ow$alarm)) det_wcusum <- det_wcusum + 1
  if (!is.na(oc$alarm)) det_cusum_std <- det_cusum_std + 1
}
cat(sprintf("mCUSUM (c_bar=0) power: %.3f\n", det_mcusum / nrep))
cat(sprintf("wCUSUM (c_bar=2) power: %.3f\n", det_wcusum / nrep))
cat(sprintf("monitor_cusum(type='standard') power (same DGP): %.3f\n", det_cusum_std / nrep))

cat("\ndone\n")
radf_lbi_monitor_validation.py 78 lines
"""Python cross-check of exuber's monitor_lbi() (Breitung & Diegel 2025's
sequential mCUSUM/wCUSUM extension), mirroring
radf_lbi_monitor_validation.R's weight-normalization, table-lookup and
basic-run checks.

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_lbi_monitor_validation.py

Reference values: a direct R run from the exuber-project/ root,
    Rscript -e '...; ml0 <- monitor_lbi(y, r_star=0.5, c_bar=0, sig_lvl=95);
    ml2 <- monitor_lbi(y, r_star=0.5, c_bar=2, sig_lvl=95);
    dput(ml0$T_star); dput(ml0$boundary);
    dput(round(tail(as.numeric(ml0$stat), 5), 10));
    dput(round(tail(as.numeric(ml2$stat), 5), 10))'
same Y42 series as radf_monitor_kurozumi_boundary_validation.py.
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.lbi_test import _bd_cusum_q, _bd_cusum_weights, monitor_lbi  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Table 1 lookup checks ===")
    checks = [(90, 1.64), (95, 1.95), (97.5, 2.24), (99, 2.57), (99.5, 2.80)]
    for sig_lvl, expected in checks:
        q = _bd_cusum_q(sig_lvl)
        print(f"sig_lvl={sig_lvl} (expect {expected}): {q}")
        assert q == expected

    print("\n=== 2. Weight normalization (eq. 12) ===")
    w0 = _bd_cusum_weights(500, 0)
    print(f"c_bar=0: sum(w^2)={np.sum(w0**2):.10f} (expect exactly 1)")
    assert abs(np.sum(w0**2) - 1.0) < 1e-12
    w2 = _bd_cusum_weights(500, 2)
    print(f"c_bar=2: sum(w^2)={np.sum(w2**2):.6f} (expect ~1, Riemann-sum approx error)")
    assert abs(np.sum(w2**2) - 1.0) < 5e-3

    print("\n=== 3. Basic run, c_bar=0 (mCUSUM) -- cross-check vs R ===")
    ml0 = monitor_lbi(Y42, r_star=0.5, c_bar=0, sig_lvl=95)
    assert ml0.t_star == 40
    assert ml0.boundary == 1.95
    expected_tail0 = np.array(
        [0.5187423335, 0.6196912485, 0.6806364851, 0.5642336848, 0.4197078303]
    )
    np.testing.assert_allclose(ml0.stat[-5:, 0], expected_tail0, atol=1e-8)
    print(f"T_star={ml0.t_star}, boundary={ml0.boundary}, stat[-5:]={ml0.stat[-5:, 0]}")

    print("\n=== 4. Basic run, c_bar=2 (wCUSUM) -- cross-check vs R ===")
    ml2 = monitor_lbi(Y42, r_star=0.5, c_bar=2, sig_lvl=95)
    expected_tail2 = np.array(
        [0.4699810166, 0.6453696898, 0.7566848661, 0.5331770251, 0.2414413064]
    )
    np.testing.assert_allclose(ml2.stat[-5:, 0], expected_tail2, atol=1e-8)
    print(f"stat[-5:]={ml2.stat[-5:, 0]}")

    print("\n=== 5. Structural check: alarm never before T_star ===")
    rng = np.random.default_rng(4)
    for _ in range(10):
        y = np.cumsum(rng.normal(size=150))
        out = monitor_lbi(y, r_star=0.5)
        if not np.isnan(out.alarm[0]):
            assert out.alarm[0] >= out.t_star
    print("all alarms (if any) fired at/after T_star: OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_lbi_validation.R 58 lines
# Validation script for lbi_test() (Breitung & Diegel 2025's static
# locally best invariant test). See docs/monitoring.md 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. Breitung & Diegel's eq. 4 telescoping identity ===\n")
cat("(2*sum(Delta y_t * y_{t-1}) = y_T^2 - T*sigma_tilde^2, y_1=0 case)\n\n")
set.seed(1)
T <- 100
y <- c(0, cumsum(rnorm(T)))
dy <- diff(y)
ylag <- y[1:T]
lhs <- 2 * sum(dy * ylag)
sigma2_tilde <- mean(dy^2)
rhs <- y[T + 1]^2 - T * sigma2_tilde
cat("LHS:", lhs, " RHS:", rhs, " match:", isTRUE(all.equal(lhs, rhs)), "\n\n")

cat("=== 2. Basic run ===\n")
set.seed(1)
y2 <- cumsum(rnorm(100))
out <- lbi_test(y2)
print(out)

cat("\n=== 3. H0 calibration: does the statistic actually follow N(0,1)? ===\n")
cat("(the paper claims a standard normal null distribution directly --\n")
cat("this checks that claim, not just an approximately-correct size)\n\n")
run_stat <- function(seed) {
  set.seed(seed)
  y <- cumsum(rnorm(100))
  lbi_test(y)$stat[["series1"]]
}
stats <- sapply(1:500, run_stat)
cat(sprintf("mean=%.3f (expect ~0), sd=%.3f (expect ~1)\n", mean(stats), sd(stats)))
cat(sprintf("empirical P(stat > qnorm(0.95)=%.3f): %.3f (expect ~0.05)\n", qnorm(0.95), mean(stats > qnorm(0.95))))
cat(sprintf("KS test vs N(0,1): p-value = %.3f (expect not tiny)\n", ks.test(stats, "pnorm")$p.value))

cat("\n=== 4. Power under a genuine explosive alternative, vs standard SADF ===\n")
run_power_lbi <- function(seed) {
  set.seed(seed)
  n1 <- 60
  y <- 100 * 1.03^(1:n1) + cumsum(rnorm(n1, sd = 1))
  lbi_test(y)$detected[["series1"]]
}
run_power_sadf <- function(seed) {
  set.seed(seed)
  n1 <- 60
  y <- 100 * 1.03^(1:n1) + cumsum(rnorm(n1, sd = 1))
  full <- radf(y, minw = 20)
  cv <- radf_mc_cv(n = n1, minw = 20, nrep = 200, seed = 1)
  full$sadf > cv$sadf_cv["95%"]
}
cat(sprintf("Detection rate, LBI:  %.3f\n", mean(sapply(1:60, run_power_lbi))))
cat(sprintf("Detection rate, SADF: %.3f\n", mean(sapply(1:60, run_power_sadf))))
radf_lbi_validation.py 72 lines
"""Python cross-check of exuber's lbi_test() (Breitung & Diegel 2025's
static locally best invariant test), mirroring
radf_lbi_validation.R's telescoping-identity, null-distribution, and
basic-run checks.

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_lbi_validation.py

Reference values: a direct R run from the exuber-project/ root,
    Rscript -e 'Sys.setenv(NOT_CRAN="true");
    devtools::load_all("exuber", quiet=TRUE); set.seed(42);
    y <- cumsum(rnorm(80)); res <- lbi_test(y, sig_lvl=95);
    dput(unname(res$stat)); dput(res$crit)'
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.lbi_test import lbi_test  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Telescoping identity (eq. 4) ===")
    rng = np.random.default_rng(2)
    y = np.cumsum(rng.normal(size=60))
    n = len(y)
    dy = np.diff(y)
    ylag = y[: n - 1]
    lhs = 2 * np.sum(dy * ylag)
    sigma2_tilde = np.mean(dy**2)
    rhs = y[-1] ** 2 - y[0] ** 2 - (n - 1) * sigma2_tilde
    np.testing.assert_allclose(lhs, rhs, rtol=1e-10)
    print(f"2*sum(dy*ylag)={lhs:.6f}, y_T^2 - y_0^2 - (n-1)*sigma_tilde^2={rhs:.6f}: OK")

    print("\n=== 2. Basic run -- cross-check vs R ===")
    res = lbi_test(Y42, sig_lvl=95)
    np.testing.assert_allclose(res.stat[0], 0.025526221649211, atol=1e-8)
    np.testing.assert_allclose(res.crit, 1.64485362695147, atol=1e-10)
    print(f"stat={res.stat[0]}, crit={res.crit}, detected={res.detected[0]}")
    assert not res.detected[0]

    print("\n=== 3. Empirical null distribution is standard normal (500 reps) ===")
    rng = np.random.default_rng(3)
    stats = np.array([lbi_test(np.cumsum(rng.normal(size=100))).stat[0] for _ in range(500)])
    print(f"mean={stats.mean():.4f} (theory 0), sd={stats.std():.4f} (theory 1)")
    assert abs(stats.mean()) < 0.15
    assert abs(stats.std() - 1) < 0.15
    fpr = np.mean(stats > 1.64485362695147)
    print(f"empirical false-alarm rate at 95%: {fpr:.3f} (nominal 0.05)")

    print("\n=== 4. Detection power under a genuine explosive alternative ===")
    rng = np.random.default_rng(5)
    detect = []
    for _ in range(60):
        n_bubble = 60
        e = rng.normal(size=n_bubble - 1)
        y_bubble = np.concatenate(([0.0], np.cumsum(1.05 ** np.arange(n_bubble - 1) + e)))
        detect.append(lbi_test(y_bubble, sig_lvl=95).detected[0])
    print(f"detection rate: {np.mean(detect):.3f}")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_monitor_fluc_boundary_validation.R 65 lines
# Validation script for monitor(..., boundary = "fluc") (Homm &
# Breitung 2012's FLUC monitoring detector). See docs/
# monitoring.md 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. Table lookup sanity checks (Homm & Breitung Table 7(i)) ===\n")
cat("sig_lvl=95, n=100, k=2 (expect 4.50):", exuber:::hb_fluc_q(95, 100, 2), "\n")
cat("sig_lvl=95, n=100, k=10 (expect 6.26):", exuber:::hb_fluc_q(95, 100, 10), "\n")
cat("sig_lvl=95, n=50, k=4 (expect 5.11):", exuber:::hb_fluc_q(95, 50, 4), "\n")
cat("sig_lvl=90, n=20, k=2 (expect 2.49):", exuber:::hb_fluc_q(90, 20, 2), "\n")
cat("sig_lvl=99, n=100, k=8 (expect 9.79):", exuber:::hb_fluc_q(99, 100, 8), "\n")
cat("sig_lvl=95, n=73, k=7 (snaps n->50, k->6, expect 5.50):", exuber:::hb_fluc_q(95, 73, 7), "\n")
tryCatch(exuber:::hb_fluc_q(93, 100, 2), error = function(e) cat("sig_lvl=93 correctly errors:", conditionMessage(e), "\n"))

cat("\n=== 2. Basic run with boundary='fluc' ===\n")
set.seed(1)
y <- cumsum(rnorm(150))
out <- monitor(y, r_star = 0.5, minw = 20, boundary = "fluc", sig_lvl = 95)
print(out)

cat("\n=== 3. False-alarm rate under H0: fluc vs kurozumi vs bootstrap ===\n")
run_null <- function(seed, boundary) {
  set.seed(seed)
  y <- cumsum(rnorm(150))
  out <- monitor(y, r_star = 0.5, minw = 20, boundary = boundary, nboot = 99, seed = 1)
  !is.na(out$alarm)
}
cat(sprintf("fluc:      %.3f\n", mean(sapply(1:100, function(s) run_null(s, "fluc")))))
cat(sprintf("kurozumi:  %.3f\n", mean(sapply(1:100, function(s) run_null(s, "kurozumi")))))
cat(sprintf("bootstrap: %.3f\n", mean(sapply(1:100, function(s) run_null(s, "bootstrap")))))

cat("\n=== 4. Detection power under a genuine post-training bubble ===\n")
cat("(HB's own paper: FLUC/CUSUM monitoring generally has LESS power than\n")
cat("a supDF-style test, though FLUC beats CUSUM -- a lower detection rate\n")
cat("here than kurozumi/bootstrap is consistent with that, not a defect)\n\n")
run_detect <- function(seed, boundary) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor(y, r_star = n1 / length(y), minw = 20, boundary = boundary, nboot = 99, seed = 1)
  !is.na(out$alarm)
}
cat(sprintf("fluc:      %.3f\n", mean(sapply(1:30, function(s) run_detect(s, "fluc")))))
cat(sprintf("kurozumi:  %.3f\n", mean(sapply(1:30, function(s) run_detect(s, "kurozumi")))))
cat(sprintf("bootstrap: %.3f\n", mean(sapply(1:30, function(s) run_detect(s, "bootstrap")))))

cat("\n=== 5. Structural check: alarm never before T_star, for fluc boundary ===\n")
run_check <- function(seed) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor(y, r_star = n1 / length(y), minw = 20, boundary = "fluc")
  alarm <- unname(out$alarm)
  if (is.na(alarm)) NA else alarm > out$T_star
}
cat("all TRUE or NA:", all(na.omit(sapply(1:10, run_check))), "\n")
radf_monitor_fluc_boundary_validation.py 69 lines
"""Python cross-check of exuber's monitor(..., boundary = "fluc") (Homm &
Breitung 2012's FLUC detector), mirroring
radf_monitor_fluc_boundary_validation.R's table-lookup and basic-run
checks (the R script's Monte Carlo false-alarm-rate/detection-power
sections are not repeated here -- different RNGs make an exact
cross-language match meaningless; docs/monitoring.md already records
those numbers from the R side).

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_monitor_fluc_boundary_validation.py

Reference values: Homm & Breitung (2012) Table 7(i), already transcribed
into both exuber/R/monitor.R's hb_fluc_table and
pyexuber/src/exuber/monitor.py's _HB_FLUC_TABLE. The stat/boundary
sequence for Y42 is from the same R run cited in
radf_monitor_kurozumi_boundary_validation.py, with `boundary="fluc"`
substituted:
    out <- monitor(y, r_star=0.5, minw=15, boundary="fluc", sig_lvl=95)
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor import _hb_fluc_q, monitor  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def main() -> None:
    print("=== 1. Table lookup sanity checks (Table 7(i)) ===")
    checks = [
        (95, 40, 2, 4.19),  # n_train snaps to 50
        (95, 100, 2, 4.50),
        (90, 20, 10, 4.12),
        (99, 100, 2, 7.76),
    ]
    for sig_lvl, n_train, k, expected in checks:
        q = _hb_fluc_q(sig_lvl, n_train, k)
        print(f"sig_lvl={sig_lvl}, n_train={n_train}, k={k} (expect {expected}): {q}")
        assert q == expected

    print("\n=== 2. Basic run, boundary='fluc' -- cross-check vs R ===")
    mon = monitor(Y42, r_star=0.5, minw=15, boundary="fluc", sig_lvl=95)
    assert mon.t_star == 40
    assert mon.boundary[0] == 4.19
    expected_stat_tail = np.array(
        [-1.5922477876, -1.5806550833, -1.5647907562, -1.6255928209, -1.660938116]
    )
    np.testing.assert_allclose(mon.stat[-5:, 0], expected_stat_tail, atol=1e-8)
    print(f"T_star={mon.t_star}, boundary={mon.boundary[0]}, stat[-5:]={mon.stat[-5:, 0]}")
    assert np.isnan(mon.alarm[0])

    # FLUC and boundary="kurozumi" (s0=0) both reuse radf()'s badf sequence
    # (Homm & Breitung's DF_{t/n} == Kurozumi's SADF(k), both confirmed
    # bit-identical to badf -- docs/monitoring.md, "Implementation (FLUC)").
    mon_k = monitor(Y42, r_star=0.5, minw=15, boundary="kurozumi", sig_lvl=95)
    np.testing.assert_allclose(mon.stat, mon_k.stat)
    print("\nFLUC stat path is bit-identical to boundary='kurozumi' s0=0 (both == badf): OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_monitor_gsadf_s0_validation.R 108 lines
# Validation of monitor(..., boundary = "kurozumi", s0 = 0.4/0.8) --
# Kurozumi (2020)'s GSADF_{s0} monitoring detector, re-triaged and shipped
# after initially being scoped out as needing new recursion code.
# See docs/monitoring.md, "Kurozumi (2020, 2021) -- SADF and
# GSADF cases both implemented", for the full writeup.
#
# Run from the exuber-project/ root.

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

cat("=== 1. Backward compatibility: s0 = 0 (default) unchanged ===\n")
set.seed(1)
y <- cumsum(rnorm(150))
o_default <- monitor(y, r_star = 0.5, minw = 20, boundary = "kurozumi", sig_lvl = 95)
o_explicit <- monitor(y, r_star = 0.5, minw = 20, boundary = "kurozumi", s0 = 0, sig_lvl = 95)
cat("stat identical:", identical(o_default$stat, o_explicit$stat), "\n")
cat("boundary identical:", identical(o_default$boundary, o_explicit$boundary), "\n")

cat("\n=== 2. kurozumi_gsadf_stat() matches radf()$badf exactly at the s0 -> 0 limit ===\n")
minw <- 20
badf <- radf(y, minw = minw, lag = 0)$badf[, 1]
stat_limit <- exuber:::kurozumi_gsadf_stat(y, T_star = minw, s0 = 1 / minw)
cat("max|badf - stat(k1_max=1)|:", max(abs(unname(stat_limit) - badf)), "\n")

cat("\n=== 3. Formula-exact vs. brute-force lm() search over the restricted band ===\n")
T_star <- 75
s0 <- 0.4
k1_max <- floor(T_star * s0)
n <- length(y)
stat <- exuber:::kurozumi_gsadf_stat(y, T_star, s0)
for (k_check in c(5, 30, 60)) {
  t_check <- T_star + k_check
  brute <- sapply(seq_len(k1_max), function(k1) {
    yy <- y[k1:t_check]
    fit <- lm(diff(yy) ~ yy[-length(yy)])
    summary(fit)$coefficients[2, 3]
  })
  cat(sprintf(
    "k=%d  brute_max=%.6f  fast=%.6f  |diff|=%.2e\n",
    k_check, max(brute), stat[k_check], abs(max(brute) - stat[k_check])
  ))
}

cat("\n=== 4. Table 1 GSADF lookups (q04_df/q08_df columns) ===\n")
for (lv in c(90, 95, 99)) {
  cat(sprintf("sig_lvl=%g s_bar=1 s0=0.4 -> %.4f\n", lv, exuber:::kurozumi_gsadf_q(lv, 1, 0.4)))
}
cat("sig_lvl=95 s_bar=1 s0=0.8 ->", exuber:::kurozumi_gsadf_q(95, 1, 0.8), "(expect 2.3330)\n")
cat("sig_lvl=95 s_bar=1 s0=0.6 (tie snap to 0.4) ->", exuber:::kurozumi_gsadf_q(95, 1, 0.6), "\n")

cat("\n=== 5. Alarms never fire before T*+1 ===\n")
set.seed(3)
ok <- TRUE
for (i in 1:30) {
  yy <- cumsum(rnorm(150))
  oo <- monitor(yy, r_star = 0.5, boundary = "kurozumi", s0 = 0.4, sig_lvl = 95)
  if (!is.na(oo$alarm) && oo$alarm <= 75) ok <- FALSE
}
cat("all alarms strictly after T_star (30 reps):", ok, "\n")

cat("\n=== 6. Empirical false-alarm rate under H0 ===\n")
set.seed(10)
nrep <- 300
n <- 150
T_star <- 75
fa_sadf <- fa_gsadf04 <- fa_gsadf08 <- 0
for (i in seq_len(nrep)) {
  yy <- cumsum(rnorm(n))
  o_sadf <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0, sig_lvl = 95)
  o_g04 <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0.4, sig_lvl = 95)
  o_g08 <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0.8, sig_lvl = 95)
  if (!is.na(o_sadf$alarm)) fa_sadf <- fa_sadf + 1
  if (!is.na(o_g04$alarm)) fa_gsadf04 <- fa_gsadf04 + 1
  if (!is.na(o_g08$alarm)) fa_gsadf08 <- fa_gsadf08 + 1
}
cat(sprintf("SADF (s0=0)   FA rate: %.3f (nominal 0.05)\n", fa_sadf / nrep))
cat(sprintf("GSADF s0=0.4  FA rate: %.3f\n", fa_gsadf04 / nrep))
cat(sprintf("GSADF s0=0.8  FA rate: %.3f\n", fa_gsadf08 / nrep))

cat("\n=== 7. Detection power on a post-training-bubble DGP ===\n")
set.seed(20)
nrep <- 60
make_bubble_series <- function(n, T_star, bubble_start_frac = 0.65, rho = 1.03) {
  yy <- numeric(n)
  yy[1:T_star] <- cumsum(rnorm(T_star))
  bstart <- T_star + round((n - T_star) * (bubble_start_frac - T_star / n) / (1 - T_star / n))
  bstart <- max(bstart, T_star + 5)
  for (t in (T_star + 1):n) {
    yy[t] <- if (t < bstart) yy[t - 1] + rnorm(1) else rho * yy[t - 1] + rnorm(1)
  }
  yy
}
det_sadf <- det_g04 <- det_g08 <- 0
for (i in seq_len(nrep)) {
  yy <- make_bubble_series(n, T_star)
  o_sadf <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0, sig_lvl = 95)
  o_g04 <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0.4, sig_lvl = 95)
  o_g08 <- monitor(yy, r_star = T_star, boundary = "kurozumi", s0 = 0.8, sig_lvl = 95)
  if (!is.na(o_sadf$alarm)) det_sadf <- det_sadf + 1
  if (!is.na(o_g04$alarm)) det_g04 <- det_g04 + 1
  if (!is.na(o_g08$alarm)) det_g08 <- det_g08 + 1
}
cat(sprintf("SADF (s0=0)   power: %.3f\n", det_sadf / nrep))
cat(sprintf("GSADF s0=0.4  power: %.3f\n", det_g04 / nrep))
cat(sprintf("GSADF s0=0.8  power: %.3f\n", det_g08 / nrep))

cat("\ndone\n")
radf_monitor_gsadf_s0_validation.py 103 lines
"""Python cross-check of exuber's monitor(..., boundary = "kurozumi",
s0 = 0.4/0.8) (Kurozumi 2020's GSADF_{s0} generalization), mirroring
radf_monitor_gsadf_s0_validation.R's table-lookup, formula-exact and
basic-run checks. The closed-form GSADF_{s0}(k) statistic is entirely
self-contained (no radf()/C++ extension call, unlike s0=0/"fluc"), so
every check below runs fully offline.

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_monitor_gsadf_s0_validation.py

Reference values:
  - Table 1's q04_df/q08_df columns, transcribed into both
    exuber/R/monitor.R and pyexuber/src/exuber/monitor.py.
  - The formula-exact brute-force check reimplements the with-intercept
    OLS ADF t-statistic independently (np.linalg.lstsq per window) and
    compares against the vectorized cumulative-sum construction -- same
    check exuber/R/monitor.R's own validation performed against lm().
  - The Y42/boundary/stat tail values: a direct R run
    (`monitor(y, r_star=0.5, minw=15, boundary="kurozumi", s0=0.4,
    sig_lvl=95)`), same series/command family as
    radf_monitor_kurozumi_boundary_validation.py, run from the
    exuber-project/ root.
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor import _kurozumi_gsadf_q, _kurozumi_gsadf_stat, monitor  # noqa: E402

from radf_monitor_kurozumi_boundary_validation import Y42  # noqa: E402


def _brute_force_gsadf_stat(y: np.ndarray, t_star: int, s0: float) -> np.ndarray:
    n = len(y)
    dy = np.diff(y)
    ylag = y[: n - 1]
    k1_max = max(int(np.floor(t_star * s0)), 1)

    out = []
    for t in range(t_star - 1, n - 1):
        best = -np.inf
        for k1 in range(k1_max):
            yy, dd = ylag[k1 : t + 1], dy[k1 : t + 1]
            x_mat = np.column_stack([np.ones(len(yy)), yy])
            beta, _, _, _ = np.linalg.lstsq(x_mat, dd, rcond=None)
            resid = dd - x_mat @ beta
            sigma2 = np.sum(resid**2) / (len(yy) - 2)
            se = np.sqrt(sigma2 * np.linalg.inv(x_mat.T @ x_mat)[1, 1])
            best = max(best, beta[1] / se)
        out.append(best)
    return np.array(out)


def main() -> None:
    print("=== 1. Table lookup sanity checks (q04_df/q08_df) ===")
    checks = [(95, 1, 0.4, 1.8081), (95, 1, 0.8, 2.3330), (95, 1, 0.6, 1.8081)]
    for sig_lvl, s_bar, s0, expected in checks:
        q = _kurozumi_gsadf_q(sig_lvl, s_bar, s0)
        print(f"sig_lvl={sig_lvl}, s_bar={s_bar}, s0={s0} (expect {expected}): {q}")
        assert q == expected

    print("\n=== 2. Formula-exact check vs brute-force OLS ===")
    for s0 in (0.4, 0.8):
        expected = _brute_force_gsadf_stat(Y42, 40, s0)
        actual = _kurozumi_gsadf_stat(Y42, 40, s0)
        np.testing.assert_allclose(actual, expected, atol=1e-8)
        print(f"s0={s0}: vectorized construction matches brute-force lstsq scan (max |diff| = "
              f"{np.max(np.abs(actual - expected)):.2e})")

    print("\n=== 3. Basic run, boundary='kurozumi', s0=0.4 -- cross-check vs R ===")
    mon = monitor(Y42, r_star=0.5, minw=15, boundary="kurozumi", s0=0.4, sig_lvl=95)
    assert mon.t_star == 40
    expected_boundary_tail = np.array(
        [1.3819348577, 1.3826566781, 1.3833643719, 1.3840584815, 1.3847395186]
    )
    expected_stat_tail = np.array(
        [-1.5282745861, -1.5179824638, -1.5031546411, -1.5618190616, -1.5957597584]
    )
    np.testing.assert_allclose(mon.boundary[-5:], expected_boundary_tail, atol=1e-8)
    np.testing.assert_allclose(mon.stat[-5:, 0], expected_stat_tail, atol=1e-8)
    assert np.isnan(mon.alarm[0])
    print(f"T_star={mon.t_star}, boundary[-5:]={mon.boundary[-5:]}")

    print("\n=== 4. Structural check: alarm never before T_star (s0=0.4 and 0.8) ===")
    rng = np.random.default_rng(0)
    for s0 in (0.4, 0.8):
        for _ in range(15):
            y = np.cumsum(rng.normal(size=150))
            out = monitor(y, r_star=0.5, minw=20, boundary="kurozumi", s0=s0)
            if not np.isnan(out.alarm[0]):
                assert out.alarm[0] >= out.t_star
    print("all alarms (if any) fired at/after T_star, for s0 in {0.4, 0.8}: OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_monitor_kurozumi_boundary_validation.R 61 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Table lookup sanity checks ===\n")
cat("sig_lvl=95, s_bar=1 (expect 1.0381):", exuber:::kurozumi_sadf_q(95, 1), "\n")
cat("sig_lvl=95, s_bar=1.2 (snap to 1, expect 1.0381):", exuber:::kurozumi_sadf_q(95, 1.2), "\n")
cat("sig_lvl=95, s_bar=3 (expect 1.3330):", exuber:::kurozumi_sadf_q(95, 3), "\n")
cat("sig_lvl=95, s_bar=5 (expect 1.4255):", exuber:::kurozumi_sadf_q(95, 5), "\n")
cat("sig_lvl=90, s_bar=1 (expect 0.6946):", exuber:::kurozumi_sadf_q(90, 1), "\n")
cat("sig_lvl=99, s_bar=1 (expect 1.6474):", exuber:::kurozumi_sadf_q(99, 1), "\n")
tryCatch(exuber:::kurozumi_sadf_q(93, 1), error = function(e) cat("sig_lvl=93 correctly errors:", conditionMessage(e), "\n"))
cat("\n")

cat("=== 2. Basic run with boundary='kurozumi' ===\n")
set.seed(1)
y <- cumsum(rnorm(150))
out <- monitor(y, r_star = 0.5, minw = 20, boundary = "kurozumi", sig_lvl = 95)
print(out)
cat("\n")

cat("=== 3. Empirical false-alarm rate under H0, boundary='kurozumi' vs 'bootstrap' ===\n")
run_null <- function(seed, boundary) {
  set.seed(seed)
  y <- cumsum(rnorm(150))
  out <- monitor(y, r_star = 0.5, minw = 20, boundary = boundary, nboot = 99, seed = 1)
  !is.na(out$alarm)
}
rate_kuro <- mean(sapply(1:100, function(s) run_null(s, "kurozumi")))
rate_boot <- mean(sapply(1:100, function(s) run_null(s, "bootstrap")))
cat(sprintf("False-alarm rate, kurozumi boundary (100 reps, target ~10%% since s_bar=(150-75)/75=1 -> nearest table row): %.3f\n", rate_kuro))
cat(sprintf("False-alarm rate, bootstrap boundary (100 reps): %.3f\n\n", rate_boot))

cat("=== 4. Detection power under a genuine post-training bubble ===\n")
run_detect <- function(seed, boundary) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor(y, r_star = n1 / length(y), minw = 20, boundary = boundary, nboot = 99, seed = 1)
  c(alarm = unname(out$alarm), true_origination = n1)
}
res_kuro <- t(sapply(1:30, function(s) run_detect(s, "kurozumi")))
res_boot <- t(sapply(1:30, function(s) run_detect(s, "bootstrap")))
cat(sprintf("Detection rate, kurozumi:  %.3f\n", mean(!is.na(res_kuro[, "alarm"]))))
cat(sprintf("Detection rate, bootstrap: %.3f\n", mean(!is.na(res_boot[, "alarm"]))))

cat("\n=== 5. Structural check: alarm never before T_star, for kurozumi boundary ===\n")
run_check <- function(seed) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor(y, r_star = n1 / length(y), minw = 20, boundary = "kurozumi")
  alarm <- unname(out$alarm)
  if (is.na(alarm)) NA else alarm > out$T_star
}
cat("all TRUE or NA:", all(na.omit(sapply(1:10, run_check))), "\n")
radf_monitor_kurozumi_boundary_validation.py 92 lines
"""Python cross-check of exuber's monitor(..., boundary = "kurozumi", s0 = 0)
(Kurozumi 2020's closed-form SADF boundary), mirroring
radf_monitor_kurozumi_boundary_validation.R's checks 1 and 5 (checks 2-4 of
the R script exercise `boundary = "bootstrap"`, which pyexuber does not
implement -- see docs/parity.md and exuber.monitor's module docstring).

Run standalone: uv run --project pyexuber python
docs/replication/monitoring/radf_monitor_kurozumi_boundary_validation.py

Reference values below (Table 1 lookups, and the deterministic
badf/boundary sequence for a fixed series) come from two sources:
  - Kurozumi (2020) Table 1 itself, already transcribed into both
    exuber/R/monitor.R and pyexuber/src/exuber/monitor.py -- checked here
    for transcription-consistency between the two ports, not re-derived.
  - A direct R run, for a bit-for-bit cross-check of the statistic path:
    `Rscript -e 'Sys.setenv(NOT_CRAN="true"); devtools::load_all("exuber",
    quiet=TRUE); set.seed(42); y <- cumsum(rnorm(80));
    out <- monitor(y, r_star=0.5, minw=15, boundary="kurozumi", sig_lvl=95);
    dput(out$T_star); dput(unname(out$boundary));
    dput(round(tail(as.numeric(out$stat), 5), 10))'`
    run from the exuber-project/ root.
"""

import sys
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "pyexuber" / "src"))

from exuber.monitor import _kurozumi_sadf_q, monitor  # noqa: E402

# set.seed(42); y <- cumsum(rnorm(80))
Y42 = np.array(
    [
        1.3709584471, 0.8062602758, 1.1693886871, 1.802251292, 2.2065196152,
        2.1003950991, 3.6119170965, 3.5172580581, 5.535681772, 5.4729676729,
        6.7778373272, 9.0644827199, 7.6756220188, 7.3968332519, 7.2635119156,
        7.8994623136, 7.6152093922, 4.9587539713, 2.5182870427, 3.8384003885,
        3.5317617944, 1.7504533604, 1.5785360046, 2.7932107038, 4.6884041651,
        4.2579350335, 4.0006656507, 2.2375025655, 2.6975999203, 2.0576050444,
        2.5130551676, 3.2178925048, 4.2529960268, 3.6440696514, 4.1490247747,
        2.4320160956, 1.6475570873, 0.7966494931, -1.6175581569, -1.58143555,
        -1.3754369498, -1.7364942483, -0.9783310126, -1.7050358397, -3.0733168841,
        -2.6404988582, -3.4518920344, -2.0077907727, -2.4392369753, -1.7835890919,
        -1.4616638267, -2.2455027676, -0.6697752478, -0.0268759421, 0.0628847045,
        0.3394354518, 1.0187242679, 1.1085571544, -1.8845329287, -1.5996499752,
        -1.9668846179, -1.7816540531, -1.1998303257, 0.1999065016, -0.5273855579,
        0.7751570742, 1.1110051939, 2.1495112926, 3.0702398609, 3.7911180238,
        2.7479990852, 2.6578126986, 3.2813308606, 2.3278075028, 1.7849786883,
        2.3659751859, 3.1341539238, 3.5979215123, 2.7121452149, 1.6123643163,
    ]
)


def main() -> None:
    print("=== 1. Table lookup sanity checks ===")
    checks = [
        (95, 1, 1.0381), (95, 1.2, 1.0381), (95, 3, 1.3330),
        (95, 5, 1.4255), (90, 1, 0.6946), (99, 1, 1.6474),
    ]
    for sig_lvl, s_bar, expected in checks:
        q = _kurozumi_sadf_q(sig_lvl, s_bar)
        print(f"sig_lvl={sig_lvl}, s_bar={s_bar} (expect {expected}): {q}")
        assert q == expected

    print("\n=== 2. Basic run, boundary='kurozumi', s0=0 -- cross-check vs R ===")
    mon = monitor(Y42, r_star=0.5, minw=15, boundary="kurozumi", sig_lvl=95)
    assert mon.t_star == 40
    assert mon.boundary[0] == 1.0381
    expected_stat_tail = np.array(
        [-1.5922477876, -1.5806550833, -1.5647907562, -1.6255928209, -1.660938116]
    )
    np.testing.assert_allclose(mon.stat[-5:, 0], expected_stat_tail, atol=1e-8)
    print(f"T_star={mon.t_star}, boundary={mon.boundary[0]}, stat[-5:]={mon.stat[-5:, 0]}")
    assert np.isnan(mon.alarm[0])

    print("\n=== 3. Structural check: alarm never before T_star ===")
    rng = np.random.default_rng(0)
    for _ in range(10):
        y = np.cumsum(rng.normal(size=150))
        out = monitor(y, r_star=0.5, minw=20, boundary="kurozumi")
        if not np.isnan(out.alarm[0]):
            assert out.alarm[0] >= out.t_star
    print("all alarms (if any) fired at/after T_star: OK")

    print("\nAll checks passed.")


if __name__ == "__main__":
    main()
radf_monitor_validation.R 54 lines
Sys.setenv(NOT_CRAN = "true")
options(exuber.parallel = FALSE, exuber.show_progress = FALSE)
devtools::load_all("exuber", quiet = TRUE)

cat("=== 1. Basic run: no bubble anywhere -- should mostly not alarm ===\n")
set.seed(1)
n <- 150
y_null <- cumsum(rnorm(n))
out_null <- monitor(y_null, r_star = 0.5, minw = 20, nboot = 199, seed = 1)
print(out_null)

cat("\n=== 2. Empirical false-alarm rate under H0 (no bubble anywhere),",
    "should be roughly <= nominal level over the monitoring horizon ===\n")
run_null <- function(seed) {
  set.seed(seed)
  n <- 150
  y <- cumsum(rnorm(n))
  out <- monitor(y, r_star = 0.5, minw = 20, nboot = 199, seed = 1)
  !is.na(out$alarm)
}
false_alarm_rate <- mean(sapply(1:40, run_null))
cat(sprintf("False alarm rate (40 reps, H0 throughout, T-T*=75 monitoring obs): %.3f\n",
            false_alarm_rate))
cat("(NOTE: this is a CUMULATIVE false-alarm probability over 75 monitoring points\n",
    " at a per-point 95% threshold, so a much higher rate than 5% is expected and\n",
    " not itself a red flag -- Family A/PSY-style monitoring's FPR grows with the\n",
    " monitoring horizon, exactly as flagged in monitoring.md's own AHLST/Whitehouse\n",
    " discussion; this is descriptive, not a strict pass/fail bound.)\n\n")

cat("=== 3. A genuine bubble starting AFTER T* should be detected, and",
    "the alarm date should be close to the true origination date ===\n")
run_detect <- function(seed) {
  set.seed(seed)
  n1 <- 75; n2 <- 40
  normal_part <- cumsum(rnorm(n1))
  expl_part <- normal_part[n1] * 1.05^(1:n2) + cumsum(rnorm(n2, sd = 0.3))
  y <- c(normal_part, expl_part)
  out <- monitor(y, r_star = n1 / length(y), minw = 20, nboot = 199, seed = 1)
  c(alarm = unname(out$alarm), true_origination = n1)
}
res <- t(sapply(1:15, run_detect))
cat("Detection rate:", mean(!is.na(res[, "alarm"])), "\n")
cat("Alarm delay (alarm - true origination) among detections:\n")
print(summary(res[!is.na(res[, "alarm"]), "alarm"] - res[!is.na(res[, "alarm"]), "true_origination"]))

cat("\n=== 4. No false alarm strictly WITHIN the training window",
    "(monitoring only starts after T*) ===\n")
# check monitoring rows never include anything before T*
set.seed(5)
n <- 150; T_star <- 75
y <- cumsum(rnorm(n))
out <- monitor(y, r_star = T_star, minw = 20, nboot = 99, seed = 1)
cat("T_star:", out$T_star, " n monitoring rows:", nrow(out$stat), "\n")