|
| 1 | +"""Stylized-facts realism diagnostic for generated market panels. |
| 2 | +
|
| 3 | +A market simulator is only useful if the tapes it emits *look like* real markets. This |
| 4 | +module is the diagnostic that certifies exactly that: given a returns/price panel it |
| 5 | +computes the canonical empirical stylized facts of financial returns (Cont 2001) and |
| 6 | +grades each against a believability threshold. |
| 7 | +
|
| 8 | +The facts computed by :func:`stylized_facts`: |
| 9 | +
|
| 10 | +* **excess_kurtosis**: leptokurtosis. Real return distributions are fat-tailed (positive |
| 11 | + excess kurtosis over the Gaussian's 3); a Gaussian scores ~0. |
| 12 | +* **abs_return_autocorr**: volatility clustering. Signed returns are ~uncorrelated but |
| 13 | + ``|return|`` is positively autocorrelated and decays slowly; the mean of that |
| 14 | + autocorrelation over the first few lags is a one-number clustering score. |
| 15 | +* **zumbach_asymmetry**: time-reversal asymmetry (Zumbach 2009). Past coarse-grained |
| 16 | + volatility predicts future fine-grained volatility better than the reverse, so the |
| 17 | + coarse->fine minus fine->coarse correlation gap is positive in real markets and ~0 for a |
| 18 | + time-symmetric process. Computed at a lag equal to the coarse window so the two vol |
| 19 | + measures never share a return. |
| 20 | +* **gain_loss_skew**: the gain/loss asymmetry of the return distribution (its skewness); |
| 21 | + equities are typically left-skewed (fatter loss tail). |
| 22 | +* **aggregational_gaussianity**: as returns are summed over longer horizons the |
| 23 | + distribution drifts back toward Gaussian, so excess kurtosis decays. The fact is the |
| 24 | + excess kurtosis at horizon 1 minus that of the horizon-aggregated series; positive in |
| 25 | + real markets. |
| 26 | +* **fano_factor**: intermittency. Large moves (``|return|`` exceedances) arrive in bursts, |
| 27 | + not as an even Poisson stream, so the Fano factor (variance/mean of exceedance counts per |
| 28 | + window) exceeds the Poisson value of 1. |
| 29 | +
|
| 30 | +:func:`certify_realism` grades a panel against :data:`DEFAULT_THRESHOLDS` (or caller |
| 31 | +overrides) and returns a :class:`RealismReport` with a per-fact pass/fail and an overall |
| 32 | +verdict. |
| 33 | +
|
| 34 | +This is a **test gate / diagnostic**, not part of the byte-identical generation hot path, |
| 35 | +so it is free to use ``log``/``sqrt`` and other transcendentals. It is pure over an |
| 36 | +injected panel: it never touches the native engine or any RNG. |
| 37 | +""" |
| 38 | + |
| 39 | +from __future__ import annotations |
| 40 | + |
| 41 | +from dataclasses import dataclass, field |
| 42 | +from typing import Optional |
| 43 | + |
| 44 | +import numpy as np |
| 45 | + |
| 46 | + |
| 47 | +def _as_returns(panel, kind: str) -> np.ndarray: |
| 48 | + """Coerce ``panel`` to a 2-D ``(T, n_series)`` returns array. |
| 49 | +
|
| 50 | + ``kind="price"`` takes log returns of a strictly-positive price panel; ``kind="return"`` |
| 51 | + treats the panel as returns already. A 1-D panel is read as a single series. |
| 52 | + """ |
| 53 | + a = np.asarray(panel, dtype=np.float64) |
| 54 | + if a.ndim == 1: |
| 55 | + a = a.reshape(-1, 1) |
| 56 | + if a.ndim != 2: |
| 57 | + raise ValueError("panel must be 1-D or 2-D (time on axis 0, series on axis 1)") |
| 58 | + if kind == "return": |
| 59 | + r = a |
| 60 | + elif kind == "price": |
| 61 | + if a.shape[0] < 2: |
| 62 | + raise ValueError("a price panel needs at least 2 rows to form a return") |
| 63 | + if not np.all(a > 0.0): |
| 64 | + raise ValueError("a price panel must be strictly positive to take log returns") |
| 65 | + r = np.diff(np.log(a), axis=0) |
| 66 | + else: |
| 67 | + raise ValueError("kind must be 'price' or 'return'") |
| 68 | + if r.shape[0] < 2: |
| 69 | + raise ValueError("need at least 2 returns to compute stylized facts") |
| 70 | + return r |
| 71 | + |
| 72 | + |
| 73 | +def _columns(r: np.ndarray): |
| 74 | + return (r[:, c] for c in range(r.shape[1])) |
| 75 | + |
| 76 | + |
| 77 | +def _excess_kurtosis_series(x: np.ndarray) -> float: |
| 78 | + x = x - x.mean() |
| 79 | + s = x.std() |
| 80 | + if s == 0.0: |
| 81 | + return np.nan |
| 82 | + return float(np.mean(x**4) / s**4 - 3.0) |
| 83 | + |
| 84 | + |
| 85 | +def _excess_kurtosis(r: np.ndarray) -> float: |
| 86 | + return float(np.nanmean([_excess_kurtosis_series(x) for x in _columns(r)])) |
| 87 | + |
| 88 | + |
| 89 | +def _abs_return_autocorr(r: np.ndarray, lags: int) -> float: |
| 90 | + """Mean autocorrelation of ``|return|`` over lags ``1..lags`` (volatility clustering).""" |
| 91 | + vals: list[float] = [] |
| 92 | + for x in _columns(r): |
| 93 | + a = np.abs(x) |
| 94 | + a = a - a.mean() |
| 95 | + denom = float(np.sum(a * a)) |
| 96 | + if denom == 0.0: |
| 97 | + continue |
| 98 | + for k in range(1, min(lags, len(a) - 1) + 1): |
| 99 | + vals.append(float(np.sum(a[k:] * a[:-k]) / denom)) |
| 100 | + return float(np.nanmean(vals)) if vals else np.nan |
| 101 | + |
| 102 | + |
| 103 | +def _zumbach_asymmetry(r: np.ndarray, window: int) -> float: |
| 104 | + """Coarse->fine minus fine->coarse volatility-correlation gap at lag == ``window``. |
| 105 | +
|
| 106 | + ``fine`` is instantaneous ``|return|``; ``coarse`` is its trailing ``window``-bar mean. |
| 107 | + The lag equals the window so the coarse measure and the lagged fine measure never share |
| 108 | + a return (which would inject a spurious contemporaneous correlation). |
| 109 | + """ |
| 110 | + lag = window |
| 111 | + vals: list[float] = [] |
| 112 | + kernel = np.ones(window) / window |
| 113 | + for x in _columns(r): |
| 114 | + fine = np.abs(x) |
| 115 | + if len(fine) <= lag + 1: |
| 116 | + continue |
| 117 | + coarse = np.convolve(fine, kernel, mode="full")[: len(fine)] # trailing MA |
| 118 | + cf = np.corrcoef(coarse[:-lag], fine[lag:])[0, 1] |
| 119 | + fc = np.corrcoef(fine[:-lag], coarse[lag:])[0, 1] |
| 120 | + vals.append(float(cf - fc)) |
| 121 | + return float(np.nanmean(vals)) if vals else np.nan |
| 122 | + |
| 123 | + |
| 124 | +def _gain_loss_skew(r: np.ndarray) -> float: |
| 125 | + vals: list[float] = [] |
| 126 | + for x in _columns(r): |
| 127 | + x = x - x.mean() |
| 128 | + s = x.std() |
| 129 | + if s == 0.0: |
| 130 | + continue |
| 131 | + vals.append(float(np.mean(x**3) / s**3)) |
| 132 | + return float(np.nanmean(vals)) if vals else np.nan |
| 133 | + |
| 134 | + |
| 135 | +def _aggregational_gaussianity(r: np.ndarray, horizon: int) -> float: |
| 136 | + """Excess kurtosis at horizon 1 minus that of the ``horizon``-aggregated return series. |
| 137 | +
|
| 138 | + Positive when tails thin toward Gaussian as returns are summed over longer horizons. |
| 139 | + """ |
| 140 | + n_blocks = r.shape[0] // horizon |
| 141 | + if n_blocks < 2: |
| 142 | + return np.nan |
| 143 | + agg = r[: n_blocks * horizon].reshape(n_blocks, horizon, r.shape[1]).sum(axis=1) |
| 144 | + return float(_excess_kurtosis(r) - _excess_kurtosis(agg)) |
| 145 | + |
| 146 | + |
| 147 | +def _fano_factor(r: np.ndarray, window: int, z: float) -> float: |
| 148 | + """Fano factor (variance/mean) of large-move exceedance counts per ``window``. |
| 149 | +
|
| 150 | + A large move is ``|return|`` above ``mean + z*std``; counting exceedances per window |
| 151 | + turns the tape into a point process whose Fano factor is 1 under a Poisson (memoryless) |
| 152 | + arrival and > 1 when large moves cluster (intermittency). |
| 153 | + """ |
| 154 | + vals: list[float] = [] |
| 155 | + for x in _columns(r): |
| 156 | + a = np.abs(x) |
| 157 | + thr = a.mean() + z * a.std() |
| 158 | + events = (a > thr).astype(np.float64) |
| 159 | + n_blocks = len(events) // window |
| 160 | + if n_blocks < 2: |
| 161 | + continue |
| 162 | + counts = events[: n_blocks * window].reshape(n_blocks, window).sum(axis=1) |
| 163 | + mean = counts.mean() |
| 164 | + if mean > 0.0: |
| 165 | + vals.append(float(counts.var() / mean)) |
| 166 | + return float(np.nanmean(vals)) if vals else np.nan |
| 167 | + |
| 168 | + |
| 169 | +def stylized_facts( |
| 170 | + panel, |
| 171 | + *, |
| 172 | + kind: str = "price", |
| 173 | + abs_acf_lags: int = 10, |
| 174 | + coarse_window: int = 5, |
| 175 | + agg_horizon: int = 4, |
| 176 | + fano_window: int = 10, |
| 177 | + fano_z: float = 2.0, |
| 178 | +) -> dict[str, float]: |
| 179 | + """Compute the stylized-facts vector of a returns/price ``panel``. |
| 180 | +
|
| 181 | + ``panel`` is a 1-D series or a 2-D ``(T, n_series)`` array; per-series facts are |
| 182 | + averaged. ``kind="price"`` (default) takes log returns of a positive price panel, |
| 183 | + ``kind="return"`` treats the panel as returns. Returns a dict of the six facts described |
| 184 | + in the module docstring. Any fact that is undefined for the given panel (too few bars, |
| 185 | + a constant series) is returned as ``nan`` rather than raising. |
| 186 | + """ |
| 187 | + r = _as_returns(panel, kind) |
| 188 | + return { |
| 189 | + "excess_kurtosis": _excess_kurtosis(r), |
| 190 | + "abs_return_autocorr": _abs_return_autocorr(r, abs_acf_lags), |
| 191 | + "zumbach_asymmetry": _zumbach_asymmetry(r, coarse_window), |
| 192 | + "gain_loss_skew": _gain_loss_skew(r), |
| 193 | + "aggregational_gaussianity": _aggregational_gaussianity(r, agg_horizon), |
| 194 | + "fano_factor": _fano_factor(r, fano_window, fano_z), |
| 195 | + } |
| 196 | + |
| 197 | + |
| 198 | +# Directional believability bounds for a real market, ``fact -> (low, high)`` inclusive |
| 199 | +# (``None`` == unbounded). A fact with no entry here is reported but not gated. Defaults |
| 200 | +# encode the sign of each empirical stylized fact; skew and Zumbach asymmetry are left |
| 201 | +# informational because their sign is asset- and regime-dependent. |
| 202 | +DEFAULT_THRESHOLDS: dict[str, tuple[Optional[float], Optional[float]]] = { |
| 203 | + "excess_kurtosis": (0.0, None), # fat-tailed / leptokurtic |
| 204 | + "abs_return_autocorr": (0.0, None), # volatility clustering present |
| 205 | + "aggregational_gaussianity": (0.0, None), # kurtosis decays on aggregation |
| 206 | + "fano_factor": (1.0, None), # super-Poisson (bursty) large moves |
| 207 | +} |
| 208 | + |
| 209 | + |
| 210 | +@dataclass(frozen=True) |
| 211 | +class RealismReport: |
| 212 | + """The graded output of :func:`certify_realism`. |
| 213 | +
|
| 214 | + ``facts`` is the full stylized-facts vector; ``checks`` maps each *gated* fact to its |
| 215 | + pass/fail; ``passed`` is the conjunction over ``checks`` (a panel with no gated facts |
| 216 | + trivially passes). ``thresholds`` records the bounds actually applied. |
| 217 | + """ |
| 218 | + |
| 219 | + facts: dict[str, float] |
| 220 | + checks: dict[str, bool] |
| 221 | + passed: bool |
| 222 | + thresholds: dict[str, tuple[Optional[float], Optional[float]]] = field(default_factory=dict) |
| 223 | + |
| 224 | + |
| 225 | +def certify_realism( |
| 226 | + panel, |
| 227 | + thresholds: Optional[dict[str, tuple[Optional[float], Optional[float]]]] = None, |
| 228 | + **facts_kwargs, |
| 229 | +) -> RealismReport: |
| 230 | + """Grade a ``panel`` against believability ``thresholds`` (defaults merged in). |
| 231 | +
|
| 232 | + ``thresholds`` maps a fact name to an inclusive ``(low, high)`` band (either bound may |
| 233 | + be ``None``). Provided entries override :data:`DEFAULT_THRESHOLDS`; a fact whose value is |
| 234 | + ``nan`` fails its check. Extra keyword args flow through to :func:`stylized_facts`. |
| 235 | + """ |
| 236 | + merged = dict(DEFAULT_THRESHOLDS) |
| 237 | + if thresholds is not None: |
| 238 | + merged.update(thresholds) |
| 239 | + facts = stylized_facts(panel, **facts_kwargs) |
| 240 | + checks: dict[str, bool] = {} |
| 241 | + for name, (low, high) in merged.items(): |
| 242 | + value = facts.get(name, np.nan) |
| 243 | + ok = np.isfinite(value) |
| 244 | + if ok and low is not None: |
| 245 | + ok = value >= low |
| 246 | + if ok and high is not None: |
| 247 | + ok = value <= high |
| 248 | + checks[name] = bool(ok) |
| 249 | + return RealismReport( |
| 250 | + facts=facts, |
| 251 | + checks=checks, |
| 252 | + passed=all(checks.values()), |
| 253 | + thresholds=merged, |
| 254 | + ) |
| 255 | + |
| 256 | + |
| 257 | +__all__ = [ |
| 258 | + "stylized_facts", |
| 259 | + "certify_realism", |
| 260 | + "RealismReport", |
| 261 | + "DEFAULT_THRESHOLDS", |
| 262 | +] |
0 commit comments