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/spa.py

src/anomaly_atlas/stats/spa.py 133 lines
# =============================================================================
#  Project   : anomaly-atlas
#  File      : src/anomaly_atlas/stats/spa.py
#  Purpose   : White Reality Check & Hansen SPA over a rule-return matrix
#  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)
# =============================================================================
"""Data-snooping corrections over a searched universe of rules.

Inputs are a (T days × N rules) matrix of rule returns. H0: no rule has
positive expected return — max_k E[f_k] <= 0.

* White (2000) Reality Check: max-statistic over the centered stationary
  bootstrap (Politis-Romano 1994).
* Hansen (2005) SPA: studentized statistic with the recentering threshold,
  less sensitive to poor/irrelevant rules in the universe.

Validated on synthetic ground truth (§8.1 gate: pure noise must not
survive; a planted profitable rule must).
"""

from __future__ import annotations

import numpy as np


def stationary_bootstrap_indices(
    n: int, mean_block: float, n_boot: int, seed: int = 42
) -> np.ndarray:
    """(n_boot, n) index matrix from the Politis-Romano stationary bootstrap.

    Geometric block lengths with mean `mean_block`, circular wrapping —
    resamples preserve short-range dependence in expectation.
    """
    rng = np.random.default_rng(seed)
    p = 1.0 / mean_block
    idx = np.empty((n_boot, n), dtype=np.int64)
    for b in range(n_boot):
        t = 0
        while t < n:
            start = rng.integers(0, n)
            length = min(int(rng.geometric(p)), n - t)
            idx[b, t : t + length] = (start + np.arange(length)) % n
            t += length
    return idx


def reality_check(
    x: np.ndarray, n_boot: int = 500, mean_block: float = 5.0, seed: int = 42
) -> dict:
    """White's Reality Check p-value for max_k mean(x_k) > 0.

    x: (T, N) rule-return matrix (NaN rows dropped listwise).
    """
    x = np.asarray(x, dtype=float)
    x = x[np.isfinite(x).all(axis=1)]
    t_len, n_rules = x.shape
    if t_len < 30 or n_rules == 0:
        return {"p": float("nan"), "best_rule": None, "v_stat": float("nan")}
    means = x.mean(axis=0)
    v = np.sqrt(t_len) * means.max()
    idx = stationary_bootstrap_indices(t_len, mean_block, n_boot, seed)
    centered = x - means  # White: bootstrap distribution of centered means
    v_boot = np.empty(n_boot)
    for b in range(n_boot):
        v_boot[b] = np.sqrt(t_len) * centered[idx[b]].mean(axis=0).max()
    p = float((np.sum(v_boot >= v) + 1) / (n_boot + 1))
    return {"p": p, "best_rule": int(means.argmax()), "v_stat": float(v),
            "best_mean_daily": float(means.max())}


def spa_test(
    x: np.ndarray, n_boot: int = 500, mean_block: float = 5.0, seed: int = 42
) -> dict:
    """Hansen's SPA p-value (consistent variant) for max_k mean(x_k) > 0."""
    x = np.asarray(x, dtype=float)
    x = x[np.isfinite(x).all(axis=1)]
    t_len, n_rules = x.shape
    if t_len < 30 or n_rules == 0:
        return {"p": float("nan"), "best_rule": None}
    means = x.mean(axis=0)
    idx = stationary_bootstrap_indices(t_len, mean_block, n_boot, seed)
    boot_means = np.empty((n_boot, n_rules))
    for b in range(n_boot):
        boot_means[b] = x[idx[b]].mean(axis=0)
    omega = np.sqrt(t_len) * boot_means.std(axis=0, ddof=1)
    omega = np.maximum(omega, 1e-12)
    t_stat = float((np.sqrt(t_len) * means / omega).max())
    # Hansen recentering: rules with sufficiently negative means contribute 0
    thresh = -omega / np.sqrt(t_len) * np.sqrt(2.0 * np.log(np.log(max(t_len, 3))))
    center = np.where(means >= thresh, means, 0.0)
    t_boot = np.empty(n_boot)
    for b in range(n_boot):
        z = np.sqrt(t_len) * (boot_means[b] - center) / omega
        t_boot[b] = max(z.max(), 0.0)
    p = float((np.sum(t_boot >= max(t_stat, 0.0)) + 1) / (n_boot + 1))
    rule_t = np.sqrt(t_len) * means / omega
    t95 = float(np.percentile(t_boot, 95))
    return {"p": p, "best_rule": int(rule_t.argmax()), "t_stat": t_stat,
            "rule_t": rule_t.tolist(), "t95": t95,
            "n_step1_survivors": int((rule_t >= t95).sum()) if np.isfinite(t95) else 0}


def deflated_sharpe(
    sr: float, t_len: int, skew: float, kurt: float,
    n_trials: int, sr_variance: float,
) -> dict:
    """Bailey & López de Prado (2014) Deflated Sharpe Ratio.

    `sr` is the per-period (e.g. daily) Sharpe of the BEST rule; `sr_variance`
    the variance of Sharpe estimates across the searched universe; `kurt` is
    Pearson kurtosis (normal = 3). Returns the expected max Sharpe under
    pure selection (`sr0`) and DSR = P[true SR > 0 | selection].
    """
    from math import sqrt

    from scipy.stats import norm

    if n_trials < 2 or sr_variance <= 0 or t_len < 10:
        return {"sr0": float("nan"), "dsr": float("nan")}
    gamma = 0.5772156649015329
    z1 = norm.ppf(1.0 - 1.0 / n_trials)
    z2 = norm.ppf(1.0 - 1.0 / (n_trials * np.e))
    sr0 = sqrt(sr_variance) * ((1.0 - gamma) * z1 + gamma * z2)
    denom = sqrt(max(1.0 - skew * sr + (kurt - 1.0) / 4.0 * sr**2, 1e-12))
    dsr = float(norm.cdf((sr - sr0) * sqrt(t_len - 1.0) / denom))
    return {"sr0": float(sr0), "dsr": dsr}