use crate::core::error::{Error, Result};
use crate::stats::special::{
chi2_sf, f_sf, normal_cdf, normal_sf, student_t_ppf, student_t_two_sided_p,
};
use crate::time_series::analysis::ols_with_std_errors;
use crate::time_series::core::TimeSeries;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[path = "stats_normality.rs"]
mod normality;
#[path = "stats_numeric.rs"]
mod numeric;
use numeric::{
adf_p_value, adf_regression_statistic, average_ranks, durbin_watson_bounds, tie_sum,
};
pub(crate) use numeric::{
inv_normal_cdf, kpss_critical_values, kpss_p_value_from_table, newey_west_bandwidth,
newey_west_long_run_variance, normal_critical_value, poly,
};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeSeriesStats {
pub descriptive: DescriptiveStats,
pub stationarity_tests: StationarityTestResults,
pub seasonality_tests: SeasonalityTestResults,
pub autocorrelation_tests: AutocorrelationTestResults,
pub normality_tests: NormalityTestResults,
pub outlier_tests: OutlierTestResults,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DescriptiveStats {
pub count: usize,
pub mean: f64,
pub std: f64,
pub min: f64,
pub q25: f64,
pub median: f64,
pub q75: f64,
pub max: f64,
pub skewness: f64,
pub kurtosis: f64,
pub cv: f64,
pub iqr: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StationarityTestResults {
pub adf_test: AugmentedDickeyFullerTest,
pub kpss_test: KwiatkowskiPhillipsSchmidtShinTest,
pub pp_test: PhillipsPerronTest,
pub is_stationary: bool,
pub differencing_recommendation: DifferencingRecommendation,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonalityTestResults {
pub seasonal_test: SeasonalTest,
pub friedman_test: Option<FriedmanTest>,
pub kruskal_wallis_test: KruskalWallisTest,
pub has_seasonality: bool,
pub seasonal_periods: Vec<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AutocorrelationTestResults {
pub ljung_box_test: LjungBoxTest,
pub box_pierce_test: BoxPierceTest,
pub durbin_watson_test: DurbinWatsonTest,
pub breusch_godfrey_test: BreuschGodfreyTest,
pub is_white_noise: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NormalityTestResults {
pub jarque_bera_test: JarqueBeraTest,
pub shapiro_wilk_test: ShapiroWilkTest,
pub anderson_darling_test: AndersonDarlingTest,
pub is_normal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutlierTestResults {
pub grubbs_test: GrubbsTest,
pub modified_z_score_test: ModifiedZScoreTest,
pub iqr_outlier_test: IQROutlierTest,
pub outlier_indices: Vec<usize>,
pub outlier_percentage: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AugmentedDickeyFullerTest {
pub statistic: f64,
pub p_value: f64,
pub n_lags: usize,
pub critical_values: HashMap<String, f64>,
pub is_stationary: bool,
pub trend: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KwiatkowskiPhillipsSchmidtShinTest {
pub statistic: f64,
pub p_value: f64,
pub critical_values: HashMap<String, f64>,
pub is_stationary: bool,
pub trend: String,
pub n_lags: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PhillipsPerronTest {
pub statistic: f64,
pub p_value: f64,
pub critical_values: HashMap<String, f64>,
pub is_stationary: bool,
pub trend: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DifferencingRecommendation {
pub recommended_d: usize,
pub recommended_seasonal_d: usize,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SeasonalTest {
pub statistic: f64,
pub p_value: f64,
pub period: Option<usize>,
pub seasonal_strength: f64,
pub is_seasonal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FriedmanTest {
pub statistic: f64,
pub p_value: f64,
pub df: f64,
pub is_seasonal: bool,
pub period: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KruskalWallisTest {
pub statistic: f64,
pub p_value: f64,
pub df: f64,
pub is_significant: bool,
pub period: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LjungBoxTest {
pub statistic: f64,
pub p_value: f64,
pub df: usize,
pub n_lags: usize,
pub has_autocorrelation: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BoxPierceTest {
pub statistic: f64,
pub p_value: f64,
pub df: usize,
pub n_lags: usize,
pub has_autocorrelation: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DurbinWatsonTest {
pub statistic: f64,
pub lower_critical: f64,
pub upper_critical: f64,
pub result: String,
pub has_positive_autocorr: bool,
pub has_negative_autocorr: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BreuschGodfreyTest {
pub statistic: f64,
pub p_value: f64,
pub df: usize,
pub n_lags: usize,
pub has_serial_correlation: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JarqueBeraTest {
pub statistic: f64,
pub p_value: f64,
pub skewness_stat: f64,
pub kurtosis_stat: f64,
pub is_normal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShapiroWilkTest {
pub statistic: f64,
pub p_value: f64,
pub is_normal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AndersonDarlingTest {
pub statistic: f64,
pub critical_values: HashMap<String, f64>,
pub p_value: f64,
pub is_normal: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GrubbsTest {
pub statistic: f64,
pub p_value: f64,
pub critical_value: f64,
pub outlier_index: Option<usize>,
pub has_outlier: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModifiedZScoreTest {
pub modified_z_scores: Vec<f64>,
pub threshold: f64,
pub outlier_indices: Vec<usize>,
pub has_outliers: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IQROutlierTest {
pub q1: f64,
pub q3: f64,
pub iqr: f64,
pub lower_fence: f64,
pub upper_fence: f64,
pub outlier_indices: Vec<usize>,
pub has_outliers: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WhiteNoiseTest {
pub ljung_box_tests: Vec<LjungBoxTest>,
pub variance_ratio_test: VarianceRatioTest,
pub runs_test: RunsTest,
pub is_white_noise: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VarianceRatioTest {
pub statistic: f64,
pub p_value: f64,
pub variance_ratio: f64,
pub is_random_walk: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunsTest {
pub n_runs: usize,
pub expected_runs: f64,
pub statistic: f64,
pub p_value: f64,
pub is_random: bool,
}
impl TimeSeriesStats {
pub fn compute(ts: &TimeSeries) -> Result<Self> {
let values: Vec<f64> = (0..ts.len())
.filter_map(|i| ts.values.get_f64(i))
.filter(|v| v.is_finite())
.collect();
if values.is_empty() {
return Err(Error::InvalidInput(
"No valid values in time series".to_string(),
));
}
let descriptive = Self::compute_descriptive_stats(&values)?;
let stationarity_tests = Self::compute_stationarity_tests(&values)?;
let seasonality_tests = Self::compute_seasonality_tests(&values)?;
let autocorrelation_tests = Self::compute_autocorrelation_tests(&values)?;
let normality_tests = Self::compute_normality_tests(&values)?;
let outlier_tests = Self::compute_outlier_tests(&values)?;
Ok(Self {
descriptive,
stationarity_tests,
seasonality_tests,
autocorrelation_tests,
normality_tests,
outlier_tests,
})
}
fn compute_descriptive_stats(values: &[f64]) -> Result<DescriptiveStats> {
let count = values.len();
let sum = values.iter().sum::<f64>();
let mean = sum / count as f64;
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count as f64;
let std = variance.sqrt();
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
let min = sorted[0];
let max = sorted[count - 1];
let median = if count % 2 == 0 {
(sorted[count / 2 - 1] + sorted[count / 2]) / 2.0
} else {
sorted[count / 2]
};
let q1_idx = count / 4;
let q3_idx = 3 * count / 4;
let q25 = sorted[q1_idx];
let q75 = sorted[q3_idx];
let iqr = q75 - q25;
let skewness = if std > 0.0 {
values
.iter()
.map(|x| ((x - mean) / std).powi(3))
.sum::<f64>()
/ count as f64
} else {
0.0
};
let kurtosis = if std > 0.0 {
values
.iter()
.map(|x| ((x - mean) / std).powi(4))
.sum::<f64>()
/ count as f64
- 3.0
} else {
0.0
};
let cv = if mean != 0.0 { std / mean.abs() } else { 0.0 };
Ok(DescriptiveStats {
count,
mean,
std,
min,
q25,
median,
q75,
max,
skewness,
kurtosis,
cv,
iqr,
})
}
fn compute_stationarity_tests(values: &[f64]) -> Result<StationarityTestResults> {
let adf_test = AugmentedDickeyFullerTest::compute(values)?;
let kpss_test = KwiatkowskiPhillipsSchmidtShinTest::compute(values, "constant")?;
let pp_test = PhillipsPerronTest::compute(values)?;
let is_stationary = adf_test.is_stationary && kpss_test.is_stationary;
let differencing_recommendation = if !adf_test.is_stationary {
DifferencingRecommendation {
recommended_d: 1,
recommended_seasonal_d: 0,
reason: "ADF test suggests non-stationarity".to_string(),
}
} else {
DifferencingRecommendation {
recommended_d: 0,
recommended_seasonal_d: 0,
reason: "Series appears stationary".to_string(),
}
};
Ok(StationarityTestResults {
adf_test,
kpss_test,
pp_test,
is_stationary,
differencing_recommendation,
})
}
fn compute_seasonality_tests(values: &[f64]) -> Result<SeasonalityTestResults> {
let seasonal_test = SeasonalTest::compute(values)?;
let friedman_test = FriedmanTest::compute(values, 12).ok();
let kruskal_wallis_test = KruskalWallisTest::compute(values, 7)?;
let has_seasonality = seasonal_test.is_seasonal
|| friedman_test.as_ref().is_some_and(|t| t.is_seasonal)
|| kruskal_wallis_test.is_significant;
let mut seasonal_periods = Vec::new();
if let Some(period) = seasonal_test.period {
seasonal_periods.push(period);
}
if let Some(friedman) = friedman_test.as_ref().filter(|t| t.is_seasonal) {
seasonal_periods.push(friedman.period);
}
if kruskal_wallis_test.is_significant {
seasonal_periods.push(kruskal_wallis_test.period);
}
seasonal_periods.sort_unstable();
seasonal_periods.dedup();
Ok(SeasonalityTestResults {
seasonal_test,
friedman_test,
kruskal_wallis_test,
has_seasonality,
seasonal_periods,
})
}
fn compute_autocorrelation_tests(values: &[f64]) -> Result<AutocorrelationTestResults> {
let ljung_box_test = LjungBoxTest::compute(values, 10)?;
let box_pierce_test = BoxPierceTest::compute(values, 10)?;
let durbin_watson_test = DurbinWatsonTest::compute(values)?;
let breusch_godfrey_test = BreuschGodfreyTest::compute(values, 5)?;
let is_white_noise = !ljung_box_test.has_autocorrelation
&& !box_pierce_test.has_autocorrelation
&& !durbin_watson_test.has_positive_autocorr
&& !breusch_godfrey_test.has_serial_correlation;
Ok(AutocorrelationTestResults {
ljung_box_test,
box_pierce_test,
durbin_watson_test,
breusch_godfrey_test,
is_white_noise,
})
}
fn compute_normality_tests(values: &[f64]) -> Result<NormalityTestResults> {
let jarque_bera_test = JarqueBeraTest::compute(values)?;
let shapiro_wilk_test = ShapiroWilkTest::compute(values)?;
let anderson_darling_test = AndersonDarlingTest::compute(values)?;
let is_normal = jarque_bera_test.is_normal
&& shapiro_wilk_test.is_normal
&& anderson_darling_test.is_normal;
Ok(NormalityTestResults {
jarque_bera_test,
shapiro_wilk_test,
anderson_darling_test,
is_normal,
})
}
fn compute_outlier_tests(values: &[f64]) -> Result<OutlierTestResults> {
let grubbs_test = GrubbsTest::compute(values)?;
let modified_z_score_test = ModifiedZScoreTest::compute(values, 3.5)?;
let iqr_outlier_test = IQROutlierTest::compute(values)?;
let mut all_outliers = Vec::new();
if let Some(idx) = grubbs_test.outlier_index {
all_outliers.push(idx);
}
all_outliers.extend(&modified_z_score_test.outlier_indices);
all_outliers.extend(&iqr_outlier_test.outlier_indices);
all_outliers.sort_unstable();
all_outliers.dedup();
let outlier_percentage = all_outliers.len() as f64 / values.len() as f64 * 100.0;
Ok(OutlierTestResults {
grubbs_test,
modified_z_score_test,
iqr_outlier_test,
outlier_indices: all_outliers,
outlier_percentage,
})
}
}
impl AugmentedDickeyFullerTest {
pub fn compute(values: &[f64]) -> Result<Self> {
if values.len() < 10 {
return Err(Error::InvalidInput(
"Need at least 10 observations for ADF test".to_string(),
));
}
let n = values.len();
let n_lags = ((n as f64).cbrt() * 12.0 / 100.0) as usize;
let statistic = adf_regression_statistic(values, n_lags)?;
let mut critical_values = HashMap::new();
critical_values.insert("1%".to_string(), -3.43);
critical_values.insert("5%".to_string(), -2.86);
critical_values.insert("10%".to_string(), -2.57);
let p_value = adf_p_value(statistic);
let is_stationary = statistic < critical_values["5%"];
Ok(Self {
statistic,
p_value,
n_lags,
critical_values,
is_stationary,
trend: "constant".to_string(),
})
}
}
impl KwiatkowskiPhillipsSchmidtShinTest {
pub fn compute(values: &[f64], trend: &str) -> Result<Self> {
if values.len() < 10 {
return Err(Error::InvalidInput(
"Need at least 10 observations for KPSS test".to_string(),
));
}
let detrended = match trend {
"constant" => Self::detrend_constant(values)?,
"linear" => Self::detrend_linear(values)?,
_ => {
return Err(Error::InvalidInput(
"Invalid trend specification".to_string(),
))
}
};
let mut partial_sums = vec![0.0; detrended.len()];
partial_sums[0] = detrended[0];
for i in 1..detrended.len() {
partial_sums[i] = partial_sums[i - 1] + detrended[i];
}
let n_obs = detrended.len();
let bandwidth = (4.0 * (n_obs as f64 / 100.0).powf(0.25)).floor() as usize;
let long_run_variance = newey_west_long_run_variance(&detrended, bandwidth);
let n = values.len() as f64;
let sum_of_squares: f64 = partial_sums.iter().map(|x| x * x).sum();
let statistic = if long_run_variance > 0.0 {
sum_of_squares / (n * n * long_run_variance)
} else {
0.0
};
let (c1, c5, c10) = kpss_critical_values(trend)?;
let mut critical_values = HashMap::new();
critical_values.insert("1%".to_string(), c1);
critical_values.insert("5%".to_string(), c5);
critical_values.insert("10%".to_string(), c10);
let p_value = kpss_p_value_from_table(statistic, c10, c5, c1);
let is_stationary = statistic < c5;
Ok(Self {
statistic,
p_value,
critical_values,
is_stationary,
trend: trend.to_string(),
n_lags: bandwidth,
})
}
fn detrend_constant(values: &[f64]) -> Result<Vec<f64>> {
let mean = values.iter().sum::<f64>() / values.len() as f64;
Ok(values.iter().map(|x| x - mean).collect())
}
fn detrend_linear(values: &[f64]) -> Result<Vec<f64>> {
let n = values.len() as f64;
let x_values: Vec<f64> = (0..values.len()).map(|i| i as f64).collect();
let sum_x = x_values.iter().sum::<f64>();
let sum_y = values.iter().sum::<f64>();
let sum_xy = x_values.iter().zip(values).map(|(x, y)| x * y).sum::<f64>();
let sum_x2 = x_values.iter().map(|x| x * x).sum::<f64>();
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_x2 - sum_x * sum_x);
let intercept = (sum_y - slope * sum_x) / n;
let detrended: Vec<f64> = x_values
.iter()
.zip(values)
.map(|(x, y)| y - (slope * x + intercept))
.collect();
Ok(detrended)
}
}
impl PhillipsPerronTest {
pub fn compute(values: &[f64]) -> Result<Self> {
let n = values.len();
if n < 10 {
return Err(Error::InvalidInput(
"Need at least 10 observations for the Phillips-Perron test".to_string(),
));
}
let mut x_rows: Vec<Vec<f64>> = Vec::with_capacity(n - 1);
let mut response: Vec<f64> = Vec::with_capacity(n - 1);
for t in 1..n {
x_rows.push(vec![1.0, values[t - 1]]);
response.push(values[t] - values[t - 1]);
}
let (coefficients, std_errors) =
ols_with_std_errors(&x_rows, &response).ok_or_else(|| {
Error::InvalidInput("Phillips-Perron regression matrix is singular".to_string())
})?;
let rho = coefficients[1];
let se_rho = std_errors[1];
if !se_rho.is_finite() || se_rho <= 0.0 {
return Err(Error::InvalidInput(
"Phillips-Perron regression produced a degenerate standard error".to_string(),
));
}
let t_rho = rho / se_rho;
let residuals: Vec<f64> = x_rows
.iter()
.zip(response.iter())
.map(|(row, &y)| y - (coefficients[0] * row[0] + coefficients[1] * row[1]))
.collect();
let t_obs = residuals.len();
let t_f = t_obs as f64;
let n_regressors = 2.0; let ssr: f64 = residuals.iter().map(|e| e * e).sum();
let gamma0 = ssr / t_f;
let s_squared = ssr / (t_f - n_regressors);
let s = s_squared.sqrt();
let bandwidth = newey_west_bandwidth(t_obs);
let lambda_squared = newey_west_long_run_variance(&residuals, bandwidth);
let statistic = if gamma0 > 0.0 && lambda_squared > 0.0 && s > 0.0 {
let lambda = lambda_squared.sqrt();
(gamma0 / lambda_squared).sqrt() * t_rho
- (lambda_squared - gamma0) * t_f * se_rho / (2.0 * lambda * s)
} else {
f64::NAN
};
let mut critical_values = HashMap::new();
critical_values.insert("1%".to_string(), -3.43);
critical_values.insert("5%".to_string(), -2.86);
critical_values.insert("10%".to_string(), -2.57);
let is_stationary = statistic < -2.86;
let p_value = if statistic.is_finite() {
adf_p_value(statistic)
} else {
f64::NAN
};
Ok(Self {
statistic,
p_value,
critical_values,
is_stationary,
trend: "constant".to_string(),
})
}
}
impl SeasonalTest {
pub fn compute(values: &[f64]) -> Result<Self> {
if values.len() < 20 {
return Ok(Self {
statistic: 0.0,
p_value: 1.0,
period: None,
seasonal_strength: 0.0,
is_seasonal: false,
});
}
let mut max_strength = 0.0;
let mut best_period = None;
for period in 2..=std::cmp::min(values.len() / 3, 365) {
let strength = Self::calculate_seasonal_strength(values, period)?;
if strength > max_strength {
max_strength = strength;
best_period = Some(period);
}
}
let (statistic, p_value, is_seasonal) = match best_period {
Some(period) => {
let n = values.len();
let k = period;
let grand_mean = values.iter().sum::<f64>() / n as f64;
let mut group_sum = vec![0.0_f64; k];
let mut group_count = vec![0usize; k];
for (i, &v) in values.iter().enumerate() {
group_sum[i % k] += v;
group_count[i % k] += 1;
}
let mut ss_between = 0.0;
for g in 0..k {
if group_count[g] > 0 {
let gm = group_sum[g] / group_count[g] as f64;
ss_between += group_count[g] as f64 * (gm - grand_mean).powi(2);
}
}
let mut ss_within = 0.0;
for (i, &v) in values.iter().enumerate() {
let g = i % k;
if group_count[g] > 0 {
let gm = group_sum[g] / group_count[g] as f64;
ss_within += (v - gm).powi(2);
}
}
let df1 = (k - 1) as f64;
let df2 = (n - k) as f64;
if df1 > 0.0 && df2 > 0.0 && ss_within > 0.0 {
let f_stat = (ss_between / df1) / (ss_within / df2);
let p = f_sf(f_stat, df1, df2);
(f_stat, p, p < 0.05)
} else {
(0.0, 1.0, false)
}
}
None => (0.0, 1.0, false),
};
Ok(Self {
statistic,
p_value,
period: best_period,
seasonal_strength: max_strength,
is_seasonal,
})
}
fn calculate_seasonal_strength(values: &[f64], period: usize) -> Result<f64> {
if values.len() < period * 2 {
return Ok(0.0);
}
let mut seasonal_means = vec![0.0; period];
let mut counts = vec![0; period];
for (i, &value) in values.iter().enumerate() {
let season_idx = i % period;
seasonal_means[season_idx] += value;
counts[season_idx] += 1;
}
for i in 0..period {
if counts[i] > 0 {
seasonal_means[i] /= counts[i] as f64;
}
}
let overall_mean = values.iter().sum::<f64>() / values.len() as f64;
let seasonal_variance = seasonal_means
.iter()
.map(|&mean| (mean - overall_mean).powi(2))
.sum::<f64>()
/ period as f64;
let total_variance = values
.iter()
.map(|&value| (value - overall_mean).powi(2))
.sum::<f64>()
/ values.len() as f64;
if total_variance > 0.0 {
Ok((seasonal_variance / total_variance).min(1.0))
} else {
Ok(0.0)
}
}
}
impl FriedmanTest {
pub fn compute(values: &[f64], period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidInput(format!(
"Friedman test needs a seasonal period of at least 2, got {period}"
)));
}
if values.len() < period * 2 {
return Err(Error::InvalidInput(format!(
"Friedman test needs at least 2 complete cycles ({} observations), got {}",
period * 2,
values.len()
)));
}
let n_blocks = values.len() / period;
let b = n_blocks as f64;
let k = period as f64;
let mut rank_sums = vec![0.0; period];
let mut tie_correction = 0.0;
for block in 0..n_blocks {
let row = &values[block * period..(block + 1) * period];
let ranks = average_ranks(row);
for (j, rank) in ranks.iter().enumerate() {
rank_sums[j] += rank;
}
tie_correction += tie_sum(row);
}
let statistic_raw = 12.0 / (b * k * (k + 1.0))
* rank_sums.iter().map(|r| r * r).sum::<f64>()
- 3.0 * b * (k + 1.0);
let tie_denominator = 1.0 - tie_correction / (b * k * (k * k - 1.0));
let statistic = if tie_denominator > 0.0 {
statistic_raw / tie_denominator
} else {
f64::NAN
};
let df = k - 1.0;
let p_value = if statistic.is_finite() {
chi2_sf(statistic, df)
} else {
f64::NAN
};
let is_seasonal = p_value < 0.05;
Ok(Self {
statistic,
p_value,
df,
is_seasonal,
period,
})
}
}
impl KruskalWallisTest {
pub fn compute(values: &[f64], period: usize) -> Result<Self> {
if period < 2 {
return Err(Error::InvalidValue(
"KruskalWallis requires at least 2 groups (period >= 2)".into(),
));
}
let n = values.len();
if n < period {
return Err(Error::InvalidValue(
"Insufficient observations for Kruskal-Wallis test".into(),
));
}
let mut indexed: Vec<(usize, f64)> = values.iter().cloned().enumerate().collect();
indexed.sort_by(|a, b| a.1.total_cmp(&b.1));
let mut ranks = vec![0.0_f64; n];
let mut i = 0usize;
while i < n {
let mut j = i + 1;
while j < n && (indexed[j].1 - indexed[i].1).abs() < 1e-12 {
j += 1;
}
let avg_rank = ((i + 1 + j) as f64) / 2.0;
for &(orig_idx, _) in &indexed[i..j] {
ranks[orig_idx] = avg_rank;
}
i = j;
}
let k = period;
let mut rank_sums = vec![0.0_f64; k];
let mut group_sizes = vec![0usize; k];
for (idx, &r) in ranks.iter().enumerate() {
let g = idx % k;
rank_sums[g] += r;
group_sizes[g] += 1;
}
let nf = n as f64;
let h_raw: f64 = rank_sums
.iter()
.zip(group_sizes.iter())
.filter(|(_, &sz)| sz > 0)
.map(|(&rs, &sz)| rs * rs / sz as f64)
.sum();
let statistic = 12.0 / (nf * (nf + 1.0)) * h_raw - 3.0 * (nf + 1.0);
let df = (k - 1) as f64;
let p_value = chi2_sf(statistic.max(0.0), df);
let is_significant = p_value < 0.05;
Ok(Self {
statistic,
p_value,
df,
is_significant,
period,
})
}
}
impl LjungBoxTest {
pub fn compute(values: &[f64], n_lags: usize) -> Result<Self> {
let n = values.len() as f64;
let mut statistic = 0.0;
let mean = values.iter().sum::<f64>() / n;
for lag in 1..=n_lags {
let autocorr = Self::calculate_autocorrelation(values, lag, mean)?;
statistic += autocorr * autocorr / (n - lag as f64);
}
statistic *= n * (n + 2.0);
let df = n_lags;
let p_value = chi2_sf(statistic, df as f64);
let has_autocorrelation = p_value < 0.05;
Ok(Self {
statistic,
p_value,
df,
n_lags,
has_autocorrelation,
})
}
fn calculate_autocorrelation(values: &[f64], lag: usize, mean: f64) -> Result<f64> {
if lag >= values.len() {
return Ok(0.0);
}
let n = values.len() - lag;
let mut numerator = 0.0;
let mut denominator = 0.0;
for i in 0..n {
let dev1 = values[i] - mean;
let dev2 = values[i + lag] - mean;
numerator += dev1 * dev2;
}
for &val in values {
let dev = val - mean;
denominator += dev * dev;
}
if denominator == 0.0 {
Ok(0.0)
} else {
Ok(numerator / denominator)
}
}
}
impl BoxPierceTest {
pub fn compute(values: &[f64], n_lags: usize) -> Result<Self> {
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let mut statistic = 0.0;
for lag in 1..=n_lags {
let autocorr = LjungBoxTest::calculate_autocorrelation(values, lag, mean)?;
statistic += autocorr * autocorr;
}
statistic *= n;
let df = n_lags;
let p_value = chi2_sf(statistic, df as f64);
let has_autocorrelation = p_value < 0.05;
Ok(Self {
statistic,
p_value,
df,
n_lags,
has_autocorrelation,
})
}
}
impl DurbinWatsonTest {
pub fn compute(values: &[f64]) -> Result<Self> {
if values.len() < 3 {
return Err(Error::InvalidInput(
"Need at least 3 observations for DW test".to_string(),
));
}
let mut sum_diff_sq = 0.0;
let mut sum_sq = 0.0;
let mean = values.iter().sum::<f64>() / values.len() as f64;
for i in 1..values.len() {
sum_diff_sq += (values[i] - values[i - 1]).powi(2);
}
for &val in values {
sum_sq += (val - mean).powi(2);
}
let statistic = if sum_sq > 0.0 {
sum_diff_sq / sum_sq
} else {
f64::NAN
};
let (lower_critical, upper_critical) = durbin_watson_bounds(values.len());
let (result, has_positive_autocorr, has_negative_autocorr) = if !statistic.is_finite() {
("Undefined (no residual variation)", false, false)
} else if statistic < lower_critical {
("Positive autocorrelation", true, false)
} else if statistic > 4.0 - lower_critical {
("Negative autocorrelation", false, true)
} else if statistic < upper_critical || statistic > 4.0 - upper_critical {
("Inconclusive", false, false)
} else {
("No significant autocorrelation", false, false)
};
Ok(Self {
statistic,
lower_critical,
upper_critical,
result: result.to_string(),
has_positive_autocorr,
has_negative_autocorr,
})
}
}
impl BreuschGodfreyTest {
pub fn compute(values: &[f64], n_lags: usize) -> Result<Self> {
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let mut r_sq_sum = 0.0;
for lag in 1..=n_lags {
let rk = LjungBoxTest::calculate_autocorrelation(values, lag, mean)?;
r_sq_sum += rk * rk;
}
let statistic = n * r_sq_sum;
let df = n_lags;
let p_value = chi2_sf(statistic, df as f64);
let has_serial_correlation = p_value < 0.05;
Ok(Self {
statistic,
p_value,
df,
n_lags,
has_serial_correlation,
})
}
}
impl JarqueBeraTest {
pub fn compute(values: &[f64]) -> Result<Self> {
let n = values.len() as f64;
let mean = values.iter().sum::<f64>() / n;
let variance = values.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n;
let std = variance.sqrt();
let skewness = if std > 0.0 {
values
.iter()
.map(|x| ((x - mean) / std).powi(3))
.sum::<f64>()
/ n
} else {
0.0
};
let kurtosis = if std > 0.0 {
values
.iter()
.map(|x| ((x - mean) / std).powi(4))
.sum::<f64>()
/ n
- 3.0
} else {
0.0
};
let skewness_stat = n * skewness.powi(2) / 6.0;
let kurtosis_stat = n * kurtosis.powi(2) / 24.0;
let statistic = skewness_stat + kurtosis_stat;
let p_value = chi2_sf(statistic, 2.0);
let is_normal = p_value > 0.05;
Ok(Self {
statistic,
p_value,
skewness_stat,
kurtosis_stat,
is_normal,
})
}
}
impl WhiteNoiseTest {
pub fn compute(values: &[f64]) -> Result<Self> {
let mut ljung_box_tests = Vec::new();
for &n_lags in &[5, 10, 15, 20] {
if n_lags < values.len() / 4 {
ljung_box_tests.push(LjungBoxTest::compute(values, n_lags)?);
}
}
let variance_ratio_test = VarianceRatioTest::compute(values)?;
let runs_test = RunsTest::compute(values)?;
let is_white_noise = ljung_box_tests.iter().all(|test| !test.has_autocorrelation)
&& variance_ratio_test.is_random_walk
&& runs_test.is_random;
Ok(Self {
ljung_box_tests,
variance_ratio_test,
runs_test,
is_white_noise,
})
}
}
impl VarianceRatioTest {
pub fn compute(values: &[f64]) -> Result<Self> {
if values.len() < 10 {
return Ok(Self {
statistic: 0.0,
p_value: 0.5,
variance_ratio: 1.0,
is_random_walk: true,
});
}
let mut diff_values = Vec::new();
for i in 1..values.len() {
diff_values.push(values[i] - values[i - 1]);
}
let mean_diff = diff_values.iter().sum::<f64>() / diff_values.len() as f64;
let var_1 = diff_values
.iter()
.map(|x| (x - mean_diff).powi(2))
.sum::<f64>()
/ diff_values.len() as f64;
let k = 2;
let mut k_diff_values = Vec::new();
for i in k..values.len() {
k_diff_values.push(values[i] - values[i - k]);
}
let mean_k_diff = k_diff_values.iter().sum::<f64>() / k_diff_values.len() as f64;
let var_k = k_diff_values
.iter()
.map(|x| (x - mean_k_diff).powi(2))
.sum::<f64>()
/ k_diff_values.len() as f64;
let variance_ratio = if var_1 > 0.0 {
var_k / (k as f64 * var_1)
} else {
1.0
};
let big_n = diff_values.len() as f64;
let kf = k as f64;
let vr_var = 2.0 * (2.0 * kf - 1.0) * (kf - 1.0) / (3.0 * kf * big_n);
let statistic = if vr_var > 0.0 {
(variance_ratio - 1.0) / vr_var.sqrt()
} else {
0.0
};
let p_value = (2.0 * normal_sf(statistic.abs())).clamp(0.0, 1.0);
let is_random_walk = p_value > 0.05;
Ok(Self {
statistic,
p_value,
variance_ratio,
is_random_walk,
})
}
}
impl RunsTest {
pub fn compute(values: &[f64]) -> Result<Self> {
if values.is_empty() {
return Ok(Self {
n_runs: 0,
expected_runs: 0.0,
statistic: 0.0,
p_value: 0.5,
is_random: true,
});
}
let median = {
let mut sorted = values.to_vec();
sorted.sort_by(|a, b| a.total_cmp(b));
if sorted.len() % 2 == 0 {
(sorted[sorted.len() / 2 - 1] + sorted[sorted.len() / 2]) / 2.0
} else {
sorted[sorted.len() / 2]
}
};
let binary: Vec<bool> = values.iter().map(|&x| x >= median).collect();
let mut n_runs = 1;
for i in 1..binary.len() {
if binary[i] != binary[i - 1] {
n_runs += 1;
}
}
let n_pos = binary.iter().filter(|&&x| x).count() as f64;
let n_neg = binary.len() as f64 - n_pos;
let n = binary.len() as f64;
let expected_runs = if n > 0.0 {
(2.0 * n_pos * n_neg) / n + 1.0
} else {
0.0
};
let variance = if n > 1.0 {
(2.0 * n_pos * n_neg * (2.0 * n_pos * n_neg - n)) / (n * n * (n - 1.0))
} else {
1.0
};
let statistic = if variance > 0.0 {
(n_runs as f64 - expected_runs) / variance.sqrt()
} else {
0.0
};
let p_value = (2.0 * normal_sf(statistic.abs())).clamp(0.0, 1.0);
let is_random = p_value > 0.05;
Ok(Self {
n_runs,
expected_runs,
statistic,
p_value,
is_random,
})
}
}
#[cfg(test)]
#[path = "stats_tests.rs"]
mod tests;