Skip to content
exuber

Replication

Simulation DGPs

Data-generating processes for the axes the original sim_*() functions do not cover.

This page is the technical record behind the methods. To learn how to run them, start with Simulating bubbles in the guide.

This file differs from the other family files, because it catalogues data-generating processes (DGPs) for simulating bubble series and not tests or statistics. The DGPs come from the Monte Carlo sections of the papers in this project, plus a few papers added for this purpose. The question the file answers is what the sim.R of exuber (sim_psy1, sim_psy2, sim_ps1, sim_ps2, sim_blan, sim_evans, sim_div) does not cover.

The seven existing sim_* functions share one property: fixed (homoskedastic), i.i.d. Gaussian innovations. They differ in the mean equation (PSY-style regime-switching AR(1) against Blanchard and Evans rational bubbles) and in the number and shape of bubbles and collapses. None has time-varying volatility, GARCH, non-Gaussian innovations, stochastic switch timing or a multi-series or factor structure. A DGP is catalogued below only if it differs on one of those axes through a different mechanism, and not through a reparameterisation of an equation that already exists (see the exclusions at the end).

All 15 catalogued DGPs are implemented in exuber/R/sim.R and tested in exuber/tests/testthat/test-sim-dgp.R (39 assertions). The tests use formula-exact checks against independent brute-force reimplementations where feasible, checks of published properties (for example the price floor of sim_tree() and the empirical transition rate of sim_msbubble()), and moment and reproducibility checks otherwise.

Items 1, 2 and 3 compose as optional arguments of sim_psy1() (e, shifts, coef_noise). Five small, reusable generators cover items 8 to 12 (stochastic volatility, innovation distribution, level shift, long memory and stochastic coefficient): sim_innov(), sim_vol_garch(), sim_vol_cir(), sim_vol_sv() and sim_fi(). sim_blan() has a type = "rotermann_wilfling" option (item 13). The six DGPs with their own architecture (items 4 to 7, 14 and 15) are standalone functions: sim_common(), sim_coexplosive(), sim_tree(), sim_mar(), sim_msbubble() and sim_falsebubble().

A DGP does not imply that its paired test exists. The level-shift robustness result of Harvey, Leybourne, Tatlow & Zu (2025), which motivates item 3, is covered by radf_sign() and radf_sign_dm() (see volatility-robustness.md). sim_falsebubble() and sim_msbubble() have no dedicated test in exuber. They are stress-test and demonstration series for the existing PSY/GSADF machinery, the role that sim_evans() already plays.

Two implementation notes. sim_fi() convolves the truncated MA(∞\infty) filter by hand over m extra truncation-lag innovations. stats::filter(..., sides = 1) needs strictly more input than filter taps to return non-NA values, and stats::filter() and stats::convolve() also crashed on the R installation used for this project (Windows, R 4.6.1) independently of exuber. sim_tree() clips pt=Φ(Xt)p_t = \Phi(X_t) into [10−10,1−10−10][10^{-10}, 1 - 10^{-10}], because ptp_t can round to exactly 0 or 1 in the tail of a long series, which would turn ξ1t\xi_{1t} and εt\varepsilon_t into 0/00/0.

DGPs from papers already in the project

#DGPSourceSingle/multiImplementation
1CIR-type stochastic volatilityHarvey, Leybourne & Zu (2019)singlesim_vol_cir()
2AR(1) lognormal stochastic volatility, near-unit persistenceSarkar & Wells (2025/2026)singlesim_vol_sv()
3Deterministic level shifts (mean jumps)Harvey, Leybourne, Tatlow & Zu (2025)singlesim_psy1(..., shifts = ...)
4Latent common-factor bubbleChen, Phillips & Shi (2023)common, multi-seriessim_common()
5Bivariate co-explosive linkageEvripidou, Harvey, Leybourne & Sollis (2022)bivariatesim_coexplosive()
6Stochastic branching-tree (random-coefficient RCA)Gourieroux & Jasiak (2025)singlesim_tree()
7Mixed causal-noncausal AR (MAR), heavy-tailedBlasques, Koopman, Mingoli & Telg (2025)single, self-terminatingsim_mar()
8Fractionally integrated (long-memory) innovationsLui, Phillips & Yu (2024)singlesim_fi()
9PSY equation with heavy-tailed or skewed innovationsWu, Shi & Wu (2025)singlesim_innov(dist = "t"/"skew_t")
10GARCH(1,1) and logistic smooth-transition volatilityWhitehouse, Harvey & Leybourne (2025); Harvey, Leybourne, Taylor & Zu (2024)singlesim_vol_garch()
11Stochastic explosive coefficient (persistence itself random)Kurozumi & Nishi (2025)singlesim_psy1(..., coef_noise = ...)

1. CIR-type stochastic volatility

Harvey, Leybourne & Zu (2019) use a square-root (Cox–Ingersoll–Ross) diffusion for volatility, “representative of Bollerslev and Zhou (2002)”, as a robustness design beyond their main deterministic-volatility one:

dσ2(r)=0.03 (0.25−σ2(r)) dr+0.1 σ(r) dB(r).d\sigma^2(r) = 0.03\,\bigl(0.25 - \sigma^2(r)\bigr)\,dr + 0.1\,\sigma(r)\,dB(r).

Each replication simulates it from NIID(0,1) Brownian increments, independent of the level innovations, and feeds the same PSY-style level process that sim_psy1 implements. The new element is continuous-time stochastic volatility, where sim.R has fixed or deterministic volatility.

2. AR(1) lognormal stochastic volatility

Sarkar & Wells (2025, Section 2) reused in Sarkar & Wells (2026, eq. 7):

yt=ρn yt−1+ut,ut=σt εt,log⁡σt2=ϕnlog⁡σt−12+ηt.y_t = \rho_n\, y_{t-1} + u_t, \qquad u_t = \sigma_t\, \varepsilon_t, \qquad \log \sigma_t^2 = \phi_n \log \sigma_{t-1}^2 + \eta_t .

Both ρn→1\rho_n \to 1 and ϕn→1\phi_n \to 1, which makes the process double local-to-unity in the mean and in the variance. It is a discrete-time stochastic volatility model with a persistent, near-integrated log-variance. No existing sim_* function has a persistent variance process.

3. Deterministic level shifts

Harvey, Leybourne, Tatlow & Zu (2025):

yt=yt−1+∑j=1mδj 1(t≥τj)+εt(null).y_t = y_{t-1} + \sum_{j=1}^{m} \delta_j\, \mathbf 1(t \ge \tau_j) + \varepsilon_t \qquad \text{(null)}.

The process has an arbitrary number mm of jumps of size δj\delta_j at unknown dates τj\tau_j, with an explosive regime layered on top for the alternative (Section 5.2). Nothing in sim.R has jump components.

4. Latent common-factor bubble

Chen, Phillips & Shi (2023) eq. 2.3 and 2.7–2.9:

Xt=Λft+et.X_t = \Lambda f_t + e_t .

There are NN observed series and one latent factor ftf_t that follows a PSY-style unit-root, explosive, collapse sequence. The loadings are Λ∼U[0,2]\Lambda \sim U[0,2], and the idiosyncratic noise has σe=0.1\sigma_e = 0.1. A collapse-splicing construction (paper lines 960–977) avoids discontinuities at the regime boundary. The new element is one common bubble that drives many series jointly. Every existing sim_* function is single-series.

5. Bivariate co-explosive linkage

Evripidou, Harvey, Leybourne & Sollis (2022) The series xtx_t is generated from the regime-dummy Models 1–4 of HLS (each individually PSY-style), and a second series is linked to it:

yt=μy+ϕx xt−i+ϕz zt+εy,t.y_t = \mu_y + \phi_x\, x_{t-i} + \phi_z\, z_t + \varepsilon_{y,t} .

The second series combines a lead or lagged copy of the explosive series xtx_t with a third, latent explosive series ztz_t, and the heteroskedasticity is timed to the regime changes. The new element is a two-series lead and lag linkage of explosive components. It differs from the common-factor case (item 4), which shares one factor and does not link two distinct explosive series.

6. Stochastic branching-tree bubble

Gourieroux & Jasiak (2025) describe an affine autoregression with a stochastic coefficient. It can be represented as a random-coefficient AR process generated by a binomial tree with stochastic branching intensity, in contrast to the deterministic branches of Cox–Ross–Rubinstein. The Blanchard & Watson (1982) bubble, already sim_blan, is the special case of constant intensity. The branching mechanism generates the path directly, with no fixed collapse probability.

7. Mixed causal-noncausal AR (MAR)

Blasques, Koopman, Mingoli & Telg (2025):

(1−φ1L)(1−ψ1L−1) yt=εt.(1 - \varphi_1 L)(1 - \psi_1 L^{-1})\, y_t = \varepsilon_t .

The causal root is φ1=0.7\varphi_1 = 0.7, ψ1\psi_1 is the noncausal root, and the innovations are Cauchy or Student-t(2)t(2). The noncausal component generates transient, self-terminating local bubbles with no scripted regime dates. The process differs on every axis: a lag-polynomial form in place of a regime-dummy AR, an implicit collapse and heavy-tailed non-Gaussian innovations.

8. Fractionally integrated innovations

Lui, Phillips & Yu (2024):

yt=yt−1+ut,ut=Δ−dεt(d>0, εt i.i.d. with finite (2+δ) moments).y_t = y_{t-1} + u_t, \qquad u_t = \Delta^{-d} \varepsilon_t \quad (d > 0,\ \varepsilon_t \text{ i.i.d. with finite } (2+\delta) \text{ moments}).

The innovations are FI(d)\mathrm{FI}(d) (long memory) and not i.i.d. The paper develops an explosive-alternative analogue in Section 4. The new element is long-range-dependent noise in the unit-root or explosive equation.

9. PSY equation with heavy-tailed or skewed innovations

Wu, Shi & Wu (2025) eq. 6, use the standard PSY unit-root, explosive, collapse equation with innovations from N(0,1)N(0,1), t(3)t(3), skewed-t(3,−0.75)t(3, -0.75) and skewed-t(3,+0.75)t(3, +0.75). Only the innovation distribution is new. Every existing sim_* function uses fixed Gaussian noise.

10. GARCH(1,1) and smooth-transition volatility

Whitehouse, Harvey & Leybourne (2025):

zt=ht1/2 εt,ht=0.1+0.1 zt−12+0.8 ht−1.z_t = h_t^{1/2}\, \varepsilon_t, \qquad h_t = 0.1 + 0.1\, z_{t-1}^2 + 0.8\, h_{t-1} .

Harvey, Leybourne, Taylor & Zu (2024) use the same GARCH(1,1) specification. Next to their main design, they use a logistic smooth-transition volatility function:

σ(r)=σ1+σ2−σ11+exp⁡{−κ(r−δ)}.\sigma(r) = \sigma_1 + \frac{\sigma_2 - \sigma_1}{1 + \exp\{-\kappa (r - \delta)\}} .

The new elements are conditional GARCH heteroskedasticity and a smooth transition between volatility regimes in place of an instant jump.

11. Stochastic explosive coefficient

Kurozumi & Nishi (2025) is documented in volatility-robustness.md. The coefficient 1+c1/T+a ut/T1 + c_1/T + a\, u_t/\sqrt T replaces the deterministic 1+c/Tα1 + c/T^\alpha, so the persistence parameter itself is random and not only the noise scale.

DGPs from additional papers

12. TGARCH(1,1) with leverage effect

Monschang, V. & Wilfling, B. (2021). Sup-ADF-style bubble-detection methods under test. Empirical Economics, 61, 145–172, doi:10.1007/s00181-020-01859-7. Open access (CQE Working Paper 78/2019):

εt=st ht1/2,ht=ω+α εt−12+β ht−1+γ εt−12 1(εt−1<0).\varepsilon_t = s_t\, h_t^{1/2}, \qquad h_t = \omega + \alpha\, \varepsilon_{t-1}^2 + \beta\, h_{t-1} + \gamma\, \varepsilon_{t-1}^2\, \mathbf 1(\varepsilon_{t-1} < 0) .

The process is calibrated to NASDAQ estimates (α=0.4387\alpha = 0.4387, γ=0.1306\gamma = 0.1306, β=0.9319\beta = 0.9319). Relative to item 10, the new element is an asymmetric (sign-dependent) shock response, the leverage effect.

It is sim_vol_garch(omega, alpha, beta, gamma), the same function as item 10 with gamma as the leverage parameter. sim_vol_garch(omega = 0.4387, alpha = 0, beta = 0.9319, gamma = 0.1306) reproduces the NASDAQ calibration of the paper, and the default gamma = 0 gives plain GARCH(1,1).

13. Lognormal-mixture rational bubble (Rotermann–Wilfling)

The same paper, eq. 4:

Bt+1={Bt ut/δwith probability π,1−πδ1−π Bt utwith probability 1−π,ut∼iidlognormal.B_{t+1} = \begin{cases} B_t\, u_t / \delta & \text{with probability } \pi,\\[2pt] \dfrac{1 - \pi\delta}{1 - \pi}\, B_t\, u_t & \text{with probability } 1 - \pi, \end{cases} \qquad u_t \overset{\text{iid}}{\sim} \text{lognormal}.

It produces recurring, stochastically deflating trajectories and no single full collapse to a fixed floor, unlike sim_blan and sim_evans. The new element is partial, probabilistic deflation in place of total collapse to noise.

It is sim_blan(type = "rotermann_wilfling", delta, rw_sigma), a branch of the existing function, because it shares the two-regime structure with probability π\pi and only the update rule per regime differs. test-sim-dgp.R verifies that it stays strictly positive, which is a structural invariant of the multiplicative recursion.

14. Markov-switching present-value bubble

Chan, J. C. C. & Santi, C. (2021). Speculative Bubbles in Present-Value Models: A Bayesian Markov-Switching State Space Approach. Journal of Economic Dynamics and Control, 127, 104101. Open access (author’s site):

bt=1λSt+1 bt−1+εbt,St∈{1,2} first-order Markov with transition probabilities p11,p22.b_t = \frac{1}{\lambda_{S_t + 1}}\, b_{t-1} + \varepsilon_{bt}, \qquad S_t \in \{1, 2\} \text{ first-order Markov with transition probabilities } p_{11}, p_{22}.

Regime 1 is “surviving” (λ<1\lambda < 1, explosive) and regime 2 is “collapsing” (λ>1\lambda > 1, mean-reverting). The bubble sits inside a full present-value state-space model with time-varying expected returns and dividend growth, and the section “Simulated Datasets” of the paper (Table 2) generates artificial data from these parameters. The new element is that the timing of the switch is itself stochastic (a Markov chain). The PSY-style DGPs in sim.R script the dates as fixed fractions of nn.

It is sim_msbubble(p11, p22, lambda1, lambda2, sigma_b). It covers only the bubble component btb_t and not the full present-value state-space model. Eq. 16 of the source applies the regime coefficient through St+1S_{t+1}, the realised regime of the next period, while the implementation uses the contemporaneous StS_t, which only changes which time step a given draw of SS labels. test-sim-dgp.R verifies that the empirical self-transition rate of the simulated regime path matches p11p_{11} and p22p_{22} within Monte Carlo tolerance.

15. Deterministic technology-adoption “false bubble” null

Chen, H., Chen, L., Huang, D., Li, Y. & Zhang, Z. (2026). Technology Fundamentals and False Bubble Detection: Evidence from Dot-Com and AI Episodes. arXiv:2604.25826. Open

The paper embeds a hump-shaped (triangular, Gaussian, Beta or Gamma) deterministic technology-adoption shock into the Campbell–Shiller present-value fundamental. The fundamental price is then locally explosive during adoption with no bubble present, and PSY-style tests can reject spuriously. Appendix E.5 extends it to a Bayesian-updated stochastic version. The new element is a no-bubble null DGP with a smooth deterministic drift, where the other functions in sim.R do not produce a series of fundamentals that looks locally explosive.

It is sim_falsebubble(t1, t2, kappa, shape, amplitude, mu, r). shape = "triangular" reproduces the worked example of eq. 4 exactly, and shape = "gaussian" is one alternative from the paper’s list of hump-shaped specifications. Beta and Gamma are not included, since the paper’s robustness claim is that they make no qualitative difference. The shock is deterministic, so its price contribution is an exact forward-looking discounted sum, Tt=∑s>tβs−tτsT_t = \sum_{s > t} \beta^{s-t} \tau_s. The function is a single-shock reproduction of the mechanism and leaves out the DOLS and multi-functional-form robustness machinery. test-sim-dgp.R verifies that amplitude = 0 reduces to the fundamental-price formula of sim_div() bit for bit, and that the technology term is exactly zero outside [t1,t2][t_1, t_2].

Adjacent: not a price-level DGP

Richter, S., Wang, W. & Wu, W. B. (2023). A supreme test for periodic explosive GARCH. Econometrics (MDPI); arXiv:1812.03475. Open The paper studies a piecewise or periodic explosive GARCH(1,1) in which the explosiveness lives in the volatility recursion (α\alpha and β\beta are temporarily driven toward or through the IGARCH boundary αΣ+βΣ≥1\alpha_\Sigma + \beta_\Sigma \ge 1) and not in the price level. It is a volatility-bubble reference and is not counted above, because it addresses a different detection problem from radf() and sim.R.

Excluded as reparameterisations

  • The six Monte Carlo DGPs A–F of Harvey, Leybourne & Whitehouse (2020), with two- and three-bubble sequences. They belong to the same regime-dummy AR(1) family as HLS/PSY, and only the regime count and the parameters differ from sim_psy2.
  • The confidence-sets paper of Kurozumi & Skrobotov (2025). It has the linear AR(1) with switching coefficient of the other Kurozumi and Skrobotov papers, with a named recovery regime added.
  • The noncausal green-bubble paper of Giancaterini, Hecq, Jasiak & Manafi Neyazi (2025), arXiv:2505.14911, which uses the same mixed causal-noncausal mechanism as item 7.

Considered but not used

  • Breitung & Kruse (2013), When bubbles burst: econometric tests based on structural breaks, Statistical Papers, 54(4). Not freely available.
  • Testing for explosive bubbles in the presence of non-Gaussian conditions, Economics Letters, 233 (2023). Not freely available.
  • Montanino & De Luca, The Bubble Crash GARCH model, SSRN 5604452. No retrievable PDF.
  • Horváth, Trapani & Wang (2024), Sequential Monitoring for Explosive Volatility Regimes, arXiv:2404.17885. Likely overlaps with the RCA-monitoring paper of Horváth & Trapani (2026) by the same authors.
  • Lin, Ren & Sornette (2009), the LPPLS finite-time-singularity model, arXiv:0905.0128. It is a curve-fitting paradigm and not a Monte Carlo DGP for stress-testing right-tailed unit-root tests.

Replication scripts

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

sim_psy1_axes_and_blan_rw_validation.R 46 lines
# Replication script for the optional e, shifts, coef_noise and coef_a axes of
# sim_psy1() and for sim_blan(type = "rotermann_wilfling") in R/sim.R.
devtools::load_all("exuber", quiet = TRUE)

cat("=== sim_psy1(e = ...): custom innovations replace rnorm(n-1, sd=sigma) ===\n")
set.seed(42)
y_plain <- sim_psy1(50, seed = 42)
y_zero_e <- sim_psy1(50, seed = 42, e = rep(0, 49))
cat("y_zero_e finite:", all(is.finite(y_zero_e)), "\n")
cat("y_zero_e != y_plain (e overrides rnorm):", !isTRUE(all.equal(y_zero_e, y_plain)), "\n\n")

cat("=== sim_psy1(shifts = ...): one-period deterministic bump ===\n")
y_shift <- sim_psy1(50, seed = 42, shifts = list(date = 10, size = 100))
cat("y_shift[10] - y_plain[10] (expect 100):", y_shift[10] - y_plain[10], "\n")
cat("y_shift[1:9] == y_plain[1:9] (no effect before shift date):",
    isTRUE(all.equal(y_shift[1:9], y_plain[1:9])), "\n\n")

cat("=== sim_blan(type = 'rotermann_wilfling') ===\n")
set.seed(7)
n_b <- 5; pi_b <- 0.7; delta_b <- 0.984; rw_sigma <- 0.05; b0 <- 0.1
theta_b <- rbinom(n_b - 1, 1, pi_b)
u_b <- rlnorm(n_b - 1, meanlog = -rw_sigma ^ 2 / 2, sdlog = rw_sigma)
cat("theta:", theta_b, "\n")
cat("u:", paste(sprintf("%.8f", u_b), collapse = ","), "\n")
b <- b0
for (i in 1:(n_b - 1)) {
  b[i + 1] <- if (theta_b[i] == 1) {
    b[i] * u_b[i] / delta_b
  } else {
    ((1 - pi_b * delta_b) / (1 - pi_b)) * b[i] * u_b[i]
  }
}
cat("b:", paste(sprintf("%.8f", b), collapse = ","), "\n\n")

cat("=== sim_blan(type = 'rotermann_wilfling') via the real function ===\n")
set.seed(7)
b_real <- sim_blan(n_b, pi = pi_b, type = "rotermann_wilfling", delta = delta_b,
                    rw_sigma = rw_sigma, b0 = b0)
cat("b (real fn call):", paste(sprintf("%.8f", b_real), collapse = ","), "\n")
cat("matches hand-traced b:", isTRUE(all.equal(as.numeric(b_real), b)), "\n")

# All numbers above are reproduced (the deterministic/formula parts
# bit-for-bit, the RNG-driven parts structurally) in
# docs/replication/simulation-dgps/sim_psy1_axes_and_blan_rw_validation.py
# and in pyexuber/tests/test_sim.py.
sim_psy1_axes_and_blan_rw_validation.py 68 lines
"""Python counterpart of sim_psy1_axes_and_blan_rw_validation.R --
cross-checks pyexuber's sim_psy1() e/shifts/coef_noise/coef_a optional
axes and sim_blan(type="rotermann_wilfling") (exuber.sim) against R's
R/sim.R equivalents.

Run standalone: uv run --project pyexuber python
docs/replication/simulation-dgps/sim_psy1_axes_and_blan_rw_validation.py

sim_psy1's e=/shifts= structural behavior (overrides innovations, adds
an isolated one-period bump) is checked directly against the real
function. sim_blan(type="rotermann_wilfling")'s recursion formula is
checked bit-for-bit via a fake RNG feeding R's exact theta/u draws to
the real sim_blan() function.
"""

import numpy as np

from exuber.sim import sim_blan, sim_psy1


def check_sim_psy1_e() -> None:
    y_plain = sim_psy1(50, seed=42)
    y_zero_e = sim_psy1(50, seed=42, e=np.zeros(49))
    assert np.all(np.isfinite(y_zero_e))
    assert not np.allclose(y_zero_e, y_plain)  # e overrides the default rnorm draws
    print("sim_psy1(e=...): overrides innovations, matches R's structural behavior.")


def check_sim_psy1_shifts() -> None:
    y_plain = sim_psy1(50, seed=42)
    y_shift = sim_psy1(50, seed=42, shifts={"date": [10], "size": [100.0]})
    assert y_shift[9] - y_plain[9] == 100.0  # R: y_shift[10] - y_plain[10] == 100
    assert np.array_equal(y_shift[:9], y_plain[:9])
    print("sim_psy1(shifts=...): exact +100 one-period bump, no effect before it, matches R.")


def check_sim_blan_rotermann_wilfling() -> None:
    # set.seed(7); theta <- rbinom(4,1,0.7); u <- rlnorm(4, meanlog=-.05^2/2, sdlog=.05) in R.
    theta = np.array([0, 1, 1, 1])
    u = np.array([0.96467442, 0.97837265, 0.95143523, 0.95254875])

    class _FakeRWRNG:
        def binomial(self, n_trials, p, size=None):
            return theta

        def lognormal(self, mean=0.0, sigma=1.0, size=None):
            return u

    orig = np.random.default_rng
    np.random.default_rng = lambda seed=None: _FakeRWRNG()
    try:
        b = sim_blan(
            5, pi=0.7, type="rotermann_wilfling", delta=0.984, rw_sigma=0.05, b0=0.1, seed=7
        )
    finally:
        np.random.default_rng = orig

    expected = [0.10000000, 0.10006889, 0.09949661, 0.09620385, 0.09312891]
    np.testing.assert_allclose(b, expected, atol=1e-6)
    print("sim_blan(type='rotermann_wilfling'): recursion matches R bit-for-bit.")


if __name__ == "__main__":
    check_sim_psy1_e()
    check_sim_psy1_shifts()
    check_sim_blan_rotermann_wilfling()
    print("All checks passed.")
sim_vol_innovations_validation.R 94 lines
# Replication script for the innovation generators in R/sim.R: sim_vol_break(),
# sim_vol_garch() (TGARCH through gamma > 0), sim_vol_cir(), sim_vol_sv(),
# sim_fi() and sim_innov().
#
# All of them are deterministic transformations of Gaussian noise, so each is
# checked by fixing the underlying rnorm() draws (set.seed(1); rnorm(10)) and
# tracing the recursion by hand. pyexuber/tests/test_sim.py uses the same
# technique for the regime-switching branches of sim_psy1().
devtools::load_all("exuber", quiet = TRUE)

set.seed(1)
eps10 <- rnorm(10)
cat("eps10:", paste(sprintf("%.8f", eps10), collapse = ","), "\n\n")

cat("=== sim_vol_break(n=10, tau=0.5, ratio=3, sigma=2) ===\n")
n <- 10; tau <- 0.5; ratio <- 3; sigma <- 2
sd_t <- sigma * ifelse(seq_len(n) > floor(tau * n), ratio, 1)
z_break <- sd_t * eps10
cat("sd_t:", sd_t, "\n")
cat("z:", paste(sprintf("%.8f", z_break), collapse = ","), "\n\n")

cat("=== sim_vol_garch(n=5, omega=0.1, alpha=0.1, beta=0.8, gamma=0) ===\n")
omega <- 0.1; alpha <- 0.1; beta <- 0.8
eps5 <- eps10[1:5]
h <- numeric(5); z <- numeric(5); h_prev <- 0; z_prev <- 0
for (t in 1:5) {
  h[t] <- omega + alpha * z_prev ^ 2 + beta * h_prev
  z[t] <- sqrt(h[t]) * eps5[t]
  h_prev <- h[t]; z_prev <- z[t]
}
cat("h:", paste(sprintf("%.8f", h), collapse = ","), "\n")
cat("z:", paste(sprintf("%.8f", z), collapse = ","), "\n\n")

cat("=== sim_vol_garch(..., gamma=0.2) [TGARCH] ===\n")
gamma2 <- 0.2
h2 <- numeric(5); z2 <- numeric(5); h_prev <- 0; z_prev <- 0
for (t in 1:5) {
  h2[t] <- omega + alpha * z_prev ^ 2 + beta * h_prev + gamma2 * z_prev ^ 2 * (z_prev < 0)
  z2[t] <- sqrt(h2[t]) * eps5[t]
  h_prev <- h2[t]; z_prev <- z2[t]
}
cat("h:", paste(sprintf("%.8f", h2), collapse = ","), "\n")
cat("z:", paste(sprintf("%.8f", z2), collapse = ","), "\n\n")

cat("=== sim_vol_cir(n=5, kappa=0.03, theta=0.25, xi=0.1) ===\n")
kappa <- 0.03; theta <- 0.25; xi <- 0.1; sigma0_sq <- theta
dt <- 1 / 5
db <- eps10[1:4] * sqrt(dt)
mult <- eps10[6:10]
sig2 <- numeric(5); sig2[1] <- sigma0_sq
for (i in 2:5) {
  prev <- max(sig2[i - 1], 0)
  sig2[i] <- max(prev + kappa * (theta - prev) * dt + xi * sqrt(prev) * db[i - 1], 0)
}
cat("sig2:", paste(sprintf("%.8f", sig2), collapse = ","), "\n")
cat("z:", paste(sprintf("%.8f", sqrt(sig2) * mult), collapse = ","), "\n\n")

cat("=== sim_vol_sv(n=5, phi=0.98, tau=0.1) ===\n")
phi <- 0.98; tau_sv <- 0.1
eta <- eps10[1:4] * tau_sv
mult_sv <- eps10[6:10]
log_sig2 <- numeric(5); log_sig2[1] <- 0
for (i in 2:5) log_sig2[i] <- phi * log_sig2[i - 1] + eta[i - 1]
cat("log_sig2:", paste(sprintf("%.8f", log_sig2), collapse = ","), "\n")
cat("z:", paste(sprintf("%.8f", exp(log_sig2 / 2) * mult_sv), collapse = ","), "\n\n")

cat("=== sim_fi(): psi recursion (d=0.2, m=5) + hand convolution (n=3) ===\n")
d <- 0.2; m <- 5
psi <- numeric(m + 1); psi[1] <- 1
for (j in 2:(m + 1)) psi[j] <- psi[j - 1] * (j - 2 + d) / (j - 1)
cat("psi:", paste(sprintf("%.8f", psi), collapse = ","), "\n")
epsfi <- eps10[1:8]
u <- vapply(1:3, function(k) sum(psi * rev(epsfi[k:(k + m)])), numeric(1))
cat("u:", paste(sprintf("%.8f", u), collapse = ","), "\n\n")

cat("=== sim_innov(dist='normal', sigma=2) ===\n")
cat("z:", paste(sprintf("%.8f", eps10 * 2), collapse = ","), "\n\n")

cat("=== sim_innov: t/skew_t closed-form constants (df=5) ===\n")
df <- 5
cat("t rescale 1/sqrt(df/(df-2)):", sprintf("%.10f", 1 / sqrt(df / (df - 2))), "\n")
xi <- -0.75
delta <- xi / sqrt(1 + xi ^ 2)
e_abs_t0 <- (2 * sqrt(df) / ((df - 1) * beta(df / 2, 0.5))) / sqrt(df / (df - 2))
mean_raw <- delta * e_abs_t0
sd_raw <- sqrt(max(1 - delta ^ 2 * e_abs_t0 ^ 2, .Machine$double.eps))
cat("delta:", sprintf("%.10f", delta), " e_abs_t0:", sprintf("%.10f", e_abs_t0),
    " mean_raw:", sprintf("%.10f", mean_raw), " sd_raw:", sprintf("%.10f", sd_raw), "\n")

# All numbers above are reproduced bit-for-bit in
# docs/replication/simulation-dgps/sim_vol_innovations_validation.py and
# in pyexuber/tests/test_sim.py (same reference values, duplicated as
# literals there per docs/replication/README.md's convention).
sim_vol_innovations_validation.py 164 lines
"""Python counterpart of sim_vol_innovations_validation.R -- cross-checks
pyexuber's sim_vol_break()/sim_vol_garch()/sim_vol_cir()/sim_vol_sv()/
sim_fi()/sim_innov() (exuber.sim) against R's R/sim.R equivalents.

Run standalone: uv run --project pyexuber python
docs/replication/simulation-dgps/sim_vol_innovations_validation.py

These are otherwise-deterministic transformations of Gaussian noise, so
each is checked by feeding the *actual* exuber.sim functions a fixed
pool of draws (R: set.seed(1); rnorm(10)) via a fake np.random.Generator
replacement (_FakeRNG) that returns pool slices in the same call order R
draws them in -- the algorithm gets checked bit-for-bit, not the RNG
bit-stream (see exuber.sim's module docstring on that distinction).
"""

import math

import numpy as np

from exuber.sim import (
    _beta_fn,
    sim_fi,
    sim_innov,
    sim_vol_break,
    sim_vol_cir,
    sim_vol_garch,
    sim_vol_sv,
)

# set.seed(1); rnorm(10) in R.
EPS10 = np.array(
    [-0.62645381, 0.18364332, -0.83562861, 1.59528080, 0.32950777,
     -0.82046838, 0.48742905, 0.73832471, 0.57578135, -0.30538839]
)


class _FakeRNG:
    """Feeds a fixed pool to standard_normal()/normal() in call order --
    see exuber's own tests/test_sim.py, which uses the identical class."""

    def __init__(self, pool):
        self.pool = np.asarray(pool, dtype=float)
        self.pos = 0

    def _take(self, size):
        if size is None:
            v = self.pool[self.pos]
            self.pos += 1
            return v
        out = self.pool[self.pos : self.pos + size]
        self.pos += size
        return out

    def standard_normal(self, size=None):
        return self._take(size)

    def normal(self, loc=0.0, scale=1.0, size=None):
        if size is None and isinstance(scale, np.ndarray):
            size = scale.shape[0]
        return loc + scale * self._take(size)


def _with_fake_rng(pool, fn, *args, **kwargs):
    orig = np.random.default_rng
    np.random.default_rng = lambda seed=None: _FakeRNG(pool)
    try:
        return fn(*args, **kwargs)
    finally:
        np.random.default_rng = orig


def check_sim_vol_break() -> None:
    y = _with_fake_rng(EPS10, sim_vol_break, 10, tau=0.5, ratio=3, sigma=2, seed=1)
    expected = [-1.25290762, 0.36728665, -1.67125722, 3.19056160, 0.65901554,
                -4.92281030, 2.92457431, 4.42994823, 3.45468811, -1.83233032]
    np.testing.assert_allclose(y, expected, atol=1e-6)
    print("sim_vol_break(): matches R.")


def check_sim_vol_garch() -> None:
    y = _with_fake_rng(EPS10[:5], sim_vol_garch, 5, omega=0.1, alpha=0.1, beta=0.8, gamma=0.0, seed=1)
    expected = [-0.19810209, 0.07875803, -0.41593815, 0.89607126, 0.21675026]
    np.testing.assert_allclose(y, expected, atol=1e-6)

    y2 = _with_fake_rng(EPS10[:5], sim_vol_garch, 5, omega=0.1, alpha=0.1, beta=0.8, gamma=0.2, seed=1)
    expected2 = [-0.19810209, 0.08042096, -0.42119779, 0.95247029, 0.22731252]
    np.testing.assert_allclose(y2, expected2, atol=1e-6)
    print("sim_vol_garch(): GARCH and TGARCH (gamma>0) match R.")


def check_sim_vol_cir() -> None:
    pool = np.concatenate([EPS10[:4], EPS10[5:10]])
    y = _with_fake_rng(pool, sim_vol_cir, 5, kappa=0.03, theta=0.25, xi=0.1, seed=1)
    expected = [-0.41023419, 0.23678823, 0.36175334, 0.27117724, -0.15439035]
    np.testing.assert_allclose(y, expected, atol=1e-6)
    print("sim_vol_cir(): matches R.")


def check_sim_vol_sv() -> None:
    pool = np.concatenate([EPS10[:4], EPS10[5:10]])
    y = _with_fake_rng(pool, sim_vol_sv, 5, phi=0.98, tau=0.1, seed=1)
    expected = [-0.82046838, 0.47239810, 0.72260999, 0.54069901, -0.31098370]
    np.testing.assert_allclose(y, expected, atol=1e-6)
    print("sim_vol_sv(): matches R.")


def check_sim_innov_normal() -> None:
    y = _with_fake_rng(EPS10, sim_innov, 10, dist="normal", sigma=2, seed=1)
    expected = [-1.25290762, 0.36728665, -1.67125722, 3.19056160, 0.65901554,
                -1.64093677, 0.97485810, 1.47664941, 1.15156270, -0.61077677]
    np.testing.assert_allclose(y, expected, atol=1e-6)
    print("sim_innov(dist='normal'): matches R.")


def check_sim_innov_t_skew_t_constants() -> None:
    df = 5
    assert 1 / math.sqrt(df / (df - 2)) == 0.7745966692 or abs(
        1 / math.sqrt(df / (df - 2)) - 0.7745966692
    ) < 1e-9

    xi = -0.75
    delta = xi / math.sqrt(1 + xi**2)
    e_abs_t0 = (2 * math.sqrt(df) / ((df - 1) * _beta_fn(df / 2, 0.5))) / math.sqrt(df / (df - 2))
    mean_raw = delta * e_abs_t0
    sd_raw = math.sqrt(max(1 - delta**2 * e_abs_t0**2, np.finfo(float).eps))
    assert abs(delta - (-0.6)) < 1e-9
    assert abs(e_abs_t0 - 0.7351051939) < 1e-9
    assert abs(mean_raw - (-0.4410631163)) < 1e-9
    assert abs(sd_raw - 0.8974760874) < 1e-9
    print("sim_innov(dist='t'/'skew_t'): closed-form rescaling constants match R.")


def check_sim_fi_psi_and_convolution() -> None:
    d, m = 0.2, 5
    psi = np.empty(m + 1)
    psi[0] = 1.0
    for j in range(1, m + 1):
        psi[j] = psi[j - 1] * (j - 1 + d) / j
    np.testing.assert_allclose(psi, [1.0, 0.2, 0.12, 0.088, 0.0704, 0.059136], atol=1e-6)

    eps = EPS10[:8]  # n=3, m=5 -> n+m=8 innovations, matching sim_fi's own convolution
    u = np.empty(3)
    for k in range(3):
        window = eps[k : k + m + 1]
        u[k] = np.dot(psi, window[::-1])
    np.testing.assert_allclose(u, [-0.66078593, 0.45529270, 0.82924303], atol=1e-6)
    print("sim_fi(): psi recursion and convolution formula match R.")

    # smoke test the real function (m = max(500, 5n) there, too big to fake-RNG by hand)
    y = sim_fi(200, d=0.2, sigma=1.0, seed=1)
    assert y.shape == (200,) and np.all(np.isfinite(y))
    print("sim_fi(): real call (n=200) runs and returns finite values.")


if __name__ == "__main__":
    check_sim_vol_break()
    check_sim_vol_garch()
    check_sim_vol_cir()
    check_sim_vol_sv()
    check_sim_innov_normal()
    check_sim_innov_t_skew_t_constants()
    check_sim_fi_psi_and_convolution()
    print("All checks passed.")