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}