stochastic-rs

Statistics & estimators

Hurst estimators, MLE for 1-D diffusions with 6 transition densities, ADF/KPSS/Phillips-Perron, realized variance with BNHLS, HMM, changepoint.

Statistics & estimators

The stochastic-rs-stats crate ships estimators for the most common quant-finance workflows. Most take an ArrayView1<T> (price or log-return series) and return a plain, flat *Result struct — but the struct's field names are estimator-specific (see Common pattern below), not a single uniform point/se/p_value shape.

Sub-modules

ModuleWhat's inside
mleMLE for 1-D diffusions, 6 transition-density approximations (Euler, Ozaki, Shoji-Ozaki, Elerian, Kessler, Aït-Sahalia), L-BFGS-B via Basin. Plus the dedicated Heston MLE / NMLE-CEKF.
realizedRealised variance / bipower / MinRV / MedRV / flat-top kernel (Bartlett, Parzen, Tukey-Hanning, Cubic, Quadratic-Spectral) with BNHLS bandwidth. Semivariance, realised skew / kurtosis, HAR-RV, EWMA / RiskMetrics variance, Jacod pre-averaging, TSRV, multi-scale RV, BNS and Lee–Mykland jump tests.
normalityJarque-Bera, Shapiro-Francia, Anderson-Darling
stationarity9 HypothesisTest implementors: ADF, KPSS, Phillips-Perron, Leybourne-McCabe and ERS DF-GLS test unit-root/stationarity; Andrews-Ploberger and CUSUM test regression-parameter stability instead; Lo-MacKinlay tests the random-walk hypothesis; RESET tests linear functional form. See Choosing a stationarity test — the module's own name undersells how different these nine are.
econometricsEngle-Granger and Johansen (trace + maximum-eigenvalue) cointegration with VECM estimation, Granger causality, Gaussian-emission HMM with Baum-Welch, CUSUM and PELT changepoint
filteringParticle filter, UKF, random-walk Metropolis-Hastings
hurstSix native estimators behind one HurstEstimator trait: rescaled-range, DFA, GPH, wavelet, Whittle (Fukasawa, Takabatake & Westphal 2019 adapted quasi-likelihood; L-BFGS-B + Paxson + Eq.16 corrections, intraday Table 1 validation), variations. Two more implement the same trait by adapting a fractal-dimension estimate (fractal_dim::{Higuchi, Variogram}, H = 2 - D) — 8 HurstEstimator implementors total. hurst::whittle additionally exposes Whittle's own algorithm through dedicated one-shot functions (estimate, estimate_from_prices) outside the shared trait — see below
gaussian_kdeGaussian KDE
tail_indexHill estimator + variants
spectralPeriodogram and spectrum search
leverageLeverage-effect estimators
fou_estimatorfOU MLE with Hurst joint estimation
garchGARCH / GJR-GARCH / EGARCH Gaussian QMLE with inverse-Hessian and Bollerslev-Wooldridge robust standard errors
evtExtreme-value theory: Hill tail index, GPD peaks-over-threshold (MLE and Hosking-Wallis PWM) with VaR / ES, GEV block-maxima MLE with return levels
distfitMaximum-likelihood fits of the skewed return laws: Johnson SU, Hansen skew-t with location and scale, variance gamma

Examples

Realized variance from intraday returns

tests/doctest_stats_realized_variance.rs
// docs: stats#realized-variance-from-intraday-returns
//! Backs the realized-variance example on the statistics catalog page.

use ndarray::Array1;
use stochastic_rs::stats::realized::variance::realized_variance;

#[test]
fn realized_variance_from_log_returns() {
  // Stand-in for "1-min log-returns over a trading day": a small fixed
  // series keeps this example free of a live data feed.
  let log_returns: Array1<f64> = Array1::from(vec![
    0.001, -0.0007, 0.0012, -0.0003, 0.0009, -0.0011, 0.0004, -0.0002, 0.0006, -0.0008,
  ]);

  let rv = realized_variance(log_returns.view());
  assert!(rv > 0.0);

  let annualised_vol = (rv * 252.0).sqrt();
  assert!(annualised_vol.is_finite());
}
import stochastic_rs as srs
import numpy as np

log_returns = ...   # 1-min log-returns over a trading day
# annualisation is a constructor argument, not a post-hoc multiplier — it
# scales straight into `.volatility`. There is no standard-error field:
# RealizedMoments reports variance/volatility/skewness/kurtosis/quarticity,
# not a point estimate with a `se` — see the ADF section below for the
# same "no se/p_value field" pattern on the stationarity tests.
res = srs.RealizedMoments(log_returns, annualisation=252.0)
print("RV     =", res.variance)
print("annVol =", res.volatility)

EWMA / RiskMetrics variance

The exponentially weighted recursion σ^t2=λσ^t−12+(1−λ)rt−12\hat\sigma_t^2 = \lambda\hat\sigma_{t-1}^2 + (1-\lambda)r_{t-1}^2, seeded at r02r_0^2. riskmetrics_variance fixes the decay at the RiskMetrics daily λ=0.94\lambda = 0.94; ewma_variance takes any λ∈(0,1)\lambda \in (0,1). The result carries the full conditional-variance series, the one-step-ahead forecast, lambda and nobs.

tests/doctest_stats_ewma.rs
// docs: stats#ewma-riskmetrics-variance
//! Backs the EWMA / RiskMetrics example on the stats page.

use ndarray::Array1;
use stochastic_rs::stats::realized::ewma::RISKMETRICS_DAILY_LAMBDA;
use stochastic_rs::stats::realized::ewma::ewma_variance;
use stochastic_rs::stats::realized::ewma::riskmetrics_variance;

#[test]
fn ewma_variance_tracks_a_return_series() {
  let returns = Array1::from(vec![0.012_f64, -0.007, 0.021, 0.0, -0.015, 0.004]);

  // RiskMetrics daily decay (λ = 0.94) is the default convention…
  let rm = riskmetrics_variance(returns.view());
  assert_eq!(rm.lambda, RISKMETRICS_DAILY_LAMBDA);
  assert_eq!(rm.variance.len(), returns.len());

  // …and any λ ∈ (0, 1) is accepted explicitly.
  let fast = ewma_variance(returns.view(), 0.80);
  assert!(fast.forecast > 0.0);
  // A faster decay weights the newest shock harder, so the two forecasts
  // genuinely differ.
  assert!((fast.forecast - rm.forecast).abs() > 1e-9);
}
import stochastic_rs as srs

# lambda_ defaults to the RiskMetrics daily 0.94
res = srs.EwmaVariance(returns, lambda_=0.94)
print("sigma2 series:", res.variance())
print("1-step forecast:", res.forecast)

Lee-Mykland jump test

Every return is standardised by a bipower estimate of the local volatility from the KK observations before it, L(i)=ri/σ^(ti)\mathcal L(i) = r_i / \hat\sigma(t_i), and flagged as a jump when (∣L(i)∣−Cn)/Sn(|\mathcal L(i)| - C_n) / S_n leaves the Gumbel region of the sample maximum at level α\alpha (Lee & Mykland 2008, Lemma 1). Unlike the BNS test, which only says whether an interval contains jumps, this one returns the jump times and sizes. lee_mykland_window implements the paper's window rule — the smallest integer above 252×observations per day\sqrt{252 \times \text{observations per day}}, 16 for daily data.

tests/doctest_stats_lee_mykland.rs
// docs: stats#lee-mykland-jump-test
//! Backs the Lee–Mykland example on the stats page.

use ndarray::Array1;
use stochastic_rs::distributions::normal::SimdNormal;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::realized::lee_mykland::lee_mykland_test;
use stochastic_rs::stats::realized::lee_mykland::lee_mykland_window;

#[test]
fn lee_mykland_locates_a_planted_jump() {
  // Stand-in for a day of 5-minute log-returns with one news jump in it.
  let dist = SimdNormal::<f64>::new(0.0, 0.001, &Deterministic::new(42));
  let mut returns = Array1::<f64>::zeros(2_000);
  dist.fill_slice(returns.as_slice_mut().unwrap());
  returns[1_200] = 0.02;

  // The paper's window rule for 78 five-minute returns a day is K = 141.
  let window = lee_mykland_window(78);
  assert_eq!(window, 141);

  let test = lee_mykland_test(returns.view(), window, 0.01);
  assert!(test.jump_indices.contains(&1_200));
  // Every flagged return sits beyond the Gumbel threshold on |L(i)|.
  for &i in &test.jump_indices {
    assert!(test.statistics[i].abs() > test.threshold);
  }
}
import stochastic_rs as srs

window = srs.LeeMyklandJumpTest.recommended_window(78)  # 5-minute data → 141
test = srs.LeeMyklandJumpTest(returns, window, alpha=0.01)
print("jumps at:", test.jump_indices)
print("threshold on |L(i)|:", test.threshold)

Johansen cointegration and VECM

johansen_test returns both rank statistics of the concentrated likelihood — λtrace(r)=−T∑i>rlog⁡(1−λ^i)\lambda_{\mathrm{trace}}(r) = -T\sum_{i>r}\log(1-\hat\lambda_i) and λmax⁡(r)=−Tlog⁡(1−λ^r+1)\lambda_{\max}(r) = -T\log(1-\hat\lambda_{r+1}) — with the 5% MacKinnon–Haug–Michelis critical values for an unrestricted constant, and the rank each sequential procedure selects. vecm_fit then gives the maximum-likelihood VECM at a chosen rank: β^\hat\beta (Johansen's normalisation β^′S11β^=I\hat\beta' S_{11}\hat\beta = I), α^\hat\alpha, Π^=α^β^′\hat\Pi = \hat\alpha\hat\beta', the short-run Γ^i\hat\Gamma_i, the constant, residuals and their covariance.

tests/doctest_stats_johansen.rs
// docs: stats#johansen-cointegration-and-vecm
//! Backs the Johansen / VECM example on the stats page.

use ndarray::Array2;
use stochastic_rs::distributions::normal::SimdNormal;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::econometrics::johansen_test;
use stochastic_rs::stats::econometrics::vecm_fit;

#[test]
fn johansen_rank_then_vecm() {
  // Two prices sharing one random walk: y2 ≈ 0.7·y1, so the pair has
  // exactly one cointegrating relation.
  let steps = SimdNormal::<f64>::new(0.0, 1.0, &Deterministic::new(7));
  let noise = SimdNormal::<f64>::new(0.0, 0.1, &Deterministic::new(11));
  let mut dw = vec![0.0_f64; 500];
  let mut eps = vec![0.0_f64; 500];
  steps.fill_slice(&mut dw);
  noise.fill_slice(&mut eps);
  let mut y = Array2::<f64>::zeros((500, 2));
  let mut w = 0.0;
  for t in 0..500 {
    w += dw[t];
    y[[t, 0]] = w;
    y[[t, 1]] = 0.7 * w + eps[t];
  }

  // Rank tests at VAR order 2: both sequential procedures stop at r = 1.
  let test = johansen_test(y.view(), 2);
  assert_eq!(test.rank_trace, 1);
  assert_eq!(test.rank_max_eig, 1);
  assert!(test.max_eig_statistics[0] > test.max_eig_critical_5pct[0]);

  // The VECM at that rank recovers the relation y2 − 0.7·y1 up to scale.
  let fit = vecm_fit(y.view(), 2, test.rank_trace);
  let ratio = fit.beta[[1, 0]] / fit.beta[[0, 0]];
  assert!((ratio + 1.0 / 0.7).abs() < 0.1, "beta ratio {ratio}");
  assert_eq!(fit.gamma.len(), 1);
  assert_eq!(fit.residuals.dim(), (498, 2));
}
import stochastic_rs as srs

test = srs.Johansen(prices, lags=2)          # prices: (t, k) array
print("rank (trace):", test.rank_trace, "rank (max-eig):", test.rank_max_eig)
print("max-eig stats:", test.max_eig_statistics())

fit = srs.Vecm(prices, lags=2, rank=test.rank_trace)
print("pi:", fit.pi())
print("gamma_1:", fit.gamma()[0])

GARCH-family QMLE

garch_fit maximises the Gaussian quasi-likelihood of a GARCH(pp,qq), GJR-GARCH(pp,qq) or EGARCH(pp,qq) recursion with a constant or zero mean, in unconstrained coordinates that keep the fit inside the stationarity region. The result carries the parameters, both the inverse-Hessian and the Bollerslev–Wooldridge robust standard errors (the latter stay valid under non-Gaussian innovations), the conditional-variance path, standardised residuals, log-likelihood, AIC/BIC and persistence. Pre-sample terms follow the backcast convention of the arch reference implementation, whose fits the test-suite reproduces.

tests/doctest_stats_garch.rs
// docs: stats#garch-family-qmle
//! Backs the GARCH QMLE example on the stats page.

use ndarray::Array1;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::garch::GarchSpec;
use stochastic_rs::stats::garch::MeanSpec;
use stochastic_rs::stats::garch::garch_fit;
use stochastic_rs::stochastic::autoregressive::garch::Garch;
use stochastic_rs::traits::ProcessExt;

#[test]
fn garch_qmle_recovers_the_simulated_parameters() {
  // Two thousand daily returns from GARCH(1,1) with ω = 0.05, α = 0.10,
  // β = 0.85 (percent units, unconditional variance 1.0).
  let process = Garch::<f64, _>::new(
    0.05,
    Array1::from(vec![0.10]),
    Array1::from(vec![0.85]),
    2_000,
    Deterministic::new(42),
  );
  let returns = process.sample();

  // The simulator has no drift, so a zero-mean fit is the matching model;
  // `GarchSpec::garch(1, 1)` alone would estimate a constant mean as well.
  let fit = garch_fit(
    returns.view(),
    GarchSpec::garch(1, 1).with_mean(MeanSpec::Zero),
  );
  assert!(fit.converged);
  assert!((fit.alpha[0] - 0.10).abs() < 3.0 * fit.robust_std_errors[1]);
  assert!((fit.beta[0] - 0.85).abs() < 3.0 * fit.robust_std_errors[2]);
  assert!(fit.persistence < 1.0);
  assert_eq!(fit.conditional_variance.len(), returns.len());
}
import stochastic_rs as srs

fit = srs.GarchFit(returns, kind="gjr", p=1, q=1, mean="constant")
for name, value, se in zip(fit.param_names(), fit.params(), fit.robust_std_errors()):
    print(f"{name:9s} {value: .5f} ({se:.5f})")
print("persistence:", fit.persistence, "log-likelihood:", fit.log_likelihood)

Extreme-value theory: Hill, peaks-over-threshold and block maxima

hill_estimator is Hill's (1975) tail index from the kk largest positive observations with its ξ^/k\hat\xi/\sqrt k standard error. pot_fit fits a generalised Pareto distribution to the excesses over a threshold by maximum likelihood (gpd_fit on its own for pre-computed excesses) and turns it into tail quantiles and expected shortfall with the McNeil–Frey–Embrechts formulas; mean_excess is the mean-residual-life plot for choosing the threshold. block_maxima and gev_fit are the block-maxima route, with return_level(m) for the level exceeded once every mm blocks. The MLE fitters report inverse-information standard errors, and the fits reproduce scipy on identical data.

tests/doctest_stats_evt.rs
// docs: stats#extreme-value-theory-hill-peaks-over-threshold-and-block-maxima
//! Backs the extreme-value example on the stats page.

use ndarray::Array1;
use stochastic_rs::distributions::pareto::SimdPareto;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::evt::block_maxima;
use stochastic_rs::stats::evt::gev_fit;
use stochastic_rs::stats::evt::hill_estimator;
use stochastic_rs::stats::evt::pot_fit;

#[test]
fn evt_tail_estimates_agree_on_a_pareto_tail() {
  // Losses with an exact Pareto(α = 3) tail, so the true tail index is
  // ξ = 1/3.
  let dist = SimdPareto::<f64>::new(1.0, 3.0, &Deterministic::new(7));
  let mut losses = vec![0.0; 20_000];
  dist.fill_slice(&mut losses);
  let losses = Array1::from(losses);

  let hill = hill_estimator(losses.view(), 500);
  assert!((hill.xi - 1.0 / 3.0).abs() < 3.0 * hill.std_error);

  // Peaks over a threshold of 3: the GPD shape estimates the same ξ, and
  // the tail quantiles follow.
  let pot = pot_fit(losses.view(), 3.0);
  assert!(pot.gpd.converged);
  assert!((pot.gpd.xi - 1.0 / 3.0).abs() < 3.0 * pot.gpd.std_errors[1]);
  let var99 = pot.quantile(0.99);
  assert!(pot.expected_shortfall(0.99) > var99 && var99 > pot.threshold);

  // Block maxima of 100 draws: GEV with ξ ≈ 1/3 again.
  let maxima = block_maxima(losses.view(), 100);
  let gev = gev_fit(maxima.view());
  assert!(gev.converged);
  assert!((gev.xi - 1.0 / 3.0).abs() < 3.0 * gev.std_errors[2]);
  assert!(gev.return_level(50.0) > gev.mu);
}
import numpy as np
import stochastic_rs as srs

losses = -returns                                   # right tail = losses
hill = srs.HillEstimator(losses, k=100)
print("xi:", hill.xi, "+/-", hill.std_error)

pot = srs.PotFit(losses, threshold=np.quantile(losses, 0.95))
print("VaR99:", pot.quantile(0.99), "ES99:", pot.expected_shortfall(0.99))

gev = srs.GevFit(srs.block_maxima(losses, 21))    # monthly maxima
print("100-block return level:", gev.return_level(100.0))

Fitting the skewed return distributions

distfit fits the skewed laws of the distributions crate by maximum likelihood with their closed-form densities: johnson_su_fit (γ, δ, ξ, λ), skew_t_fit (Hansen's standardised skew-t with location μ and scale σ, so η > 2 and |λ| < 1 are kept by construction) and variance_gamma_fit (σ, ν, θ, μ through the Bessel-form density). Each returns the estimates, inverse-information standard errors, log-likelihood, AIC and BIC; the test-suite pins them to tightly converged scipy / arch optima on identical data. evt::gpd_pwm is the matching closed-form Hosking-Wallis estimator for threshold excesses.

tests/doctest_stats_distfit.rs
// docs: stats#fitting-the-skewed-return-distributions
//! Backs the distribution-fitting example on the stats page.

use ndarray::Array1;
use stochastic_rs::distributions::skew_t::SimdSkewT;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::distfit::johnson_su_fit;
use stochastic_rs::stats::distfit::skew_t_fit;

#[test]
fn skew_t_fit_recovers_the_simulated_shape() {
  // Returns from Hansen's skew-t with η = 6, λ = −0.3, scaled to 1.5%
  // daily volatility around a 0.05% drift.
  let dist = SimdSkewT::<f64>::new(6.0, -0.3, &Deterministic::new(11));
  let mut z = vec![0.0; 4_000];
  dist.fill_slice(&mut z);
  let returns = Array1::from_iter(z.iter().map(|v| 0.0005 + 0.015 * v));

  let fit = skew_t_fit(returns.view());
  assert!(fit.converged);
  assert!((fit.lambda + 0.3).abs() < 3.0 * fit.std_errors[3]);
  assert!((fit.sigma - 0.015).abs() < 3.0 * fit.std_errors[1]);

  // A Johnson SU fit of the same sample ranks below it on AIC.
  let jsu = johnson_su_fit(returns.view());
  assert!(jsu.converged && jsu.aic > fit.aic);
}
import stochastic_rs as srs

fit = srs.SkewTFit(returns)
print("eta:", fit.eta, "lambda:", fit.lambda_, "sigma:", fit.sigma)
print("SEs:", fit.std_errors(), "AIC:", fit.aic)

vg = srs.VarianceGammaFit(returns)
print("sigma, nu, theta, mu:", vg.sigma, vg.nu, vg.theta, vg.mu)

ADF stationarity test

Null hypothesis: unit root (non-stationary). adf_test lives under the stationarity module and reports the rejection decision directly as reject_unit_root: bool at the alpha configured on AdfConfig — there is no separate p_value field, only the test statistic and tabulated critical values.

tests/doctest_stats_adf.rs
// docs: stats#adf-stationarity-test
//! Backs the ADF example on the statistics catalog page. `adf_test` lives
//! under the `stationarity` module (OLS regression via
//! `ndarray-linalg`), so the whole file is gated on that feature.

use ndarray::ArrayView1;
use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::stationarity::adf::AdfConfig;
use stochastic_rs::stats::stationarity::adf::adf_test;
use stochastic_rs::stochastic::diffusion::ou::Ou;
use stochastic_rs::traits::ProcessExt;

#[test]
fn adf_rejects_unit_root_on_a_mean_reverting_series() {
  // An OU path is mean-reverting by construction, so the null (unit
  // root) should be rejected — a stand-in for "prices or log-returns"
  // that avoids a live data feed.
  let series = Ou::<f64, _>::new(
    2.0,
    0.0,
    0.5,
    500,
    Some(0.0),
    Some(10.0),
    Deterministic::new(7),
  )
  .sample();

  let result = adf_test(
    ArrayView1::from(series.as_slice().unwrap()),
    AdfConfig::default(),
  );
  assert!(
    result.reject_unit_root,
    "expected the unit-root null to be rejected"
  );
}
import stochastic_rs as srs

# ADFTest is a class, not a free function; the fitted
# object exposes .statistic/.reject_unit_root/.critical_values directly —
# no .p_value, matching the "no separate p_value field" note above.
res = srs.ADFTest(series, max_lags=5)
print(f"t-stat={res.statistic:.3f}, reject_unit_root={res.reject_unit_root}")

Hurst exponent estimation

The hurst module unifies six estimators (rescaled-range, DFA, GPH, wavelet, Whittle, variations) behind one HurstEstimator trait — estimate(x) -> Result<HurstResult, HurstError> — plus two more (Higuchi, Variogram) that reach the same trait by adapting a fractal-dimension estimate. The example below uses RescaledRange (Hurst 1951 + Anis-Lloyd 1976 bias correction), which estimates the Hurst exponent of a signal directly:

tests/doctest_stats_hurst.rs
// docs: stats#hurst-exponent-estimation
//! Backs the Hurst-estimator example on the statistics catalog page.
//!
//! Uses the rescaled-range estimator (`RescaledRange`) rather than the
//! Fukasawa/Whittle one (`stats::hurst::whittle`): Fukasawa estimates the
//! roughness of a *latent volatility* process from a realized-variance
//! series, not the Hurst exponent of a path sampled directly from `Fgn` —
//! feeding path samples straight into it silently recovers nonsense. See
//! `stochastic-rs-stats/src/hurst/whittle.rs`'s own `simulate_log_rv` test
//! helper for the realized-variance construction Fukasawa actually expects.

use stochastic_rs::simd_rng::Deterministic;
use stochastic_rs::stats::hurst::HurstEstimator;
use stochastic_rs::stats::hurst::rs::RescaledRange;
use stochastic_rs::stochastic::noise::fgn::Fgn;
use stochastic_rs::traits::ProcessExt;

#[test]
fn rescaled_range_recovers_true_h_from_fgn_increments() {
  let true_h = 0.3;
  let path = Fgn::<f64, _>::new(true_h, 4096, Some(1.0), Deterministic::new(42)).sample();

  // `path` is already the stationary fGn increment series, so disable the
  // estimator's default first-differencing (meant for an fBM-like walk).
  let estimator = RescaledRange {
    take_differences: false,
    ..RescaledRange::default()
  };
  let res = estimator.estimate(path.view()).unwrap();
  assert!(
    (res.hurst - true_h).abs() < 0.1,
    "H = {:.3} (true = {:.3})",
    res.hurst,
    true_h
  );
}
import stochastic_rs as srs

x = ...   # a price / level series, e.g. np.cumsum(fgn.sample())
res = srs.RescaledRange().estimate(x)
print(f"H = {res.hurst:.3f}")

All eight HurstEstimator implementors are Python-bound (RescaledRange, Dfa, Gph, Wavelet, Whittle, Variations, Higuchi, Variogram — the last two also via .estimate_hurst(x) alongside their native .estimate(x) -> FdResult fractal-dimension output).

Whittle is a different kind of input from the other five native estimators: it and the free-standing FukasawaHurst class (Python) / estimate, estimate_from_prices functions (Rust, stats::hurst::whittle) are the same Fukasawa, Takabatake & Westphal (2019) adapted-Whittle quasi-likelihood estimator, just reached two ways — the generic trait or a dedicated one-shot constructor. Either way it estimates the roughness of a latent volatility process from a realized-variance time series, not the Hurst exponent of a path sampled directly from Fgn. Feeding raw path samples into it silently recovers nonsense; see stochastic-rs-stats/src/hurst/whittle.rs's own simulate_log_rv test helper for the construction it actually expects (a simulated log-variance process aggregated into intraday realized-variance bins).

Choosing a stationarity test

The stationarity module holds nine HypothesisTest implementors, but "stationarity" accurately describes only five of them — the other four test a different null hypothesis entirely, despite living in the same module. The module's own doc comment (stochastic-rs-stats/src/stationarity.rs) has the full comparison with literature citations and exact minimum-sample requirements; short version:

Question you're actually askingReach for
Does this series have a unit root? (H₀: unit root)adf::adf_test, ers_dfgls::ers_dfgls_test (usually the better default — the module doc explains why), phillips_perron::phillips_perron_test
Is this series stationary? (H₀: stationarity)kpss::kpss_test, leybourne_mccabe::leybourne_mccabe_test
Are this regression's coefficients stable over time?andrews_ploberger::andrews_ploberger_test (one unknown breakpoint) or cusum::cusum_test (gradual drift in the mean or variance)
Is this specifically a random walk (uncorrelated increments)?lo_mackinlay::lo_mackinlay_test
Is my linear model's functional form correct?reset::reset_test — not a time-series question at all

Run ADF and KPSS together, not either alone: their null hypotheses are opposite, so agreement between the two is real evidence, and disagreement is informative too — often a sign of long-range dependence rather than a clean unit-root/stationary split. See Choosing a Hurst estimator below for estimating that directly instead of forcing a binary call.

Choosing a Hurst estimator

Seven of the eight HurstEstimator implementors estimate the same quantity — the self-similarity exponent of the series handed to .estimate() — by different routes; the eighth, Whittle, estimates something else entirely (the warning in the example above). The full comparison, with the literature backing each method and exact data-size requirements, lives in the hurst module's own doc comment (stochastic-rs-stats/src/hurst/mod.rs). Short version:

NeedReach for
A reasonable general-purpose defaultdfa::Dfa — its polynomial detrending step is specifically why it tolerates real (trending) data better than rs::RescaledRange
A confidence interval, not just a point estimategph::Gph or wavelet::Wavelet — the only two that populate a closed-form standard error
The series is believed to be a rough, mean-reverting (fractional-OU-like) process rather than pure fBM/fGNvariations::Variations with VariationKind::PowerVariation
You already think in terms of fractal dimension Dfractal_dim::Higuchi / fractal_dim::Variogram via the H = 2 - D adapter — prefer FractalDimEstimator directly when a degenerate fit matters, since the adapter clamps silently
Roughness of latent volatility from a realized-variance series (rough-vol calibration)hurst::whittle::Whittle — and only that input shape; feeding it a raw sampled path is the one mistake this table exists to prevent

rs::RescaledRange is the historical default (Hurst 1951) but is documented — in the same paper this crate cites for its own bias correction — as the least reliable of the group in finite samples; kept for continuity, not as a recommendation.

Common pattern

Every estimator here returns a plain, flat struct — no boxed traits, no enums to match on — but the field names are estimator-specific, not a uniform point/se/p_value triple. For example:

// RescaledRange, Dfa, Gph, Wavelet, Whittle, Variations all return this:
pub struct HurstResult<T: FloatExt = f64> {
  pub hurst: T,
  pub std_err: Option<T>, // only Gph and Wavelet populate this
  pub n_obs: usize,
  pub diagnostic: HurstDiagnostic<T>,
}

// while the stationarity tests (ADF, KPSS, ...) return their own shape:
pub struct AdfResult {
  pub statistic: f64,
  pub used_lags: usize,
  pub nobs: usize,
  pub critical_values: CriticalValues,
  pub reject_unit_root: bool,
}

Check the specific estimator's result struct for its field names — the per-module source under stochastic-rs-stats/src/ documents each one.

Adding an estimator

See the stats-estimator SKILL — covers the ArrayView1<T> input shape, *Result struct conventions, feature gating, and the paper-citation requirement.

On this page