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 / 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