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 / experiments/micro/expF_multiple_testing/benchmark.py

experiments/micro/expF_multiple_testing/benchmark.py 274 lines
# =============================================================================
#  Project   : anomaly-atlas
#  File      : experiments/micro/expF_multiple_testing/benchmark.py
#  Purpose   : Survival battery: naive -> FDR -> RC/SPA -> DSR over C/D/E rules
#  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)
# =============================================================================
"""Experiment F — the survival curve (protocol pre-specified in hypothesis.md).

Rules are built mechanically from EVERYTHING the C/D/E scans searched (both
signs), evaluated on the same TRAIN data (in-sample by design — OOS is expH),
and pushed through naive-t -> BH-FDR -> White RC / Hansen SPA -> DSR.
"""

from __future__ import annotations

import json
import sys
from collections import defaultdict
from datetime import UTC, datetime
from pathlib import Path

import numpy as np
from scipy.stats import norm

REPO_ROOT = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(REPO_ROOT / "benchmarks"))
sys.path.insert(0, str(REPO_ROOT / "src"))

from hardware_manifest import collect_manifest  # noqa: E402

from anomaly_atlas.data.cleaning import RTH_SLOTS, rth_day_grids  # noqa: E402
from anomaly_atlas.data.hf_client import HFMarketDataClient  # noqa: E402
from anomaly_atlas.data.universe import TRAIN, TRAIN_SUBPERIODS, core_universe  # noqa: E402
from anomaly_atlas.stats.multiple_testing import benjamini_hochberg  # noqa: E402
from anomaly_atlas.stats.spa import deflated_sharpe, reality_check, spa_test  # noqa: E402

ADJ = "adj_split"
N_BOOT, MEAN_BLOCK, SEED = 500, 5.0, 42
LIQUID = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "JPM", "XOM", "UNH", "QQQ"]
SECTORS = ["XLF", "XLE", "XLK", "XLV", "XLI", "XLY", "XLP", "XLU", "XLB"]
D_WINDOWS = {"2006-2007": ("2006-01-01", "2008-01-01"),
             "2014-2015": ("2014-01-01", "2016-01-01")}
ONE_MIN_WINDOW = ("2014-01-01", "2016-01-01")


def contrarian_daily(bars: list[dict], timeframe: str) -> dict[str, float]:
    """day -> contrarian rule return at the cell's timeframe (trade-time RTH)."""
    by_day: dict[str, list[float]] = defaultdict(list)
    for b in bars:
        dt = b["datetime"]
        if timeframe == "1day":
            by_day[dt[:10]].append(np.log(b["close"]))
        elif "09:30" <= dt[11:16] < "16:00":
            by_day[dt[:10]].append(np.log(b["close"]))
    days = sorted(by_day)
    out: dict[str, float] = {}
    if timeframe == "1day":
        closes = np.array([by_day[d][0] for d in days])
        r = np.diff(closes)
        for i in range(1, len(r)):
            out[days[i + 1]] = float(-np.sign(r[i - 1]) * r[i])
        return out
    for d in days:
        p = np.array(by_day[d])
        if len(p) < 3:
            continue
        r = np.diff(p)
        out[d] = float(np.sum(-np.sign(r[:-1]) * r[1:]))
    return out


def leadlag_daily(gx: dict, gy: dict, day_list: list[str]) -> dict[str, float]:
    """day -> sign(leader_{t-1}) * follower_t summed over both-fresh minutes."""
    out: dict[str, float] = {}
    for day in day_list:
        px, py = gx.get(day), gy.get(day)
        if px is None or py is None:
            continue
        ox, oy = np.isfinite(px), np.isfinite(py)
        fx = np.zeros(RTH_SLOTS - 1, dtype=bool)
        fy = np.zeros(RTH_SLOTS - 1, dtype=bool)
        rx = np.zeros(RTH_SLOTS - 1)
        ry = np.zeros(RTH_SLOTS - 1)
        fpx, fpy = px.copy(), py.copy()
        for t in range(1, RTH_SLOTS):
            if not ox[t]:
                fpx[t] = fpx[t - 1]
            if not oy[t]:
                fpy[t] = fpy[t - 1]
        rx[:] = np.diff(fpx)
        ry[:] = np.diff(fpy)
        fx[:] = ox[1:] & ox[:-1]
        fy[:] = oy[1:] & oy[:-1]
        keep = fx[:-1] & fy[1:] & np.isfinite(rx[:-1]) & np.isfinite(ry[1:])
        if keep.sum() < 30:
            continue
        out[day] = float(np.sum(np.sign(rx[:-1][keep]) * ry[1:][keep]))
    return out


def build_matrix(rules: dict[str, dict[str, float]]) -> tuple[np.ndarray, list[str], list[str]]:
    """(days x 2N signed rules) matrix; missing day = 0 (idle), as declared."""
    day_set = sorted({d for r in rules.values() for d in r})
    names, cols = [], []
    for name, series in rules.items():
        v = np.array([series.get(d, 0.0) for d in day_set])
        names += [f"+{name}", f"-{name}"]
        cols += [v, -v]
    return np.column_stack(cols), names, day_set


def battery(x: np.ndarray, names: list[str]) -> dict:
    t_len = x.shape[0]
    mu = x.mean(axis=0)
    sd = x.std(axis=0, ddof=1)
    sd = np.maximum(sd, 1e-12)
    t = np.sqrt(t_len) * mu / sd
    p_two = 2 * (1 - norm.cdf(np.abs(t)))
    rc = reality_check(x, n_boot=N_BOOT, mean_block=MEAN_BLOCK, seed=SEED)
    sp = spa_test(x, n_boot=N_BOOT, mean_block=MEAN_BLOCK, seed=SEED)
    srs = mu / sd
    best = int(np.argmax(srs))
    r = x[:, best]
    dsr = deflated_sharpe(
        sr=float(srs[best]), t_len=t_len,
        skew=float(((r - r.mean()) ** 3).mean() / r.std() ** 3),
        kurt=float(((r - r.mean()) ** 4).mean() / r.std() ** 4),
        n_trials=x.shape[1], sr_variance=float(srs.var(ddof=1)),
    )
    return {
        "days": int(t_len), "rules": len(names),
        "naive_t196": int((np.abs(t) > 1.96).sum()),
        "p_two_sided": p_two, "t": t,
        "rc_p": rc["p"], "spa_p": sp["p"],
        "spa_step1_survivors": [names[i] for i, tv in enumerate(sp["rule_t"])
                                if tv >= sp["t95"]],
        "best_rule": names[best], "best_daily_sharpe": round(float(srs[best]), 4),
        "best_annualized_sharpe": round(float(srs[best] * np.sqrt(252)), 3),
        "dsr_sr0": round(dsr["sr0"], 4), "dsr": round(dsr["dsr"], 4),
    }


def main() -> None:
    run_utc = datetime.now(UTC)
    client = HFMarketDataClient()
    universe = core_universe(client.tickers("stock", timeframe="1min", adjustment=ADJ))

    blocks: dict[str, dict[str, dict[str, float]]] = defaultdict(dict)

    # ---- R-family (expC universe)
    for asset, ticker, _ in universe:
        for sub, (s, e) in TRAIN_SUBPERIODS.items():
            day_bars = client.get_bars(asset, ticker, "1day", ADJ, s, e)
            if len(day_bars) < 200:
                continue
            for tf in ("1day", "30min", "5min"):
                bars = day_bars if tf == "1day" else client.get_bars(asset, ticker, tf, ADJ, s, e)
                series = contrarian_daily(bars, tf)
                if len(series) >= 150:
                    blocks[f"expC {sub}"][f"R:{ticker}:{tf}"] = series
        print(f"R {ticker} done")
    for asset, ticker in [("stock", t) for t in LIQUID if t != "QQQ"] + \
                         [("etf", t) for t in ("SPY", "QQQ")]:
        bars = client.get_bars(asset, ticker, "1min", ADJ, *ONE_MIN_WINDOW)
        series = contrarian_daily(bars, "1min")
        if len(series) >= 150:
            blocks["expC 1min 2014-2015"][f"R:{ticker}:1min"] = series

    # ---- L-family (expD universe)
    random10 = sorted(np.random.default_rng(42).choice(
        sorted(client.tickers("stock", timeframe="1min", adjustment=ADJ)), 30,
        replace=False))[:10]
    for window, (s, e) in D_WINDOWS.items():
        spec = ({"SPY": ("etf", "SPY", ADJ)}
                | {t: ("stock", t, ADJ) for t in LIQUID if t != "QQQ"}
                | {"QQQ": ("etf", "QQQ", ADJ)}
                | {t: ("etf", t, ADJ) for t in SECTORS}
                | {t: ("stock", t, ADJ) for t in random10})
        if window == "2014-2015":
            spec |= {"SPX": ("index", "SPX", None),
                     "ES": ("futures", "ES", "contin_adj_ratio")}
        grids = {}
        for name, (asset, ticker, adj) in spec.items():
            g = rth_day_grids(client.get_bars(asset, ticker, "1min", adj, s, e))
            if len(g) >= 200:
                grids[name] = g
        day_list = sorted(grids["SPY"].keys())
        for name, g in grids.items():
            if name == "SPY":
                continue
            if name in ("SPX", "ES"):
                series = leadlag_daily(g, grids["SPY"], day_list)  # x leads SPY
                key = f"L:{name}->SPY"
            else:
                series = leadlag_daily(grids["SPY"], g, day_list)
                key = f"L:SPY->{name}"
            if len(series) >= 150:
                blocks[f"expD {window}"][key] = series
        print(f"L {window} done ({len(blocks[f'expD {window}'])} pairs)")

    # ---- C-family (expE classes, drift-adjusted)
    bars = client.get_bars("etf", "SPY", "1day", "adj_splitdiv", TRAIN[0], TRAIN[1])
    dates = [b["datetime"][:10] for b in bars][1:]
    rets = np.diff(np.log([b["close"] for b in bars]))
    mu = rets.mean()
    sys.path.insert(0, str(REPO_ROOT / "experiments" / "micro" / "expE_calendar_scan"))
    from benchmark import class_masks  # noqa: E402

    masks = class_masks(dates)
    for cname, m in masks.items():
        blocks["expE train"][f"C:{cname}"] = {
            d: float(rets[i] - mu) for i, d in enumerate(dates) if m[i]
        }

    # ---- battery per block + global funnel
    per_block: dict[str, dict] = {}
    all_p, all_index = [], []
    for bname, rules in blocks.items():
        x, names, _ = build_matrix(rules)
        res = battery(x, names)
        all_p.extend(res.pop("p_two_sided").tolist())
        res.pop("t")
        all_index.extend([(bname, n) for n in names])
        per_block[bname] = res
        print(f"{bname}: rules={res['rules']} naive={res['naive_t196']} "
              f"rc_p={res['rc_p']} spa_p={res['spa_p']} dsr={res['dsr']}")

    fdr_mask = benjamini_hochberg(np.array(all_p), alpha=0.05)
    total_rules = len(all_p)
    funnel = {
        "universe_rules": total_rules,
        "naive_t196": int(sum(per_block[b]["naive_t196"] for b in per_block)),
        "fdr_survivors": int(fdr_mask.sum()),
        "spa_step1_survivors": sorted({n for b in per_block
                                       for n in per_block[b]["spa_step1_survivors"]}),
        "blocks_spa_significant": [b for b in per_block if per_block[b]["spa_p"] < 0.05],
        "dsr_by_block": {b: per_block[b]["dsr"] for b in per_block},
    }
    funnel["survival_rate"] = {
        "naive": round(funnel["naive_t196"] / total_rules, 4),
        "fdr": round(funnel["fdr_survivors"] / total_rules, 4),
        "spa_step1": round(len(funnel["spa_step1_survivors"]) / total_rules, 4),
    }

    results = {
        "experiment": "expF_multiple_testing",
        "run_utc": run_utc.isoformat(),
        "author": "Simon-Pierre Boucher",
        "contact": "contact@spboucher.ai",
        "data_source": "hfmarketdata.io",
        "confidence_level": 0,
        "protocol": {"n_boot": N_BOOT, "mean_block": MEAN_BLOCK, "seed": SEED,
                     "note": "in-sample search survival on TRAIN; OOS = expH"},
        "per_block": per_block,
        "funnel": funnel,
        "client_stats": vars(client.stats) | {"refreshes": list(client.stats.refreshes)},
        "manifest": collect_manifest(),
    }
    out_dir = REPO_ROOT / "results" / "expF_multiple_testing" / run_utc.strftime("%Y%m%dT%H%M%SZ")
    out_dir.mkdir(parents=True)
    (out_dir / "results.json").write_text(json.dumps(results, indent=2) + "\n")
    print(f"\nwrote {out_dir.relative_to(REPO_ROOT)}/results.json")
    print(json.dumps(funnel, indent=1))


if __name__ == "__main__":
    main()