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_gate_expc.py

benchmarks/synthetic/test_gate_expc.py 108 lines
# =============================================================================
#  Project   : anomaly-atlas
#  File      : benchmarks/synthetic/test_gate_expc.py
#  Purpose   : §8.1 gate for the expC additions: EDGE spread, FDR, bootstrap p
#  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)
# =============================================================================
"""Gate the detectors added for expC before they touch real data:

  * EDGE spread from synthetic OHLC bars: recovers a planted Roll spread,
    reads ~0 on a spread-free random walk;
  * Benjamini-Hochberg: controls FDR on uniform nulls, finds planted signal;
  * bootstrap_pvalue: uniform-ish under the null, small under a real effect.
"""

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 random_walk, roll_bounce_prices  # noqa: E402

from anomaly_atlas.stats.bootstrap import moving_block_bootstrap
from anomaly_atlas.stats.multiple_testing import (
    benjamini_hochberg,
    bonferroni,
    bootstrap_pvalue,
)
from anomaly_atlas.stats.reversion import ac1
from anomaly_atlas.validation.artifacts import edge_spread

SEEDS = [1, 2, 3, 4, 5]


def bars_from_ticks(log_prices: np.ndarray, per_bar: int = 30):
    """Aggregate a synthetic tick path into OHLC bars (price space)."""
    n = (len(log_prices) // per_bar) * per_bar
    p = np.exp(log_prices[:n]).reshape(-1, per_bar)
    return p[:, 0], p.max(axis=1), p.min(axis=1), p[:, -1]


# ----------------------------------------------------------------- EDGE gate
def test_edge_recovers_planted_spread_from_bars():
    spread = 0.004
    for seed in SEEDS:
        ticks = roll_bounce_prices(120_000, spread=spread, sigma=0.0008, seed=seed)
        o, h, low, c = bars_from_ticks(ticks)
        est = edge_spread(o, h, low, c)
        assert np.isfinite(est)
        assert abs(est - spread) / spread < 0.30  # bar aggregation loses info


def test_edge_reads_near_zero_on_spreadless_walk():
    for seed in SEEDS:
        ticks = random_walk(120_000, sigma=0.0008, seed=seed)
        o, h, low, c = bars_from_ticks(ticks)
        est = edge_spread(o, h, low, c)
        # undefined (NaN) or tiny relative to the planted case
        assert (not np.isfinite(est)) or est < 0.001


# ------------------------------------------------------------------ FDR gate
def test_bh_controls_false_discoveries_on_pure_null():
    rng = np.random.default_rng(7)
    false_rates = []
    for _ in range(200):
        p = rng.random(100)  # all null
        false_rates.append(benjamini_hochberg(p, alpha=0.05).mean())
    assert np.mean(false_rates) < 0.05  # FDR controlled


def test_bh_finds_planted_signal_and_bonferroni_is_stricter():
    rng = np.random.default_rng(8)
    p = np.concatenate([rng.random(90), rng.random(10) * 1e-5])  # 10 real
    bh = benjamini_hochberg(p, alpha=0.05)
    bf = bonferroni(p, alpha=0.05)
    assert bh[90:].all()  # all planted found
    assert bh.sum() >= bf.sum()  # BH never stricter than Bonferroni
    assert bh[:90].sum() <= 5  # few false positives


def test_bh_counts_nan_toward_m():
    p = np.array([0.001, np.nan, np.nan, np.nan])
    # m=4: threshold for rank 1 is 0.05/4=0.0125 -> still rejected
    assert benjamini_hochberg(p, alpha=0.05)[0]
    assert not benjamini_hochberg(p, alpha=0.05)[1:].any()


# ------------------------------------------------------- bootstrap p-value gate
def test_bootstrap_pvalue_calibration():
    # null: AC1 of a random walk -> p should be comfortably non-small
    r = np.diff(random_walk(60_000, seed=3))
    boot = moving_block_bootstrap(r, ac1, block=390, n_boot=300, seed=3)
    assert bootstrap_pvalue(boot, 0.0) > 0.05
    # real effect: AC1 of a bounce series -> tiny p
    rb = np.diff(roll_bounce_prices(60_000, spread=0.003, seed=3))
    boot_b = moving_block_bootstrap(rb, ac1, block=390, n_boot=300, seed=3)
    assert bootstrap_pvalue(boot_b, 0.0) < 0.01