spb/anomaly-atlas Public License
Systematic discovery & rigorous validation of statistical anomalies in open HF market data (hfmarketdata.io) — pre-registered, artifact-null-driven, fully reproducible. Live atlas: www.anomaly-atlas.io
Python 61.4%
JavaScript 28.7%
CSS 8.6%
Shell 0.7%
Makefile 0.5%
1# =============================================================================2# Project : anomaly-atlas3# File : src/anomaly_atlas/stats/reversion.py4# Purpose : Mean-reversion tests: variance ratios, AC1, AR half-life5# Author : Simon-Pierre Boucher6# Contact : contact@spboucher.ai7# Data src : hfmarketdata.io (sole data source)8# Created : 2026-08-129# Modified : 2026-08-1210# Platform : macOS / Apple Silicon (arm64)11# License : All rights reserved (research code)12# =============================================================================13"""Mean-reversion statistics on return series.1415Validated on synthetic ground truth before touching real data16(benchmarks/synthetic/test_synthetic_gate.py — charter §8.1).17"""1819from __future__ import annotations2021import numpy as np222324def ac1(returns: np.ndarray) -> float:25 """Lag-1 autocorrelation of a return series."""26 r = np.asarray(returns, dtype=float)27 if len(r) < 3:28 return float("nan")29 a, b = r[:-1], r[1:]30 sa, sb = a.std(), b.std()31 if sa == 0.0 or sb == 0.0:32 return float("nan")33 return float(((a - a.mean()) * (b - b.mean())).mean() / (sa * sb))343536def autocov1(returns: np.ndarray) -> float:37 """Lag-1 autocovariance (input to the Roll spread estimator)."""38 r = np.asarray(returns, dtype=float)39 if len(r) < 3:40 return float("nan")41 a, b = r[:-1], r[1:]42 return float(((a - a.mean()) * (b - b.mean())).mean())434445def variance_ratio(returns: np.ndarray, q: int) -> float:46 """Lo-MacKinlay variance ratio VR(q) with overlapping q-period sums.4748 Ground truth: VR = 1 for a random walk, < 1 under mean reversion,49 > 1 under momentum. Unbiased variance estimators, demeaned.50 """51 r = np.asarray(returns, dtype=float)52 n = len(r)53 if n < q + 2 or q < 2:54 return float("nan")55 mu = r.mean()56 var1 = ((r - mu) ** 2).sum() / (n - 1)57 rq = np.convolve(r, np.ones(q), mode="valid") # overlapping q-sums58 # Lo-MacKinlay bias-corrected PER-PERIOD variance of q-sums: the factor q59 # lives inside m, so the ratio below is varq/var1 (NOT varq/(q*var1)).60 m = q * (n - q + 1) * (1 - q / n)61 varq = ((rq - q * mu) ** 2).sum() / m62 if var1 == 0.0:63 return float("nan")64 return float(varq / var1)656667def half_life(log_prices: np.ndarray) -> float:68 """Mean-reversion half-life from an AR(1) fit: dp_t = a + b*p_{t-1} + e.6970 Returns ln(2)/-ln(1+b) in bars for b in (-1, 0); +inf if b >= 071 (no reversion). Matches the OU generator's ln(2)/-ln(1-kappa).72 """73 p = np.asarray(log_prices, dtype=float)74 if len(p) < 10:75 return float("nan")76 x, y = p[:-1], np.diff(p)77 vx = x.var()78 if vx == 0.0:79 return float("nan")80 b = ((x - x.mean()) * (y - y.mean())).mean() / vx81 if b >= 0.0 or b <= -1.0:82 return float("inf")83 return float(np.log(2.0) / -np.log1p(b))84