use ndarray::ArrayView1;
use stochastic_rs_distributions::special::gamma_p;
#[derive(Debug, Clone, Copy)]
pub struct JarqueBeraConfig {
pub alpha: f64,
}
impl Default for JarqueBeraConfig {
fn default() -> Self {
Self { alpha: 0.05 }
}
}
#[derive(Debug, Clone, Copy)]
pub struct JarqueBeraResult {
pub statistic: f64,
pub p_value: f64,
pub skewness: f64,
pub excess_kurtosis: f64,
pub reject_normality: bool,
}
impl crate::traits::HypothesisTest for JarqueBeraResult {
fn statistic(&self) -> f64 {
self.statistic
}
fn null_rejected(&self) -> Option<bool> {
Some(self.reject_normality)
}
}
pub fn jarque_bera_test(sample: ArrayView1<f64>, cfg: JarqueBeraConfig) -> JarqueBeraResult {
let sample = sample
.as_slice()
.expect("jarque_bera_test requires a contiguous ArrayView1");
assert!(
sample.len() >= 8,
"Jarque-Bera requires at least 8 observations"
);
assert!(
sample.iter().all(|x| x.is_finite()),
"Jarque-Bera requires finite observations"
);
assert!(
cfg.alpha > 0.0 && cfg.alpha < 1.0,
"alpha must be in (0, 1)"
);
let n = sample.len() as f64;
let mean = sample.iter().sum::<f64>() / n;
let mut m2 = 0.0;
let mut m3 = 0.0;
let mut m4 = 0.0;
for &x in sample {
let d = x - mean;
let d2 = d * d;
m2 += d2;
m3 += d2 * d;
m4 += d2 * d2;
}
m2 /= n;
m3 /= n;
m4 /= n;
if m2 <= 0.0 || !m2.is_finite() {
return JarqueBeraResult {
statistic: f64::INFINITY,
p_value: 0.0,
skewness: 0.0,
excess_kurtosis: f64::INFINITY,
reject_normality: true,
};
}
let skewness = m3 / m2.powf(1.5);
let kurtosis = m4 / (m2 * m2);
let excess_kurtosis = kurtosis - 3.0;
let statistic = (n / 6.0) * (skewness * skewness + 0.25 * excess_kurtosis * excess_kurtosis);
let p_value = (1.0 - gamma_p(1.0, 0.5 * statistic)).clamp(0.0, 1.0);
JarqueBeraResult {
statistic,
p_value,
skewness,
excess_kurtosis,
reject_normality: p_value < cfg.alpha,
}
}
#[cfg(test)]
mod tests {
use ndarray::ArrayView1;
use rand::Rng;
use stochastic_rs_distributions::normal::SimdNormal;
use super::JarqueBeraConfig;
use super::jarque_bera_test;
#[test]
fn jarque_bera_accepts_normal_sample() {
let dist = SimdNormal::<f64>::new(0.0, 1.0);
let mut rng = rand::rng();
let mut x = vec![0.0; 5000];
dist.fill_slice(&mut rng, &mut x);
let res = jarque_bera_test(ArrayView1::from(&x), JarqueBeraConfig::default());
assert!(
res.p_value > 0.01,
"p-value too small for normal sample: {res:?}"
);
}
#[test]
fn jarque_bera_rejects_heavy_tail_sample() {
let dist = SimdNormal::<f64>::new(0.0, 1.0);
let mut rng = rand::rng();
let mut x = vec![0.0; 5000];
dist.fill_slice(&mut rng, &mut x);
for v in &mut x {
let u: f64 = rng.random();
*v += if u < 0.5 { -2.0 } else { 2.0 };
}
let res = jarque_bera_test(ArrayView1::from(&x), JarqueBeraConfig::default());
assert!(
res.reject_normality,
"expected rejection for non-normal sample, got {res:?}"
);
}
}