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}