Code / benchmarks/synthetic/test_synthetic_gate.py
benchmarks/synthetic/test_synthetic_gate.py
177 lines
# =============================================================================
# Project : anomaly-atlas
# File : benchmarks/synthetic/test_synthetic_gate.py
# Purpose : §8.1 gate — detectors must pass synthetic ground truth first
# 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)
# =============================================================================
"""The mandatory gate of charter §8.1, as executable tests:
1. pure random walk -> NO anomaly may be detected
2. planted mean-reversion -> must be recovered (incl. half-life)
3. planted lead-lag -> must be recovered at the right lag
4. planted calendar effect -> must be recovered on the right phase
5. pure bid-ask bounce -> must be flagged as ARTIFACT, not anomaly
6. staleness (LOCF) -> must manufacture the documented artifacts
A detector that fails any of these is broken and must not touch real data.
Multi-seed checks use fixed seed lists — fully deterministic.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
from generators import ( # noqa: E402
correlated_pair,
leadlag_pair,
ou_prices,
random_walk,
roll_bounce_prices,
seasonal_returns,
stale_observe,
)
from anomaly_atlas.stats.bootstrap import moving_block_bootstrap, percentile_ci
from anomaly_atlas.stats.leadlag import lagged_xcorr, leadlag_asymmetry, peak_lag
from anomaly_atlas.stats.reversion import ac1, half_life, variance_ratio
from anomaly_atlas.validation.artifacts import (
excess_reversion,
locf_fill,
roll_spread,
staleness_ratio,
)
N = 100_000
SEEDS = [1, 2, 3, 4, 5]
# ----------------------------------------------------- 1. random walk: nothing
def test_random_walk_triggers_nothing():
for seed in SEEDS:
r = np.diff(random_walk(N, seed=seed))
assert abs(ac1(r)) < 0.02
assert abs(variance_ratio(r, 5) - 1.0) < 0.05
assert abs(variance_ratio(r, 30) - 1.0) < 0.12
assert half_life(random_walk(N, seed=seed)) > 5_000 # effectively none
def test_random_walk_ac1_inside_its_bootstrap_ci():
r = np.diff(random_walk(N, seed=7))
boot = moving_block_bootstrap(r, ac1, block=390, n_boot=200, seed=7)
lo, hi = percentile_ci(boot)
assert lo < 0.0 < hi # zero is inside the CI: no detection
def test_independent_walks_show_no_leadlag():
for seed in SEEDS:
x = np.diff(random_walk(N, seed=seed))
y = np.diff(random_walk(N, seed=seed + 100))
xc = lagged_xcorr(x, y, 5)
assert max(abs(v) for v in xc.values()) < 0.02
assert abs(leadlag_asymmetry(xc)) < 0.05
# ------------------------------------------- 2. planted reversion is recovered
def test_ou_reversion_recovered_with_half_life():
kappa = 0.02 # true half-life = ln2 / -ln(0.98) ≈ 34.3 bars
true_hl = np.log(2) / -np.log(1 - kappa)
for seed in SEEDS:
p = ou_prices(N, kappa=kappa, seed=seed)
r = np.diff(p)
assert ac1(r) < -0.005
assert variance_ratio(r, 30) < 0.9
assert abs(half_life(p) - true_hl) / true_hl < 0.25
# --------------------------------------------- 3. planted lead-lag is recovered
def test_planted_leadlag_recovered_at_correct_lag():
for seed in SEEDS:
x, y = leadlag_pair(N, beta=0.3, lag=2, seed=seed)
xc = lagged_xcorr(x, y, 5)
assert peak_lag(xc) == 2
assert xc[2] > 0.2
assert abs(xc[1]) < 0.02 and abs(xc[3]) < 0.02
# --------------------------------------- 4. planted calendar effect is recovered
def test_planted_seasonal_effect_recovered_on_right_phase():
period, hot, amp = 5, 3, 0.0005
for seed in SEEDS:
r = seasonal_returns(N, period, hot, amp, seed=seed)
phase_means = [r[np.arange(N) % period == k].mean() for k in range(period)]
assert np.argmax(phase_means) == hot
assert abs(phase_means[hot] - amp) < amp * 0.2
rest = [m for k, m in enumerate(phase_means) if k != hot]
assert max(abs(m) for m in rest) < amp * 0.2
# ------------------------------------- 5. pure bounce is an artifact, not a find
def test_roll_spread_recovers_planted_spread():
spread = 0.002
for seed in SEEDS:
p = roll_bounce_prices(N, spread=spread, seed=seed)
est = roll_spread(np.diff(p))
assert abs(est - spread) / spread < 0.10
def test_pure_bounce_reversion_vanishes_after_artifact_adjustment():
spread = 0.002
for seed in SEEDS:
p = roll_bounce_prices(N, spread=spread, seed=seed)
r = np.diff(p)
assert ac1(r) < -0.2 # naive detector screams "mean reversion!"
# ...but the excess over the bounce null (true spread supplied) is ~0
assert abs(excess_reversion(r, spread)) < 0.03
def test_true_reversion_survives_artifact_adjustment():
# OU + bounce: after removing the bounce share, reversion must REMAIN
kappa, spread = 0.05, 0.001
for seed in SEEDS:
mid = ou_prices(N, kappa=kappa, seed=seed)
rng = np.random.default_rng(seed + 999)
p = mid + (spread / 2.0) * rng.choice([-1.0, 1.0], size=N)
r = np.diff(p)
assert excess_reversion(r, spread) < -0.01
# ------------------------------------------------ 6. staleness manufactures lies
def test_locf_creates_spurious_positive_autocorrelation():
for seed in SEEDS:
p = random_walk(N, seed=seed)
locf, mask = stale_observe(p, p_observe=0.3, seed=seed)
r = np.diff(locf)
assert ac1(np.diff(p)) < 0.02 # underlying: nothing
# LOCF returns of a pure walk: AC1 pushed NEGATIVE at lag 1 grid steps
# is not the failure mode; the artifact is CROSS-serial (next test) and
# a big mass of zero returns. Document the zero-mass here:
assert (r == 0).mean() > 0.5
assert staleness_ratio(mask) > 0.6
def test_locf_makes_fresh_series_appear_to_lead_stale_one():
for seed in SEEDS:
px, py = correlated_pair(N, rho=0.7, seed=seed)
# underlying returns: correlation only at lag 0
xc_true = lagged_xcorr(np.diff(px), np.diff(py), 3)
assert abs(xc_true[1]) < 0.02
# y observed sparsely, LOCF-joined on the grid: x now "leads" y
mask = np.random.default_rng(seed).random(N) < 0.3
mask[0] = True
y_locf = locf_fill(py, mask)
xc = lagged_xcorr(np.diff(px), np.diff(y_locf), 3)
assert xc[1] > 0.10 # spurious lead of the fresh series
assert leadlag_asymmetry(xc) > 0.1
assert peak_lag({k: v for k, v in xc.items() if k != 0}) == 1