SPB Git

spb/anomaly-atlas Public License

Systematic discovery & rigorous validation of statistical anomalies in open HF market data (hfmarketdata.io) — pre-registered, artifact-null-driven, fully reproducible. Live atlas: www.anomaly-atlas.io

Python 61.4% JavaScript 28.7% CSS 8.6% Shell 0.7% Makefile 0.5%
6.7 KB · 177 lines python
Raw Blame History
1# =============================================================================2#  Project   : anomaly-atlas3#  File      : benchmarks/synthetic/test_synthetic_gate.py4#  Purpose   : §8.1 gate — detectors must pass synthetic ground truth first5#  Author    : Simon-Pierre Boucher6#  Contact   : contact@spboucher.ai7#  Data src  : hfmarketdata.io (sole data source)8#  Created   : 2026-08-129#  Modified  : 2026-08-1210#  Platform  : macOS / Apple Silicon (arm64)11#  License   : All rights reserved (research code)12# =============================================================================13"""The mandatory gate of charter §8.1, as executable tests:1415  1. pure random walk        -> NO anomaly may be detected16  2. planted mean-reversion  -> must be recovered (incl. half-life)17  3. planted lead-lag        -> must be recovered at the right lag18  4. planted calendar effect -> must be recovered on the right phase19  5. pure bid-ask bounce     -> must be flagged as ARTIFACT, not anomaly20  6. staleness (LOCF)        -> must manufacture the documented artifacts2122A detector that fails any of these is broken and must not touch real data.23Multi-seed checks use fixed seed lists — fully deterministic.24"""2526from __future__ import annotations2728import sys29from pathlib import Path3031import numpy as np3233sys.path.insert(0, str(Path(__file__).resolve().parent))3435from generators import (  # noqa: E40236    correlated_pair,37    leadlag_pair,38    ou_prices,39    random_walk,40    roll_bounce_prices,41    seasonal_returns,42    stale_observe,43)4445from anomaly_atlas.stats.bootstrap import moving_block_bootstrap, percentile_ci46from anomaly_atlas.stats.leadlag import lagged_xcorr, leadlag_asymmetry, peak_lag47from anomaly_atlas.stats.reversion import ac1, half_life, variance_ratio48from anomaly_atlas.validation.artifacts import (49    excess_reversion,50    locf_fill,51    roll_spread,52    staleness_ratio,53)5455N = 100_00056SEEDS = [1, 2, 3, 4, 5]575859# ----------------------------------------------------- 1. random walk: nothing60def test_random_walk_triggers_nothing():61    for seed in SEEDS:62        r = np.diff(random_walk(N, seed=seed))63        assert abs(ac1(r)) < 0.0264        assert abs(variance_ratio(r, 5) - 1.0) < 0.0565        assert abs(variance_ratio(r, 30) - 1.0) < 0.1266        assert half_life(random_walk(N, seed=seed)) > 5_000  # effectively none676869def test_random_walk_ac1_inside_its_bootstrap_ci():70    r = np.diff(random_walk(N, seed=7))71    boot = moving_block_bootstrap(r, ac1, block=390, n_boot=200, seed=7)72    lo, hi = percentile_ci(boot)73    assert lo < 0.0 < hi  # zero is inside the CI: no detection747576def test_independent_walks_show_no_leadlag():77    for seed in SEEDS:78        x = np.diff(random_walk(N, seed=seed))79        y = np.diff(random_walk(N, seed=seed + 100))80        xc = lagged_xcorr(x, y, 5)81        assert max(abs(v) for v in xc.values()) < 0.0282        assert abs(leadlag_asymmetry(xc)) < 0.05838485# ------------------------------------------- 2. planted reversion is recovered86def test_ou_reversion_recovered_with_half_life():87    kappa = 0.02  # true half-life = ln2 / -ln(0.98) ≈ 34.3 bars88    true_hl = np.log(2) / -np.log(1 - kappa)89    for seed in SEEDS:90        p = ou_prices(N, kappa=kappa, seed=seed)91        r = np.diff(p)92        assert ac1(r) < -0.00593        assert variance_ratio(r, 30) < 0.994        assert abs(half_life(p) - true_hl) / true_hl < 0.25959697# --------------------------------------------- 3. planted lead-lag is recovered98def test_planted_leadlag_recovered_at_correct_lag():99    for seed in SEEDS:100        x, y = leadlag_pair(N, beta=0.3, lag=2, seed=seed)101        xc = lagged_xcorr(x, y, 5)102        assert peak_lag(xc) == 2103        assert xc[2] > 0.2104        assert abs(xc[1]) < 0.02 and abs(xc[3]) < 0.02105106107# --------------------------------------- 4. planted calendar effect is recovered108def test_planted_seasonal_effect_recovered_on_right_phase():109    period, hot, amp = 5, 3, 0.0005110    for seed in SEEDS:111        r = seasonal_returns(N, period, hot, amp, seed=seed)112        phase_means = [r[np.arange(N) % period == k].mean() for k in range(period)]113        assert np.argmax(phase_means) == hot114        assert abs(phase_means[hot] - amp) < amp * 0.2115        rest = [m for k, m in enumerate(phase_means) if k != hot]116        assert max(abs(m) for m in rest) < amp * 0.2117118119# ------------------------------------- 5. pure bounce is an artifact, not a find120def test_roll_spread_recovers_planted_spread():121    spread = 0.002122    for seed in SEEDS:123        p = roll_bounce_prices(N, spread=spread, seed=seed)124        est = roll_spread(np.diff(p))125        assert abs(est - spread) / spread < 0.10126127128def test_pure_bounce_reversion_vanishes_after_artifact_adjustment():129    spread = 0.002130    for seed in SEEDS:131        p = roll_bounce_prices(N, spread=spread, seed=seed)132        r = np.diff(p)133        assert ac1(r) < -0.2  # naive detector screams "mean reversion!"134        # ...but the excess over the bounce null (true spread supplied) is ~0135        assert abs(excess_reversion(r, spread)) < 0.03136137138def test_true_reversion_survives_artifact_adjustment():139    # OU + bounce: after removing the bounce share, reversion must REMAIN140    kappa, spread = 0.05, 0.001141    for seed in SEEDS:142        mid = ou_prices(N, kappa=kappa, seed=seed)143        rng = np.random.default_rng(seed + 999)144        p = mid + (spread / 2.0) * rng.choice([-1.0, 1.0], size=N)145        r = np.diff(p)146        assert excess_reversion(r, spread) < -0.01147148149# ------------------------------------------------ 6. staleness manufactures lies150def test_locf_creates_spurious_positive_autocorrelation():151    for seed in SEEDS:152        p = random_walk(N, seed=seed)153        locf, mask = stale_observe(p, p_observe=0.3, seed=seed)154        r = np.diff(locf)155        assert ac1(np.diff(p)) < 0.02  # underlying: nothing156        # LOCF returns of a pure walk: AC1 pushed NEGATIVE at lag 1 grid steps157        # is not the failure mode; the artifact is CROSS-serial (next test) and158        # a big mass of zero returns. Document the zero-mass here:159        assert (r == 0).mean() > 0.5160        assert staleness_ratio(mask) > 0.6161162163def test_locf_makes_fresh_series_appear_to_lead_stale_one():164    for seed in SEEDS:165        px, py = correlated_pair(N, rho=0.7, seed=seed)166        # underlying returns: correlation only at lag 0167        xc_true = lagged_xcorr(np.diff(px), np.diff(py), 3)168        assert abs(xc_true[1]) < 0.02169        # y observed sparsely, LOCF-joined on the grid: x now "leads" y170        mask = np.random.default_rng(seed).random(N) < 0.3171        mask[0] = True172        y_locf = locf_fill(py, mask)173        xc = lagged_xcorr(np.diff(px), np.diff(y_locf), 3)174        assert xc[1] > 0.10  # spurious lead of the fresh series175        assert leadlag_asymmetry(xc) > 0.1176        assert peak_lag({k: v for k, v in xc.items() if k != 0}) == 1177