Honesty doctrine. Every candidate anomaly is an artifact until proven otherwise; in-sample results are never findings; past statistical regularity does not imply future returns. This is research on statistical properties of market data — not investment advice, not a trading system.

Code / src/anomaly_atlas/stats/reversion.py

src/anomaly_atlas/stats/reversion.py 84 lines
# =============================================================================
#  Project   : anomaly-atlas
#  File      : src/anomaly_atlas/stats/reversion.py
#  Purpose   : Mean-reversion tests: variance ratios, AC1, AR half-life
#  Author    : Simon-Pierre Boucher
#  Contact   : contact@spboucher.ai
#  Data src  : hfmarketdata.io (sole data source)
#  Created   : 2026-08-12
#  Modified  : 2026-08-12
#  Platform  : macOS / Apple Silicon (arm64)
#  License   : All rights reserved (research code)
# =============================================================================
"""Mean-reversion statistics on return series.

Validated on synthetic ground truth before touching real data
(benchmarks/synthetic/test_synthetic_gate.py — charter §8.1).
"""

from __future__ import annotations

import numpy as np


def ac1(returns: np.ndarray) -> float:
    """Lag-1 autocorrelation of a return series."""
    r = np.asarray(returns, dtype=float)
    if len(r) < 3:
        return float("nan")
    a, b = r[:-1], r[1:]
    sa, sb = a.std(), b.std()
    if sa == 0.0 or sb == 0.0:
        return float("nan")
    return float(((a - a.mean()) * (b - b.mean())).mean() / (sa * sb))


def autocov1(returns: np.ndarray) -> float:
    """Lag-1 autocovariance (input to the Roll spread estimator)."""
    r = np.asarray(returns, dtype=float)
    if len(r) < 3:
        return float("nan")
    a, b = r[:-1], r[1:]
    return float(((a - a.mean()) * (b - b.mean())).mean())


def variance_ratio(returns: np.ndarray, q: int) -> float:
    """Lo-MacKinlay variance ratio VR(q) with overlapping q-period sums.

    Ground truth: VR = 1 for a random walk, < 1 under mean reversion,
    > 1 under momentum. Unbiased variance estimators, demeaned.
    """
    r = np.asarray(returns, dtype=float)
    n = len(r)
    if n < q + 2 or q < 2:
        return float("nan")
    mu = r.mean()
    var1 = ((r - mu) ** 2).sum() / (n - 1)
    rq = np.convolve(r, np.ones(q), mode="valid")  # overlapping q-sums
    # Lo-MacKinlay bias-corrected PER-PERIOD variance of q-sums: the factor q
    # lives inside m, so the ratio below is varq/var1 (NOT varq/(q*var1)).
    m = q * (n - q + 1) * (1 - q / n)
    varq = ((rq - q * mu) ** 2).sum() / m
    if var1 == 0.0:
        return float("nan")
    return float(varq / var1)


def half_life(log_prices: np.ndarray) -> float:
    """Mean-reversion half-life from an AR(1) fit: dp_t = a + b*p_{t-1} + e.

    Returns ln(2)/-ln(1+b) in bars for b in (-1, 0); +inf if b >= 0
    (no reversion). Matches the OU generator's ln(2)/-ln(1-kappa).
    """
    p = np.asarray(log_prices, dtype=float)
    if len(p) < 10:
        return float("nan")
    x, y = p[:-1], np.diff(p)
    vx = x.var()
    if vx == 0.0:
        return float("nan")
    b = ((x - x.mean()) * (y - y.mean())).mean() / vx
    if b >= 0.0 or b <= -1.0:
        return float("inf")
    return float(np.log(2.0) / -np.log1p(b))