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