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%
11.3 KB · 274 lines python
Raw Blame History
1# =============================================================================2#  Project   : anomaly-atlas3#  File      : experiments/micro/expF_multiple_testing/benchmark.py4#  Purpose   : Survival battery: naive -> FDR -> RC/SPA -> DSR over C/D/E rules5#  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"""Experiment F — the survival curve (protocol pre-specified in hypothesis.md).1415Rules are built mechanically from EVERYTHING the C/D/E scans searched (both16signs), evaluated on the same TRAIN data (in-sample by design — OOS is expH),17and pushed through naive-t -> BH-FDR -> White RC / Hansen SPA -> DSR.18"""1920from __future__ import annotations2122import json23import sys24from collections import defaultdict25from datetime import UTC, datetime26from pathlib import Path2728import numpy as np29from scipy.stats import norm3031REPO_ROOT = Path(__file__).resolve().parents[3]32sys.path.insert(0, str(REPO_ROOT / "benchmarks"))33sys.path.insert(0, str(REPO_ROOT / "src"))3435from hardware_manifest import collect_manifest  # noqa: E4023637from anomaly_atlas.data.cleaning import RTH_SLOTS, rth_day_grids  # noqa: E40238from anomaly_atlas.data.hf_client import HFMarketDataClient  # noqa: E40239from anomaly_atlas.data.universe import TRAIN, TRAIN_SUBPERIODS, core_universe  # noqa: E40240from anomaly_atlas.stats.multiple_testing import benjamini_hochberg  # noqa: E40241from anomaly_atlas.stats.spa import deflated_sharpe, reality_check, spa_test  # noqa: E4024243ADJ = "adj_split"44N_BOOT, MEAN_BLOCK, SEED = 500, 5.0, 4245LIQUID = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "JPM", "XOM", "UNH", "QQQ"]46SECTORS = ["XLF", "XLE", "XLK", "XLV", "XLI", "XLY", "XLP", "XLU", "XLB"]47D_WINDOWS = {"2006-2007": ("2006-01-01", "2008-01-01"),48             "2014-2015": ("2014-01-01", "2016-01-01")}49ONE_MIN_WINDOW = ("2014-01-01", "2016-01-01")505152def contrarian_daily(bars: list[dict], timeframe: str) -> dict[str, float]:53    """day -> contrarian rule return at the cell's timeframe (trade-time RTH)."""54    by_day: dict[str, list[float]] = defaultdict(list)55    for b in bars:56        dt = b["datetime"]57        if timeframe == "1day":58            by_day[dt[:10]].append(np.log(b["close"]))59        elif "09:30" <= dt[11:16] < "16:00":60            by_day[dt[:10]].append(np.log(b["close"]))61    days = sorted(by_day)62    out: dict[str, float] = {}63    if timeframe == "1day":64        closes = np.array([by_day[d][0] for d in days])65        r = np.diff(closes)66        for i in range(1, len(r)):67            out[days[i + 1]] = float(-np.sign(r[i - 1]) * r[i])68        return out69    for d in days:70        p = np.array(by_day[d])71        if len(p) < 3:72            continue73        r = np.diff(p)74        out[d] = float(np.sum(-np.sign(r[:-1]) * r[1:]))75    return out767778def leadlag_daily(gx: dict, gy: dict, day_list: list[str]) -> dict[str, float]:79    """day -> sign(leader_{t-1}) * follower_t summed over both-fresh minutes."""80    out: dict[str, float] = {}81    for day in day_list:82        px, py = gx.get(day), gy.get(day)83        if px is None or py is None:84            continue85        ox, oy = np.isfinite(px), np.isfinite(py)86        fx = np.zeros(RTH_SLOTS - 1, dtype=bool)87        fy = np.zeros(RTH_SLOTS - 1, dtype=bool)88        rx = np.zeros(RTH_SLOTS - 1)89        ry = np.zeros(RTH_SLOTS - 1)90        fpx, fpy = px.copy(), py.copy()91        for t in range(1, RTH_SLOTS):92            if not ox[t]:93                fpx[t] = fpx[t - 1]94            if not oy[t]:95                fpy[t] = fpy[t - 1]96        rx[:] = np.diff(fpx)97        ry[:] = np.diff(fpy)98        fx[:] = ox[1:] & ox[:-1]99        fy[:] = oy[1:] & oy[:-1]100        keep = fx[:-1] & fy[1:] & np.isfinite(rx[:-1]) & np.isfinite(ry[1:])101        if keep.sum() < 30:102            continue103        out[day] = float(np.sum(np.sign(rx[:-1][keep]) * ry[1:][keep]))104    return out105106107def build_matrix(rules: dict[str, dict[str, float]]) -> tuple[np.ndarray, list[str], list[str]]:108    """(days x 2N signed rules) matrix; missing day = 0 (idle), as declared."""109    day_set = sorted({d for r in rules.values() for d in r})110    names, cols = [], []111    for name, series in rules.items():112        v = np.array([series.get(d, 0.0) for d in day_set])113        names += [f"+{name}", f"-{name}"]114        cols += [v, -v]115    return np.column_stack(cols), names, day_set116117118def battery(x: np.ndarray, names: list[str]) -> dict:119    t_len = x.shape[0]120    mu = x.mean(axis=0)121    sd = x.std(axis=0, ddof=1)122    sd = np.maximum(sd, 1e-12)123    t = np.sqrt(t_len) * mu / sd124    p_two = 2 * (1 - norm.cdf(np.abs(t)))125    rc = reality_check(x, n_boot=N_BOOT, mean_block=MEAN_BLOCK, seed=SEED)126    sp = spa_test(x, n_boot=N_BOOT, mean_block=MEAN_BLOCK, seed=SEED)127    srs = mu / sd128    best = int(np.argmax(srs))129    r = x[:, best]130    dsr = deflated_sharpe(131        sr=float(srs[best]), t_len=t_len,132        skew=float(((r - r.mean()) ** 3).mean() / r.std() ** 3),133        kurt=float(((r - r.mean()) ** 4).mean() / r.std() ** 4),134        n_trials=x.shape[1], sr_variance=float(srs.var(ddof=1)),135    )136    return {137        "days": int(t_len), "rules": len(names),138        "naive_t196": int((np.abs(t) > 1.96).sum()),139        "p_two_sided": p_two, "t": t,140        "rc_p": rc["p"], "spa_p": sp["p"],141        "spa_step1_survivors": [names[i] for i, tv in enumerate(sp["rule_t"])142                                if tv >= sp["t95"]],143        "best_rule": names[best], "best_daily_sharpe": round(float(srs[best]), 4),144        "best_annualized_sharpe": round(float(srs[best] * np.sqrt(252)), 3),145        "dsr_sr0": round(dsr["sr0"], 4), "dsr": round(dsr["dsr"], 4),146    }147148149def main() -> None:150    run_utc = datetime.now(UTC)151    client = HFMarketDataClient()152    universe = core_universe(client.tickers("stock", timeframe="1min", adjustment=ADJ))153154    blocks: dict[str, dict[str, dict[str, float]]] = defaultdict(dict)155156    # ---- R-family (expC universe)157    for asset, ticker, _ in universe:158        for sub, (s, e) in TRAIN_SUBPERIODS.items():159            day_bars = client.get_bars(asset, ticker, "1day", ADJ, s, e)160            if len(day_bars) < 200:161                continue162            for tf in ("1day", "30min", "5min"):163                bars = day_bars if tf == "1day" else client.get_bars(asset, ticker, tf, ADJ, s, e)164                series = contrarian_daily(bars, tf)165                if len(series) >= 150:166                    blocks[f"expC {sub}"][f"R:{ticker}:{tf}"] = series167        print(f"R {ticker} done")168    for asset, ticker in [("stock", t) for t in LIQUID if t != "QQQ"] + \169                         [("etf", t) for t in ("SPY", "QQQ")]:170        bars = client.get_bars(asset, ticker, "1min", ADJ, *ONE_MIN_WINDOW)171        series = contrarian_daily(bars, "1min")172        if len(series) >= 150:173            blocks["expC 1min 2014-2015"][f"R:{ticker}:1min"] = series174175    # ---- L-family (expD universe)176    random10 = sorted(np.random.default_rng(42).choice(177        sorted(client.tickers("stock", timeframe="1min", adjustment=ADJ)), 30,178        replace=False))[:10]179    for window, (s, e) in D_WINDOWS.items():180        spec = ({"SPY": ("etf", "SPY", ADJ)}181                | {t: ("stock", t, ADJ) for t in LIQUID if t != "QQQ"}182                | {"QQQ": ("etf", "QQQ", ADJ)}183                | {t: ("etf", t, ADJ) for t in SECTORS}184                | {t: ("stock", t, ADJ) for t in random10})185        if window == "2014-2015":186            spec |= {"SPX": ("index", "SPX", None),187                     "ES": ("futures", "ES", "contin_adj_ratio")}188        grids = {}189        for name, (asset, ticker, adj) in spec.items():190            g = rth_day_grids(client.get_bars(asset, ticker, "1min", adj, s, e))191            if len(g) >= 200:192                grids[name] = g193        day_list = sorted(grids["SPY"].keys())194        for name, g in grids.items():195            if name == "SPY":196                continue197            if name in ("SPX", "ES"):198                series = leadlag_daily(g, grids["SPY"], day_list)  # x leads SPY199                key = f"L:{name}->SPY"200            else:201                series = leadlag_daily(grids["SPY"], g, day_list)202                key = f"L:SPY->{name}"203            if len(series) >= 150:204                blocks[f"expD {window}"][key] = series205        print(f"L {window} done ({len(blocks[f'expD {window}'])} pairs)")206207    # ---- C-family (expE classes, drift-adjusted)208    bars = client.get_bars("etf", "SPY", "1day", "adj_splitdiv", TRAIN[0], TRAIN[1])209    dates = [b["datetime"][:10] for b in bars][1:]210    rets = np.diff(np.log([b["close"] for b in bars]))211    mu = rets.mean()212    sys.path.insert(0, str(REPO_ROOT / "experiments" / "micro" / "expE_calendar_scan"))213    from benchmark import class_masks  # noqa: E402214215    masks = class_masks(dates)216    for cname, m in masks.items():217        blocks["expE train"][f"C:{cname}"] = {218            d: float(rets[i] - mu) for i, d in enumerate(dates) if m[i]219        }220221    # ---- battery per block + global funnel222    per_block: dict[str, dict] = {}223    all_p, all_index = [], []224    for bname, rules in blocks.items():225        x, names, _ = build_matrix(rules)226        res = battery(x, names)227        all_p.extend(res.pop("p_two_sided").tolist())228        res.pop("t")229        all_index.extend([(bname, n) for n in names])230        per_block[bname] = res231        print(f"{bname}: rules={res['rules']} naive={res['naive_t196']} "232              f"rc_p={res['rc_p']} spa_p={res['spa_p']} dsr={res['dsr']}")233234    fdr_mask = benjamini_hochberg(np.array(all_p), alpha=0.05)235    total_rules = len(all_p)236    funnel = {237        "universe_rules": total_rules,238        "naive_t196": int(sum(per_block[b]["naive_t196"] for b in per_block)),239        "fdr_survivors": int(fdr_mask.sum()),240        "spa_step1_survivors": sorted({n for b in per_block241                                       for n in per_block[b]["spa_step1_survivors"]}),242        "blocks_spa_significant": [b for b in per_block if per_block[b]["spa_p"] < 0.05],243        "dsr_by_block": {b: per_block[b]["dsr"] for b in per_block},244    }245    funnel["survival_rate"] = {246        "naive": round(funnel["naive_t196"] / total_rules, 4),247        "fdr": round(funnel["fdr_survivors"] / total_rules, 4),248        "spa_step1": round(len(funnel["spa_step1_survivors"]) / total_rules, 4),249    }250251    results = {252        "experiment": "expF_multiple_testing",253        "run_utc": run_utc.isoformat(),254        "author": "Simon-Pierre Boucher",255        "contact": "contact@spboucher.ai",256        "data_source": "hfmarketdata.io",257        "confidence_level": 0,258        "protocol": {"n_boot": N_BOOT, "mean_block": MEAN_BLOCK, "seed": SEED,259                     "note": "in-sample search survival on TRAIN; OOS = expH"},260        "per_block": per_block,261        "funnel": funnel,262        "client_stats": vars(client.stats) | {"refreshes": list(client.stats.refreshes)},263        "manifest": collect_manifest(),264    }265    out_dir = REPO_ROOT / "results" / "expF_multiple_testing" / run_utc.strftime("%Y%m%dT%H%M%SZ")266    out_dir.mkdir(parents=True)267    (out_dir / "results.json").write_text(json.dumps(results, indent=2) + "\n")268    print(f"\nwrote {out_dir.relative_to(REPO_ROOT)}/results.json")269    print(json.dumps(funnel, indent=1))270271272if __name__ == "__main__":273    main()274