# ============================================================================= # 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