Skip to content

Commit 7892e39

Browse files
merge feat-realism-py (new-builds-2 sibling candidate)
2 parents 2017206 + df39719 commit 7892e39

3 files changed

Lines changed: 439 additions & 0 deletions

File tree

crates/openoutcry-py/python/openoutcry/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,12 @@
119119
assert_no_regression,
120120
EVAL_SET_VERSION,
121121
)
122+
from .realism import (
123+
stylized_facts,
124+
certify_realism,
125+
RealismReport,
126+
DEFAULT_THRESHOLDS,
127+
)
122128
from .registration import register_envs
123129

124130
# Farama plugin convention: register the versioned env IDs at import time (idempotent).
@@ -245,6 +251,10 @@
245251
"MISSPECIFIED_PROXY_POLICIES",
246252
"misspecification_gap",
247253
"demonstrate_punishment",
254+
"stylized_facts",
255+
"certify_realism",
256+
"RealismReport",
257+
"DEFAULT_THRESHOLDS",
248258
"register_envs",
249259
]
250260
__version__ = "0.6.0"
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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

Comments
 (0)