use crate::stats::{kurtosis, mean, skewness};
#[derive(Clone, Debug)]
pub struct StylizedFactsReport {
pub excess_kurtosis: f64,
pub abs_return_autocorr: f64,
pub vol_clustering_acf: f64,
pub gain_loss_skew: f64,
pub aggregational_gaussianity: f64,
pub zumbach_asymmetry: f64,
}
pub const AGGREGATION_BLOCK: usize = 5;
const CLUSTER_LAGS: usize = 10;
#[derive(Clone, Copy, Debug)]
pub struct RealismThresholds {
pub min_excess_kurtosis: f64,
pub min_abs_return_autocorr: f64,
pub min_aggregational_gaussianity: f64,
pub min_zumbach_asymmetry: f64,
}
impl Default for RealismThresholds {
fn default() -> Self {
Self {
min_excess_kurtosis: 0.5,
min_abs_return_autocorr: 0.02,
min_aggregational_gaussianity: 0.1,
min_zumbach_asymmetry: 0.005,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RealismFailure {
ThinTails,
NoVolatilityClustering,
NoAggregationalGaussianity,
TimeReversalSymmetric,
}
#[derive(Clone, Debug)]
pub struct RealismVerdict {
pub report: StylizedFactsReport,
pub thresholds: RealismThresholds,
pub realistic: bool,
pub failures: Vec<RealismFailure>,
}
impl StylizedFactsReport {
pub fn has_fat_tails(&self, t: &RealismThresholds) -> bool {
self.excess_kurtosis >= t.min_excess_kurtosis
}
pub fn has_volatility_clustering(&self, t: &RealismThresholds) -> bool {
self.abs_return_autocorr >= t.min_abs_return_autocorr
}
pub fn has_aggregational_gaussianity(&self, t: &RealismThresholds) -> bool {
self.aggregational_gaussianity >= t.min_aggregational_gaussianity
}
pub fn has_time_reversal_asymmetry(&self, t: &RealismThresholds) -> bool {
self.zumbach_asymmetry.abs() >= t.min_zumbach_asymmetry
}
pub fn is_realistic(&self, t: &RealismThresholds) -> bool {
self.failures(t).is_empty()
}
pub fn failures(&self, t: &RealismThresholds) -> Vec<RealismFailure> {
let mut out = Vec::new();
if !self.has_fat_tails(t) {
out.push(RealismFailure::ThinTails);
}
if !self.has_volatility_clustering(t) {
out.push(RealismFailure::NoVolatilityClustering);
}
if !self.has_aggregational_gaussianity(t) {
out.push(RealismFailure::NoAggregationalGaussianity);
}
if !self.has_time_reversal_asymmetry(t) {
out.push(RealismFailure::TimeReversalSymmetric);
}
out
}
}
fn autocorr(xs: &[f64], lag: usize) -> f64 {
let n = xs.len();
if lag == 0 {
return 1.0;
}
if n <= lag {
return 0.0;
}
let m = mean(xs);
let mut den = 0.0;
for x in xs {
let d = x - m;
den += d * d;
}
if den <= 0.0 {
return 0.0;
}
let mut num = 0.0;
for i in 0..(n - lag) {
num += (xs[i] - m) * (xs[i + lag] - m);
}
num / den
}
fn cross_corr(a: &[f64], b: &[f64], lag: usize) -> f64 {
let n = a.len().min(b.len());
if n <= lag {
return 0.0;
}
let ma = mean(&a[..n]);
let mb = mean(&b[..n]);
let mut va = 0.0;
let mut vb = 0.0;
for i in 0..n {
va += (a[i] - ma) * (a[i] - ma);
vb += (b[i] - mb) * (b[i] - mb);
}
if va <= 0.0 || vb <= 0.0 {
return 0.0;
}
let mut cov = 0.0;
for t in 0..(n - lag) {
cov += (a[t] - ma) * (b[t + lag] - mb);
}
cov / (va.sqrt() * vb.sqrt())
}
fn aggregated_excess_kurtosis(xs: &[f64], block: usize) -> f64 {
if block <= 1 || xs.len() < block * 4 {
return kurtosis(xs) - 3.0;
}
let agg: Vec<f64> = xs
.chunks_exact(block)
.map(|c| c.iter().sum::<f64>())
.collect();
kurtosis(&agg) - 3.0
}
pub fn stylized_facts(returns: &[f64]) -> StylizedFactsReport {
let abs: Vec<f64> = returns.iter().map(|r| r.abs()).collect();
let sq: Vec<f64> = returns.iter().map(|r| r * r).collect();
let excess_kurtosis = kurtosis(returns) - 3.0;
let abs_return_autocorr = autocorr(&abs, 1);
let max_lag = CLUSTER_LAGS.min(returns.len().saturating_sub(2)).max(1);
let vol_clustering_acf = (1..=max_lag).map(|k| autocorr(&sq, k)).sum::<f64>() / max_lag as f64;
let gain_loss_skew = skewness(returns);
let raw_xk = excess_kurtosis;
let agg_xk = aggregated_excess_kurtosis(returns, AGGREGATION_BLOCK);
let aggregational_gaussianity = raw_xk - agg_xk;
let lev_fwd = (1..=max_lag)
.map(|k| cross_corr(returns, &sq, k))
.sum::<f64>()
/ max_lag as f64;
let lev_rev = (1..=max_lag)
.map(|k| cross_corr(&sq, returns, k))
.sum::<f64>()
/ max_lag as f64;
let zumbach_asymmetry = lev_fwd - lev_rev;
StylizedFactsReport {
excess_kurtosis,
abs_return_autocorr,
vol_clustering_acf,
gain_loss_skew,
aggregational_gaussianity,
zumbach_asymmetry,
}
}
pub fn validate_dataset(returns: &[f64]) -> RealismVerdict {
validate_dataset_with(returns, &RealismThresholds::default())
}
pub fn validate_dataset_with(returns: &[f64], thresholds: &RealismThresholds) -> RealismVerdict {
let report = stylized_facts(returns);
let failures = report.failures(thresholds);
RealismVerdict {
realistic: failures.is_empty(),
report,
thresholds: *thresholds,
failures,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Lcg(u64);
impl Lcg {
fn u(&mut self) -> f64 {
self.0 = self
.0
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
fn z(&mut self) -> f64 {
let mut s = 0.0;
for _ in 0..12 {
s += self.u();
}
s - 6.0
}
}
fn gaussian_iid(n: usize, seed: u64) -> Vec<f64> {
let mut r = Lcg(seed);
(0..n).map(|_| 0.0005 + 0.01 * r.z()).collect()
}
fn leverage_sv(n: usize, seed: u64) -> Vec<f64> {
let mut r = Lcg(seed);
let mean_lv = -4.6_f64;
let mut log_vol = mean_lv;
let mut z_prev = 0.0_f64;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let eta = r.z();
log_vol = mean_lv + 0.94 * (log_vol - mean_lv) - 0.20 * z_prev + 0.30 * eta;
let heavy = if r.u() < 0.04 { 3.5 } else { 1.0 };
let z = r.z() * heavy;
out.push(0.0003 + log_vol.exp() * z);
z_prev = z;
}
out
}
#[test]
fn gaussian_iid_is_not_realistic() {
let v = validate_dataset(&gaussian_iid(4000, 12345));
assert!(!v.realistic, "thin Gaussian toy must fail: {:?}", v.report);
assert!(
v.failures.contains(&RealismFailure::ThinTails),
"no fat tails in a Gaussian: {:?}",
v.report
);
assert!(v.report.excess_kurtosis.abs() < 0.5);
assert!(v.report.abs_return_autocorr < 0.05);
}
#[test]
fn fat_tailed_clustered_series_is_realistic() {
let v = validate_dataset(&leverage_sv(4000, 99999));
assert!(v.realistic, "leverage-SV must certify: {:?}", v);
assert!(v.failures.is_empty());
assert!(v.report.excess_kurtosis > 1.0, "fat tails");
assert!(v.report.abs_return_autocorr > 0.1, "volatility clustering");
assert!(
v.report.vol_clustering_acf > 0.0,
"squared-return persistence"
);
assert!(
v.report.aggregational_gaussianity > 0.0,
"kurtosis falls under aggregation"
);
assert!(
v.report.zumbach_asymmetry.abs() >= 0.01,
"time-reversal asymmetry from leverage"
);
}
#[test]
fn zumbach_asymmetry_is_directional_leverage() {
let v = stylized_facts(&leverage_sv(4000, 7));
assert!(
v.zumbach_asymmetry < 0.0,
"leverage → negative Zumbach score, got {}",
v.zumbach_asymmetry
);
}
#[test]
fn time_symmetric_process_has_no_zumbach() {
let v = stylized_facts(&gaussian_iid(4000, 4242));
assert!(
v.zumbach_asymmetry.abs() < 0.01,
"iid is time-symmetric, got {}",
v.zumbach_asymmetry
);
}
#[test]
fn constant_and_short_series_are_safe() {
for r in [vec![], vec![0.0; 8], vec![0.001; 3], vec![0.01, -0.01]] {
let v = validate_dataset(&r);
assert!(v.report.excess_kurtosis.is_finite());
assert!(v.report.abs_return_autocorr.is_finite());
assert!(v.report.zumbach_asymmetry.is_finite());
assert!(!v.realistic);
}
}
#[test]
fn thresholds_are_configurable() {
let returns = gaussian_iid(2000, 1);
let lax = RealismThresholds {
min_excess_kurtosis: f64::NEG_INFINITY,
min_abs_return_autocorr: f64::NEG_INFINITY,
min_aggregational_gaussianity: f64::NEG_INFINITY,
min_zumbach_asymmetry: 0.0,
};
assert!(validate_dataset_with(&returns, &lax).realistic);
}
}