Skip to main content

sharpebench_stats/
stylized_facts.rs

1//! Cont's stylized facts — a deterministic realism validator for a return dataset.
2//!
3//! A benchmark is only as honest as the market it simulates. A synthetic (or
4//! frozen) dataset that has thin Gaussian tails, no volatility clustering, and is
5//! symmetric under time reversal is a *toy*: an agent that wins on it has learned
6//! nothing about real markets, and a generator that silently drifts into that toy
7//! regime quietly invalidates every score computed on it.
8//!
9//! This module measures the canonical **stylized facts of asset returns** (Cont,
10//! *Empirical properties of asset returns*, 2001) over a return series and certifies
11//! that a dataset exhibits them:
12//! - **Fat tails** — positive excess kurtosis; extreme moves are far more frequent
13//!   than a Gaussian predicts.
14//! - **Volatility clustering** — slow-decaying positive autocorrelation in the
15//!   *magnitude* of returns (|r| and r²), even though signed returns are ~white.
16//! - **Aggregational Gaussianity** — as returns are summed over longer horizons the
17//!   distribution walks back toward Gaussian (excess kurtosis shrinks).
18//! - **Time-reversal asymmetry (the Zumbach / leverage effect)** — past returns
19//!   drive future volatility more than future returns "drive" past volatility; a
20//!   time-reversal-symmetric process (Gaussian i.i.d., plain GARCH) has none.
21//!
22//! Pure and deterministic: plain `f64`, fixed reduction order, no RNG, no I/O, and
23//! (like the rest of this crate) no dependencies. The moment primitives are reused
24//! verbatim from [`crate::stats`].
25
26use crate::stats::{kurtosis, mean, skewness};
27
28/// The measured stylized-facts profile of a return series. Each field is a plain
29/// statistic; the realism predicates ([`StylizedFactsReport::has_fat_tails`] …)
30/// compare them against a [`RealismThresholds`].
31#[derive(Clone, Debug)]
32pub struct StylizedFactsReport {
33    /// Excess kurtosis (`kurtosis - 3`). > 0 ⇒ fatter tails than a Gaussian.
34    pub excess_kurtosis: f64,
35    /// Lag-1 autocorrelation of |returns| — the cleanest single volatility-clustering
36    /// signal (magnitudes persist even when signed returns do not).
37    pub abs_return_autocorr: f64,
38    /// Mean autocorrelation of *squared* returns over the first several lags — the
39    /// slow-decaying persistence that is the hallmark of volatility clustering.
40    pub vol_clustering_acf: f64,
41    /// Skewness of returns — the gain/loss asymmetry (equity indices fall faster
42    /// than they rise, so this is typically negative). Reported, not gated.
43    pub gain_loss_skew: f64,
44    /// Excess-kurtosis *drop* under temporal aggregation (`raw − aggregated`). > 0 ⇒
45    /// the distribution becomes more Gaussian at longer horizons (aggregational
46    /// Gaussianity); the aggregation block size is [`AGGREGATION_BLOCK`].
47    pub aggregational_gaussianity: f64,
48    /// Time-reversal-asymmetry (Zumbach/leverage) score: `lev_fwd − lev_rev`, the
49    /// difference between "past return → future volatility" and "past volatility →
50    /// future return" lead-lag correlation. ~0 under time reversal; markedly
51    /// non-zero (usually negative, from the leverage effect) in real markets.
52    pub zumbach_asymmetry: f64,
53}
54
55/// Non-overlapping block size used for the aggregational-Gaussianity measurement
56/// (5 periods ≈ a trading week of daily bars).
57pub const AGGREGATION_BLOCK: usize = 5;
58
59/// Number of lags averaged for [`StylizedFactsReport::vol_clustering_acf`] and the
60/// Zumbach lead-lag terms.
61const CLUSTER_LAGS: usize = 10;
62
63/// Realism-gate thresholds. Defaults are deliberately permissive lower bounds — a
64/// dataset only has to *clear* each stylized fact, not match any particular market.
65#[derive(Clone, Copy, Debug)]
66pub struct RealismThresholds {
67    /// Minimum excess kurtosis to count as fat-tailed.
68    pub min_excess_kurtosis: f64,
69    /// Minimum lag-1 |return| autocorrelation to count as volatility-clustered.
70    pub min_abs_return_autocorr: f64,
71    /// Minimum excess-kurtosis drop under aggregation to count as aggregationally
72    /// Gaussian.
73    pub min_aggregational_gaussianity: f64,
74    /// Minimum |Zumbach asymmetry| to count as time-reversal-asymmetric.
75    pub min_zumbach_asymmetry: f64,
76}
77
78impl Default for RealismThresholds {
79    fn default() -> Self {
80        Self {
81            min_excess_kurtosis: 0.5,
82            min_abs_return_autocorr: 0.02,
83            min_aggregational_gaussianity: 0.1,
84            // Weak by design: real markets differ in leverage strength (equity
85            // indices are strongly time-asymmetric, crypto only mildly), so the bar
86            // only has to separate a genuine leverage signal from Gaussian noise
87            // (~0.001), not match any one asset class.
88            min_zumbach_asymmetry: 0.005,
89        }
90    }
91}
92
93/// A single stylized fact a dataset failed to exhibit.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum RealismFailure {
96    /// Tails no fatter than a Gaussian's.
97    ThinTails,
98    /// No persistence in return magnitudes.
99    NoVolatilityClustering,
100    /// The distribution does not become more Gaussian under aggregation.
101    NoAggregationalGaussianity,
102    /// The process looks the same run forwards or backwards (no leverage/Zumbach).
103    TimeReversalSymmetric,
104}
105
106/// The certification verdict: the measured profile, the thresholds applied, whether
107/// every gated stylized fact held, and the specific failures if not.
108#[derive(Clone, Debug)]
109pub struct RealismVerdict {
110    pub report: StylizedFactsReport,
111    pub thresholds: RealismThresholds,
112    /// True iff the dataset exhibits every *gated* stylized fact (fat tails,
113    /// volatility clustering, aggregational Gaussianity, time-reversal asymmetry).
114    pub realistic: bool,
115    pub failures: Vec<RealismFailure>,
116}
117
118impl StylizedFactsReport {
119    /// Fat tails: excess kurtosis clears the bar.
120    pub fn has_fat_tails(&self, t: &RealismThresholds) -> bool {
121        self.excess_kurtosis >= t.min_excess_kurtosis
122    }
123    /// Volatility clustering: |return| autocorrelation clears the bar.
124    pub fn has_volatility_clustering(&self, t: &RealismThresholds) -> bool {
125        self.abs_return_autocorr >= t.min_abs_return_autocorr
126    }
127    /// Aggregational Gaussianity: kurtosis shrinks enough under aggregation.
128    pub fn has_aggregational_gaussianity(&self, t: &RealismThresholds) -> bool {
129        self.aggregational_gaussianity >= t.min_aggregational_gaussianity
130    }
131    /// Time-reversal asymmetry: the Zumbach/leverage score is large enough in
132    /// magnitude (either sign) to distinguish the series from its time reversal.
133    pub fn has_time_reversal_asymmetry(&self, t: &RealismThresholds) -> bool {
134        self.zumbach_asymmetry.abs() >= t.min_zumbach_asymmetry
135    }
136
137    /// Every gated stylized fact holds.
138    pub fn is_realistic(&self, t: &RealismThresholds) -> bool {
139        self.failures(t).is_empty()
140    }
141
142    /// The specific stylized facts the series fails to exhibit (empty ⇒ realistic).
143    pub fn failures(&self, t: &RealismThresholds) -> Vec<RealismFailure> {
144        let mut out = Vec::new();
145        if !self.has_fat_tails(t) {
146            out.push(RealismFailure::ThinTails);
147        }
148        if !self.has_volatility_clustering(t) {
149            out.push(RealismFailure::NoVolatilityClustering);
150        }
151        if !self.has_aggregational_gaussianity(t) {
152            out.push(RealismFailure::NoAggregationalGaussianity);
153        }
154        if !self.has_time_reversal_asymmetry(t) {
155            out.push(RealismFailure::TimeReversalSymmetric);
156        }
157        out
158    }
159}
160
161/// Biased autocorrelation of `xs` at `lag` (denominator is the full sum of squares,
162/// the standard estimator). 0.0 when undefined (too short or constant).
163fn autocorr(xs: &[f64], lag: usize) -> f64 {
164    let n = xs.len();
165    if lag == 0 {
166        return 1.0;
167    }
168    if n <= lag {
169        return 0.0;
170    }
171    let m = mean(xs);
172    let mut den = 0.0;
173    for x in xs {
174        let d = x - m;
175        den += d * d;
176    }
177    if den <= 0.0 {
178        return 0.0;
179    }
180    let mut num = 0.0;
181    for i in 0..(n - lag) {
182        num += (xs[i] - m) * (xs[i + lag] - m);
183    }
184    num / den
185}
186
187/// Normalized lead-lag cross-correlation `corr(a_t, b_{t+lag})`, normalized by the
188/// full-sample (population) standard deviations. 0.0 when undefined.
189fn cross_corr(a: &[f64], b: &[f64], lag: usize) -> f64 {
190    let n = a.len().min(b.len());
191    if n <= lag {
192        return 0.0;
193    }
194    let ma = mean(&a[..n]);
195    let mb = mean(&b[..n]);
196    let mut va = 0.0;
197    let mut vb = 0.0;
198    for i in 0..n {
199        va += (a[i] - ma) * (a[i] - ma);
200        vb += (b[i] - mb) * (b[i] - mb);
201    }
202    if va <= 0.0 || vb <= 0.0 {
203        return 0.0;
204    }
205    let mut cov = 0.0;
206    for t in 0..(n - lag) {
207        cov += (a[t] - ma) * (b[t + lag] - mb);
208    }
209    // Same normalization for cov (sum, not mean) as va/vb, so this is a correlation.
210    cov / (va.sqrt() * vb.sqrt())
211}
212
213/// Excess kurtosis of non-overlapping block sums of `xs` (block size `block`).
214/// Falls back to the raw excess kurtosis when the series is too short to aggregate.
215fn aggregated_excess_kurtosis(xs: &[f64], block: usize) -> f64 {
216    if block <= 1 || xs.len() < block * 4 {
217        return kurtosis(xs) - 3.0;
218    }
219    let agg: Vec<f64> = xs
220        .chunks_exact(block)
221        .map(|c| c.iter().sum::<f64>())
222        .collect();
223    kurtosis(&agg) - 3.0
224}
225
226/// Measure the stylized-facts profile of a return series. Pure; deterministic.
227pub fn stylized_facts(returns: &[f64]) -> StylizedFactsReport {
228    let abs: Vec<f64> = returns.iter().map(|r| r.abs()).collect();
229    let sq: Vec<f64> = returns.iter().map(|r| r * r).collect();
230
231    let excess_kurtosis = kurtosis(returns) - 3.0;
232    let abs_return_autocorr = autocorr(&abs, 1);
233
234    let max_lag = CLUSTER_LAGS.min(returns.len().saturating_sub(2)).max(1);
235    let vol_clustering_acf = (1..=max_lag).map(|k| autocorr(&sq, k)).sum::<f64>() / max_lag as f64;
236
237    let gain_loss_skew = skewness(returns);
238
239    let raw_xk = excess_kurtosis;
240    let agg_xk = aggregated_excess_kurtosis(returns, AGGREGATION_BLOCK);
241    let aggregational_gaussianity = raw_xk - agg_xk;
242
243    // Zumbach / leverage time-reversal asymmetry: "past return → future vol" versus
244    // "past vol → future return", averaged over the near lags. A time-reversal-
245    // symmetric process has these equal (score ~0); the leverage effect makes the
246    // forward term negative in real markets.
247    let lev_fwd = (1..=max_lag)
248        .map(|k| cross_corr(returns, &sq, k))
249        .sum::<f64>()
250        / max_lag as f64;
251    let lev_rev = (1..=max_lag)
252        .map(|k| cross_corr(&sq, returns, k))
253        .sum::<f64>()
254        / max_lag as f64;
255    let zumbach_asymmetry = lev_fwd - lev_rev;
256
257    StylizedFactsReport {
258        excess_kurtosis,
259        abs_return_autocorr,
260        vol_clustering_acf,
261        gain_loss_skew,
262        aggregational_gaussianity,
263        zumbach_asymmetry,
264    }
265}
266
267/// Certify a dataset against the default [`RealismThresholds`].
268pub fn validate_dataset(returns: &[f64]) -> RealismVerdict {
269    validate_dataset_with(returns, &RealismThresholds::default())
270}
271
272/// Certify a dataset against explicit thresholds.
273pub fn validate_dataset_with(returns: &[f64], thresholds: &RealismThresholds) -> RealismVerdict {
274    let report = stylized_facts(returns);
275    let failures = report.failures(thresholds);
276    RealismVerdict {
277        realistic: failures.is_empty(),
278        report,
279        thresholds: *thresholds,
280        failures,
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// A tiny deterministic LCG in [0, 1) — no crate deps, byte-identical anywhere.
289    struct Lcg(u64);
290    impl Lcg {
291        fn u(&mut self) -> f64 {
292            self.0 = self
293                .0
294                .wrapping_mul(6364136223846793005)
295                .wrapping_add(1442695040888963407);
296            (self.0 >> 11) as f64 / (1u64 << 53) as f64
297        }
298        /// Approximate standard normal (sum of 12 uniforms − 6; mean 0, var 1).
299        fn z(&mut self) -> f64 {
300            let mut s = 0.0;
301            for _ in 0..12 {
302                s += self.u();
303            }
304            s - 6.0
305        }
306    }
307
308    /// Thin-tailed Gaussian i.i.d. returns — the null "toy market".
309    fn gaussian_iid(n: usize, seed: u64) -> Vec<f64> {
310        let mut r = Lcg(seed);
311        (0..n).map(|_| 0.0005 + 0.01 * r.z()).collect()
312    }
313
314    /// A leverage stochastic-vol series: stable AR(1) log-vol with a leverage term
315    /// (negative returns raise next-period vol) and heavy-tailed innovations. Built
316    /// to exhibit every stylized fact at once.
317    fn leverage_sv(n: usize, seed: u64) -> Vec<f64> {
318        let mut r = Lcg(seed);
319        let mean_lv = -4.6_f64;
320        let mut log_vol = mean_lv;
321        let mut z_prev = 0.0_f64;
322        let mut out = Vec::with_capacity(n);
323        for _ in 0..n {
324            let eta = r.z();
325            log_vol = mean_lv + 0.94 * (log_vol - mean_lv) - 0.20 * z_prev + 0.30 * eta;
326            let heavy = if r.u() < 0.04 { 3.5 } else { 1.0 };
327            let z = r.z() * heavy;
328            out.push(0.0003 + log_vol.exp() * z);
329            z_prev = z;
330        }
331        out
332    }
333
334    #[test]
335    fn gaussian_iid_is_not_realistic() {
336        let v = validate_dataset(&gaussian_iid(4000, 12345));
337        assert!(!v.realistic, "thin Gaussian toy must fail: {:?}", v.report);
338        assert!(
339            v.failures.contains(&RealismFailure::ThinTails),
340            "no fat tails in a Gaussian: {:?}",
341            v.report
342        );
343        // Sanity on the raw statistics: near-zero excess kurtosis and no clustering.
344        assert!(v.report.excess_kurtosis.abs() < 0.5);
345        assert!(v.report.abs_return_autocorr < 0.05);
346    }
347
348    #[test]
349    fn fat_tailed_clustered_series_is_realistic() {
350        let v = validate_dataset(&leverage_sv(4000, 99999));
351        assert!(v.realistic, "leverage-SV must certify: {:?}", v);
352        assert!(v.failures.is_empty());
353        // Each stylized fact is on the realistic side of its bar.
354        assert!(v.report.excess_kurtosis > 1.0, "fat tails");
355        assert!(v.report.abs_return_autocorr > 0.1, "volatility clustering");
356        assert!(
357            v.report.vol_clustering_acf > 0.0,
358            "squared-return persistence"
359        );
360        assert!(
361            v.report.aggregational_gaussianity > 0.0,
362            "kurtosis falls under aggregation"
363        );
364        assert!(
365            v.report.zumbach_asymmetry.abs() >= 0.01,
366            "time-reversal asymmetry from leverage"
367        );
368    }
369
370    #[test]
371    fn zumbach_asymmetry_is_directional_leverage() {
372        // The leverage term makes past returns predict future volatility more than
373        // the reverse, so the forward-minus-reverse score is negative.
374        let v = stylized_facts(&leverage_sv(4000, 7));
375        assert!(
376            v.zumbach_asymmetry < 0.0,
377            "leverage → negative Zumbach score, got {}",
378            v.zumbach_asymmetry
379        );
380    }
381
382    #[test]
383    fn time_symmetric_process_has_no_zumbach() {
384        // Gaussian i.i.d. is time-reversal symmetric: the score sits near zero.
385        let v = stylized_facts(&gaussian_iid(4000, 4242));
386        assert!(
387            v.zumbach_asymmetry.abs() < 0.01,
388            "iid is time-symmetric, got {}",
389            v.zumbach_asymmetry
390        );
391    }
392
393    #[test]
394    fn constant_and_short_series_are_safe() {
395        // No panics, no NaNs, and (correctly) not certified realistic.
396        for r in [vec![], vec![0.0; 8], vec![0.001; 3], vec![0.01, -0.01]] {
397            let v = validate_dataset(&r);
398            assert!(v.report.excess_kurtosis.is_finite());
399            assert!(v.report.abs_return_autocorr.is_finite());
400            assert!(v.report.zumbach_asymmetry.is_finite());
401            assert!(!v.realistic);
402        }
403    }
404
405    #[test]
406    fn thresholds_are_configurable() {
407        let returns = gaussian_iid(2000, 1);
408        // An impossible-to-fail threshold set certifies anything finite.
409        let lax = RealismThresholds {
410            min_excess_kurtosis: f64::NEG_INFINITY,
411            min_abs_return_autocorr: f64::NEG_INFINITY,
412            min_aggregational_gaussianity: f64::NEG_INFINITY,
413            min_zumbach_asymmetry: 0.0,
414        };
415        assert!(validate_dataset_with(&returns, &lax).realistic);
416    }
417}