expF: survival battery — statistical correction is not artifact correction
- 372 signed rules from the full C/D/E searched space; funnel: 232 naive (62%) -> 226 FDR (61%) -> 68 SPA step-1 (18%), all GROSS - survivors carry absurd Sharpes (10-31 ann.): bounce harvesting + frictionless timing; the KNOWN SPX->SPY artifact passes SPA (canary) — artifact nulls, search correction and costs are all three required - expE calendar block: zero survivors (pipeline tripwire did not fire) - 12/14 artifact-adjusted expC triage cells + ES->SPY form the double-filtered pool for expG - new stats/spa.py (Politis-Romano bootstrap, White RC, Hansen SPA, StepM step-1, DSR) with 4-gate synthetic validation; funnel figure - 0 network requests: entire battery served by the frozen cache Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Showing 9 changed files with +987 and −27
added
benchmarks/synthetic/test_gate_expf.py
+92 −0
@@ -0,0 +1,92 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Project : anomaly-atlas | |
| 3 | +# File : benchmarks/synthetic/test_gate_expf.py | |
| 4 | +# Purpose : §8.1 gate for expF: Reality Check, SPA, Deflated Sharpe | |
| 5 | +# Author : Simon-Pierre Boucher | |
| 6 | +# Contact : contact@spboucher.ai | |
| 7 | +# Data src : hfmarketdata.io (sole data source) | |
| 8 | +# Created : 2026-08-12 | |
| 9 | +# Modified : 2026-08-12 | |
| 10 | +# Platform : macOS / Apple Silicon (arm64) | |
| 11 | +# License : All rights reserved (research code) | |
| 12 | +# ============================================================================= | |
| 13 | +"""Gate the correction battery before it touches real scan output: | |
| 14 | + | |
| 15 | + * a universe of PURE-NOISE rules must not survive RC/SPA (p not small), | |
| 16 | + and its best Sharpe must be fully explained by selection (DSR ~ 0); | |
| 17 | + * a planted genuinely profitable rule must survive all three; | |
| 18 | + * the bootstrap must respect serial dependence (block structure). | |
| 19 | +""" | |
| 20 | + | |
| 21 | +from __future__ import annotations | |
| 22 | + | |
| 23 | +import numpy as np | |
| 24 | + | |
| 25 | +from anomaly_atlas.stats.spa import deflated_sharpe, reality_check, spa_test | |
| 26 | + | |
| 27 | +T, N = 1500, 60 # ~6 years of days, 60 searched rules | |
| 28 | + | |
| 29 | + | |
| 30 | +def noise_matrix(seed: int) -> np.ndarray: | |
| 31 | + rng = np.random.default_rng(seed) | |
| 32 | + return rng.normal(0.0, 0.01, (T, N)) | |
| 33 | + | |
| 34 | + | |
| 35 | +def test_pure_noise_universe_does_not_survive(): | |
| 36 | + ps_rc, ps_spa = [], [] | |
| 37 | + for seed in (1, 2, 3, 4, 5): | |
| 38 | + x = noise_matrix(seed) | |
| 39 | + ps_rc.append(reality_check(x, n_boot=300, seed=seed)["p"]) | |
| 40 | + ps_spa.append(spa_test(x, n_boot=300, seed=seed)["p"]) | |
| 41 | + # under H0 the p-values should look uniform: none should be tiny, | |
| 42 | + # and on average they must be comfortably away from 0 | |
| 43 | + assert min(ps_rc) > 0.01 and np.mean(ps_rc) > 0.2 | |
| 44 | + assert min(ps_spa) > 0.01 and np.mean(ps_spa) > 0.2 | |
| 45 | + | |
| 46 | + | |
| 47 | +def test_planted_profitable_rule_survives_rc_and_spa(): | |
| 48 | + for seed in (1, 2, 3): | |
| 49 | + x = noise_matrix(seed) | |
| 50 | + rng = np.random.default_rng(seed + 100) | |
| 51 | + x[:, 7] = rng.normal(0.0015, 0.01, T) # daily SR ~ 0.15 (t ~ 5.8) | |
| 52 | + rc = reality_check(x, n_boot=300, seed=seed) | |
| 53 | + sp = spa_test(x, n_boot=300, seed=seed) | |
| 54 | + assert rc["p"] < 0.02 and rc["best_rule"] == 7 | |
| 55 | + assert sp["p"] < 0.02 and sp["best_rule"] == 7 | |
| 56 | + | |
| 57 | + | |
| 58 | +def test_dsr_kills_noise_best_and_keeps_planted(): | |
| 59 | + rng = np.random.default_rng(9) | |
| 60 | + x = noise_matrix(9) | |
| 61 | + srs = x.mean(axis=0) / x.std(axis=0, ddof=1) | |
| 62 | + best = int(np.argmax(srs)) | |
| 63 | + r = x[:, best] | |
| 64 | + d_noise = deflated_sharpe( | |
| 65 | + sr=float(srs[best]), t_len=T, | |
| 66 | + skew=float(((r - r.mean()) ** 3).mean() / r.std() ** 3), | |
| 67 | + kurt=float(((r - r.mean()) ** 4).mean() / r.std() ** 4), | |
| 68 | + n_trials=N, sr_variance=float(srs.var(ddof=1)), | |
| 69 | + ) | |
| 70 | + assert d_noise["dsr"] < 0.6 # selection explains the best noise Sharpe | |
| 71 | + # planted strong rule | |
| 72 | + x[:, 3] = rng.normal(0.002, 0.01, T) | |
| 73 | + srs2 = x.mean(axis=0) / x.std(axis=0, ddof=1) | |
| 74 | + r2 = x[:, 3] | |
| 75 | + d_real = deflated_sharpe( | |
| 76 | + sr=float(srs2[3]), t_len=T, | |
| 77 | + skew=float(((r2 - r2.mean()) ** 3).mean() / r2.std() ** 3), | |
| 78 | + kurt=float(((r2 - r2.mean()) ** 4).mean() / r2.std() ** 4), | |
| 79 | + n_trials=N, sr_variance=float(srs2.var(ddof=1)), | |
| 80 | + ) | |
| 81 | + assert d_real["dsr"] > 0.95 | |
| 82 | + assert d_noise["dsr"] < d_real["dsr"] | |
| 83 | + | |
| 84 | + | |
| 85 | +def test_stationary_bootstrap_preserves_dependence(): | |
| 86 | + from anomaly_atlas.stats.spa import stationary_bootstrap_indices | |
| 87 | + | |
| 88 | + idx = stationary_bootstrap_indices(1000, mean_block=20, n_boot=50, seed=3) | |
| 89 | + # consecutive indices should usually be consecutive (inside a block) | |
| 90 | + consecutive = np.mean((np.diff(idx, axis=1) % 1000) == 1) | |
| 91 | + assert consecutive > 0.9 # mean block 20 -> ~95% of steps are within-block | |
| 92 | + assert idx.min() >= 0 and idx.max() < 1000 | |
modified
experiments/micro/expF_multiple_testing/README.md
+1 −1
@@ -12,4 +12,4 @@ status: draft | ||
| 12 | 12 | |
| 13 | 13 | Multiple-testing survival: White RC, SPA, FDR, deflated Sharpe on C-E output |
| 14 | 14 | |
| 15 | −Status: scaffolded 2026-08-12, not yet run. | |
| 15 | +Status: **completed 2026-08-12** — survival curve measured: 372 -> 232 (naive) -> 226 (FDR) -> 68 (SPA, 18%), all gross; survivors dominated by bounce; the known SPX artifact survives SPA (statistical correction != artifact correction). Double-filtered pool -> expG. | |
modified
experiments/micro/expF_multiple_testing/analysis.md
+51 −2
@@ -5,9 +5,58 @@ author: Simon-Pierre Boucher | ||
| 5 | 5 | contact: contact@spboucher.ai |
| 6 | 6 | data_source: hfmarketdata.io |
| 7 | 7 | created: 2026-08-12 |
| 8 | −status: draft | |
| 8 | +modified: 2026-08-12 | |
| 9 | +status: reviewed | |
| 9 | 10 | --- |
| 10 | 11 | |
| 11 | 12 | # Analysis — expF_multiple_testing |
| 12 | 13 | |
| 13 | −*To be written after results exist. Must include the seven-field block and the evidence standard of CLAUDE.md §10 (never report an in-sample number as a finding).* | |
| 14 | +Run: `results/expF_multiple_testing/20260812T072749Z/results.json`. | |
| 15 | +Battery: 372 signed rules (every cell/pair/class the C/D/E scans searched), | |
| 16 | +6 blocks, White RC + Hansen SPA (500 stationary bootstraps) + BH-FDR + DSR. | |
| 17 | +RC/SPA/DSR §8.1-gated first. **0 network requests** — the entire experiment | |
| 18 | +ran from the frozen cache (the reproducibility anchor doing its job). | |
| 19 | + | |
| 20 | +## The survival curve (charter result-type E) — gross, frictionless | |
| 21 | + | |
| 22 | +| layer | survivors | rate | | |
| 23 | +|---|---|---| | |
| 24 | +| universe (searched) | 372 | 100 % | | |
| 25 | +| naive \|t\| > 1.96 | 232 | 62 % | | |
| 26 | +| BH-FDR 5 % | 226 | 61 % | | |
| 27 | +| Hansen SPA step-1 | 68 | 18 % | | |
| 28 | + | |
| 29 | +## The headline is methodological | |
| 30 | + | |
| 31 | +The SPA survivors carry annualized Sharpes of 10–31 — **physically absurd**, | |
| 32 | +which is the charter-§12 red flag, and the diagnosis is clean: | |
| 33 | + | |
| 34 | +1. **Statistical correction corrects for search, not for mechanism.** A | |
| 35 | + contrarian rule mechanically earns −autocov₁ > 0 on paper wherever | |
| 36 | + bid-ask bounce exists — frictionless, that is "profit"; in reality it is | |
| 37 | + the spread you would pay. SPA has no way to know that. | |
| 38 | +2. **The canary proves it**: −L:SPX→SPY — the index-staleness artifact we | |
| 39 | + *know* is fake (T3, measured twice) — survives SPA comfortably. | |
| 40 | +3. The expE calendar block survives nothing anywhere (naive 0, SPA p 0.87): | |
| 41 | + the pre-registered pipeline-broken tripwire did not fire. | |
| 42 | + | |
| 43 | +Honest validation therefore requires ALL THREE independent layers — artifact | |
| 44 | +nulls (the scans), search correction (this experiment), and costs (expG) — | |
| 45 | +and no one of them substitutes for another. This goes into methodology.md | |
| 46 | +as a design axiom, with this experiment as the demonstration. | |
| 47 | + | |
| 48 | +## The double-filtered pool (artifact-adjusted ∩ search-corrected, gross) | |
| 49 | + | |
| 50 | +12 of the 14 expC artifact-adjusted triage cells also clear SPA: | |
| 51 | +AAPL/ATRO/BKE/HTD/ICUI/MSFT/SLF/XOM 5min, AXDX 30min, CKX 1day, JPM/NVDA | |
| 52 | +1min. Lead-lag: **−L:ES→SPY** (splice-invariant basis reversion) plus the | |
| 53 | +expD 2014-2015 FDR residuals; −L:SPX→SPY is excluded from the tradable pool | |
| 54 | +(routed to candidate 03 as the artifact demonstration). This pool — and | |
| 55 | +nothing else — proceeds to expG. | |
| 56 | + | |
| 57 | +## Caveats | |
| 58 | + | |
| 59 | +In-sample by design (rules evaluated on the data that surfaced them; | |
| 60 | +validation split untouched until expH). DSR values are reported per block | |
| 61 | +but the bounce-driven Sharpe heterogeneity inflates `sr0`; treat them as | |
| 62 | +universe diagnostics, not verdicts. | |
modified
experiments/micro/expF_multiple_testing/benchmark.py
+248 −10
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | # ============================================================================= |
| 2 | 2 | # Project : anomaly-atlas |
| 3 | 3 | # File : experiments/micro/expF_multiple_testing/benchmark.py |
| 4 | −# Purpose : Benchmark runner: Multiple-testing survival: White RC, SPA, FDR, deflated Sharpe o… | |
| 4 | +# Purpose : Survival battery: naive -> FDR -> RC/SPA -> DSR over C/D/E rules | |
| 5 | 5 | # Author : Simon-Pierre Boucher |
| 6 | 6 | # Contact : contact@spboucher.ai |
| 7 | 7 | # Data src : hfmarketdata.io (sole data source) |
@@ -10,25 +10,263 @@ | ||
| 10 | 10 | # Platform : macOS / Apple Silicon (arm64) |
| 11 | 11 | # License : All rights reserved (research code) |
| 12 | 12 | # ============================================================================= |
| 13 | +"""Experiment F — the survival curve (protocol pre-specified in hypothesis.md). | |
| 13 | 14 | |
| 14 | −"""Benchmark entry point for expF_multiple_testing. | |
| 15 | − | |
| 16 | −Must embed the hardware manifest in all result output | |
| 17 | −(see benchmarks/hardware_manifest.py) and write results to | |
| 18 | −results/expF_multiple_testing/<timestamp>/. Uses hfmarketdata.io data ONLY, exclusively | |
| 19 | −through src/anomaly_atlas/data/hf_client.py. | |
| 15 | +Rules are built mechanically from EVERYTHING the C/D/E scans searched (both | |
| 16 | +signs), evaluated on the same TRAIN data (in-sample by design — OOS is expH), | |
| 17 | +and pushed through naive-t -> BH-FDR -> White RC / Hansen SPA -> DSR. | |
| 20 | 18 | """ |
| 21 | 19 | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +import json | |
| 22 | 23 | import sys |
| 24 | +from collections import defaultdict | |
| 25 | +from datetime import UTC, datetime | |
| 23 | 26 | from pathlib import Path |
| 24 | 27 | |
| 25 | −sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "benchmarks")) | |
| 28 | +import numpy as np | |
| 29 | +from scipy.stats import norm | |
| 30 | + | |
| 31 | +REPO_ROOT = Path(__file__).resolve().parents[3] | |
| 32 | +sys.path.insert(0, str(REPO_ROOT / "benchmarks")) | |
| 33 | +sys.path.insert(0, str(REPO_ROOT / "src")) | |
| 34 | + | |
| 26 | 35 | from hardware_manifest import collect_manifest # noqa: E402 |
| 27 | 36 | |
| 37 | +from anomaly_atlas.data.cleaning import RTH_SLOTS, rth_day_grids # noqa: E402 | |
| 38 | +from anomaly_atlas.data.hf_client import HFMarketDataClient # noqa: E402 | |
| 39 | +from anomaly_atlas.data.universe import TRAIN, TRAIN_SUBPERIODS, core_universe # noqa: E402 | |
| 40 | +from anomaly_atlas.stats.multiple_testing import benjamini_hochberg # noqa: E402 | |
| 41 | +from anomaly_atlas.stats.spa import deflated_sharpe, reality_check, spa_test # noqa: E402 | |
| 42 | + | |
| 43 | +ADJ = "adj_split" | |
| 44 | +N_BOOT, MEAN_BLOCK, SEED = 500, 5.0, 42 | |
| 45 | +LIQUID = ["AAPL", "MSFT", "NVDA", "AMZN", "GOOGL", "META", "TSLA", "JPM", "XOM", "UNH", "QQQ"] | |
| 46 | +SECTORS = ["XLF", "XLE", "XLK", "XLV", "XLI", "XLY", "XLP", "XLU", "XLB"] | |
| 47 | +D_WINDOWS = {"2006-2007": ("2006-01-01", "2008-01-01"), | |
| 48 | + "2014-2015": ("2014-01-01", "2016-01-01")} | |
| 49 | +ONE_MIN_WINDOW = ("2014-01-01", "2016-01-01") | |
| 50 | + | |
| 51 | + | |
| 52 | +def 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 out | |
| 69 | + for d in days: | |
| 70 | + p = np.array(by_day[d]) | |
| 71 | + if len(p) < 3: | |
| 72 | + continue | |
| 73 | + r = np.diff(p) | |
| 74 | + out[d] = float(np.sum(-np.sign(r[:-1]) * r[1:])) | |
| 75 | + return out | |
| 76 | + | |
| 77 | + | |
| 78 | +def 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 | + continue | |
| 85 | + 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 | + continue | |
| 103 | + out[day] = float(np.sum(np.sign(rx[:-1][keep]) * ry[1:][keep])) | |
| 104 | + return out | |
| 105 | + | |
| 106 | + | |
| 107 | +def 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_set | |
| 116 | + | |
| 117 | + | |
| 118 | +def 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 / sd | |
| 124 | + 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 / sd | |
| 128 | + 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 | + } | |
| 147 | + | |
| 28 | 148 | |
| 29 | 149 | def main() -> None: |
| 30 | − collect_manifest() # embedded in results once implemented | |
| 31 | − raise NotImplementedError("experiment not yet implemented") | |
| 150 | + run_utc = datetime.now(UTC) | |
| 151 | + client = HFMarketDataClient() | |
| 152 | + universe = core_universe(client.tickers("stock", timeframe="1min", adjustment=ADJ)) | |
| 153 | + | |
| 154 | + blocks: dict[str, dict[str, dict[str, float]]] = defaultdict(dict) | |
| 155 | + | |
| 156 | + # ---- 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 | + continue | |
| 162 | + 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}"] = series | |
| 167 | + 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"] = series | |
| 174 | + | |
| 175 | + # ---- 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] = g | |
| 193 | + day_list = sorted(grids["SPY"].keys()) | |
| 194 | + for name, g in grids.items(): | |
| 195 | + if name == "SPY": | |
| 196 | + continue | |
| 197 | + if name in ("SPX", "ES"): | |
| 198 | + series = leadlag_daily(g, grids["SPY"], day_list) # x leads SPY | |
| 199 | + 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] = series | |
| 205 | + print(f"L {window} done ({len(blocks[f'expD {window}'])} pairs)") | |
| 206 | + | |
| 207 | + # ---- 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: E402 | |
| 214 | + | |
| 215 | + 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 | + } | |
| 220 | + | |
| 221 | + # ---- battery per block + global funnel | |
| 222 | + 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] = res | |
| 231 | + 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']}") | |
| 233 | + | |
| 234 | + 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_block | |
| 241 | + 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 | + } | |
| 250 | + | |
| 251 | + 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)) | |
| 32 | 270 | |
| 33 | 271 | |
| 34 | 272 | if __name__ == "__main__": |
modified
experiments/micro/expF_multiple_testing/hypothesis.md
+66 −13
@@ -5,34 +5,87 @@ author: Simon-Pierre Boucher | ||
| 5 | 5 | contact: contact@spboucher.ai |
| 6 | 6 | data_source: hfmarketdata.io |
| 7 | 7 | created: 2026-08-12 |
| 8 | −status: draft | |
| 8 | +modified: 2026-08-12 | |
| 9 | +status: final | |
| 9 | 10 | --- |
| 10 | 11 | |
| 11 | 12 | # Hypothesis — expF_multiple_testing |
| 12 | 13 | |
| 14 | +*Pre-specified 2026-08-12 before the battery ran. RC/SPA/DSR passed the | |
| 15 | +§8.1 gate first (4 tests: pure noise never survives; a planted profitable | |
| 16 | +rule always does).* | |
| 17 | + | |
| 13 | 18 | ```text |
| 14 | 19 | Hypothesis |
| 15 | − <what we believe and why — pre-specified BEFORE looking at results> | |
| 20 | + The survival curve (charter result-type E): after honest correction for | |
| 21 | + the FULL searched universe, few or none of the C/D scan leads survive. | |
| 22 | + Prior: the daily 2008-2015 reversal family and possibly ES->SPY have the | |
| 23 | + best odds; the tiny 2014-2015 lead-lag residuals and most intraday | |
| 24 | + reversion cells should die. | |
| 16 | 25 | |
| 17 | 26 | Falsification criterion |
| 18 | − <the concrete measurable outcome that would prove this wrong> | |
| 27 | + Not falsifiable as a directional claim — the DELIVERABLE is the measured | |
| 28 | + survival rate at each layer. The pipeline is broken (investigate, not | |
| 29 | + publish) if a rule from the expE calendar family survives SPA (expE | |
| 30 | + already showed all 8 inside the permutation band). | |
| 19 | 31 | |
| 20 | 32 | Artifact null(s) |
| 21 | − <the fake-signal baseline(s) this must beat: bounce / staleness / | |
| 22 | − non-synchronous timestamps / permuted calendar / random walk> | |
| 33 | + The searched-universe null itself: RC/SPA bootstrap under H0 "no rule | |
| 34 | + beats zero", universe = EVERYTHING the scans looked at (not only the | |
| 35 | + FDR survivors) — 2 signed variants of every cell/pair/class. | |
| 23 | 36 | |
| 24 | −Method | |
| 25 | − <exact procedure, universe, split (train/validation/holdout), seeds, | |
| 26 | − number of hypotheses tested, correction applied> | |
| 37 | +Method (pre-declared) | |
| 38 | + Rule construction (mechanical, no tuning): | |
| 39 | + R-family (expC, 127 cells x2 signs): contrarian rule at the cell's | |
| 40 | + timeframe on RTH trade-time returns, pos_t = -sign(r_{t-1}); | |
| 41 | + 1day cells use the previous daily return. Daily aggregation; days | |
| 42 | + without data = 0 (idle). | |
| 43 | + L-family (expD, 49 pairs x2): follower timed by leader's previous | |
| 44 | + 1min return on both-fresh minutes, daily aggregation. | |
| 45 | + C-family (expE, 8 classes x2): +/-(r_t - unconditional mean) on class | |
| 46 | + days, 0 elsewhere (drift-adjusted so "long Mondays" cannot free-ride | |
| 47 | + the equity premium). | |
| 48 | + Blocks (common day calendars): expC 2000-2007, expC 2008-2015, expC 1min | |
| 49 | + 2014-2015, expD 2006-2007, expD 2014-2015, expE train. | |
| 50 | + Battery per block: (1) naive |t|>1.96 count; (2) BH-FDR on two-sided | |
| 51 | + rule p-values across ALL blocks jointly; (3) Hansen SPA (500 stationary | |
| 52 | + bootstraps, mean block 5 days, seed 42) + StepM-style step-1 survivor | |
| 53 | + count (rule t >= bootstrap max-stat 95th pct); (4) DSR of each block's | |
| 54 | + best rule, n_trials = total universe size, sr_variance across the | |
| 55 | + universe. White RC reported alongside SPA. | |
| 56 | + IMPORTANT honesty note: rules are evaluated on the SAME train data the | |
| 57 | + scans ran on — expF measures survival of the in-sample search under | |
| 58 | + correction. Out-of-sample survival is expH's job on the validation split. | |
| 27 | 59 | |
| 28 | 60 | Result |
| 29 | − <filled after the run: effect size, bootstrap CIs, corrected p-values, | |
| 30 | − OOS status, cost-adjusted effect, credits used> | |
| 61 | + Run 20260812T072749Z — 0 network requests (582 cache hits; the frozen | |
| 62 | + cache carried the whole battery). Universe: 372 signed rules, 6 blocks. | |
| 63 | + Survival funnel (GROSS, frictionless): naive |t|>1.96 = 232 (62%) -> | |
| 64 | + BH-FDR = 226 (61%) -> SPA step-1 = 68 (18%). All expC/expD blocks reject | |
| 65 | + at the bootstrap floor (RC and SPA p = 0.002); the expE calendar block | |
| 66 | + survives nothing (naive 0, spa_p 0.87) — the pipeline-broken tripwire did | |
| 67 | + NOT fire. Best rules carry annualized Sharpe 10-31 — physically absurd, | |
| 68 | + the §12 red flag. Intersection with the artifact-adjusted expC triage: | |
| 69 | + 12/14 cells also pass SPA. L-family step-1 includes -L:ES->SPY (the | |
| 70 | + splice-invariant basis effect) and -L:SPX->SPY (the KNOWN index-staleness | |
| 71 | + artifact, deliberately kept in the universe as a canary — it survives | |
| 72 | + statistical correction, which proves the point below). | |
| 31 | 73 | |
| 32 | 74 | Interpretation |
| 33 | − <what the numbers mean, WITH confidence level (0-3); alternative | |
| 34 | − explanations considered — artifact first> | |
| 75 | + (Level 0.) The survival curve's headline is METHODOLOGICAL and it is the | |
| 76 | + strongest result of the project so far: statistical correction corrects | |
| 77 | + for SEARCH, not for MECHANISM. 18% of gross rules survive Hansen SPA — | |
| 78 | + and the survivors are dominated by bounce harvesting (a contrarian rule | |
| 79 | + earns -autocov1 > 0 on paper and pays the spread in reality) plus | |
| 80 | + frictionless lead-lag timing; the known artifact (SPX->SPY) sails through | |
| 81 | + SPA unharmed. Honest validation therefore REQUIRES all three layers: | |
| 82 | + artifact nulls (scans) AND search correction (expF) AND costs (expG). | |
| 83 | + The double-filtered pool going to expG: 12 reversion cells + ES->SPY + | |
| 84 | + the expD 2014-2015 FDR set. DSR by block is reported but is mostly a | |
| 85 | + universe-heterogeneity diagnostic here (bounce-inflated Sharpe variance); | |
| 86 | + documented, not over-read. | |
| 35 | 87 | |
| 36 | 88 | Next experiment |
| 37 | − <the most informative follow-up given this result> | |
| 89 | + expG (cost frontier) on the double-filtered pool; expH (validation split) | |
| 90 | + for whatever survives costs. | |
| 38 | 91 | ``` |
modified
research/LOG.md
+24 −0
@@ -199,3 +199,27 @@ negative finding. | ||
| 199 | 199 | |
| 200 | 200 | **Decision.** Nothing from expE enters the expF pool. Next: expF correction |
| 201 | 201 | battery over the C/D triage output, with the declared 22-hypothesis budget. |
| 202 | + | |
| 203 | +## 2026-08-12 08:40 ET — expF complete: the survival curve, and its methodological headline | |
| 204 | + | |
| 205 | +**Question.** What fraction of the searched universe survives honest | |
| 206 | +search correction (charter result-type E)? | |
| 207 | + | |
| 208 | +**Experiment.** expF — 372 signed rules from EVERYTHING C/D/E searched, | |
| 209 | +6 blocks, White RC + Hansen SPA (500 stationary bootstraps, gate-tested) | |
| 210 | ++ BH-FDR + DSR. 0 network requests — fully served by the frozen cache. | |
| 211 | + | |
| 212 | +**Result.** Gross funnel: 372 -> 232 naive (62%) -> 226 FDR (61%) -> 68 SPA | |
| 213 | +step-1 (18%). Best "Sharpes" 10-31 annualized = the §12 red flag. The known | |
| 214 | +SPX->SPY artifact SURVIVES SPA (canary). expE calendar block: nothing | |
| 215 | +survives (tripwire did not fire). Intersection with expC's | |
| 216 | +artifact-adjusted triage: 12/14 cells; plus -L:ES->SPY. | |
| 217 | + | |
| 218 | +**Interpretation.** Statistical correction corrects for SEARCH, not | |
| 219 | +MECHANISM: SPA survivors are dominated by bounce harvesting and | |
| 220 | +frictionless timing. Honest validation needs artifact nulls AND search | |
| 221 | +correction AND costs — no substitutions. Added to methodology as an axiom | |
| 222 | +with this experiment as the demonstration. | |
| 223 | + | |
| 224 | +**Decision.** Double-filtered pool (12 reversion cells + ES->SPY + expD | |
| 225 | +2014-15 FDR set) -> expG cost frontier. SPX->SPY routed to candidate 03. | |
added
results/expF_multiple_testing/20260812T065907Z/results.json
+338 −0
@@ -0,0 +1,338 @@ | ||
| 1 | +{ | |
| 2 | + "experiment": "expF_multiple_testing", | |
| 3 | + "run_utc": "2026-08-12T06:59:07.261223+00:00", | |
| 4 | + "author": "Simon-Pierre Boucher", | |
| 5 | + "contact": "contact@spboucher.ai", | |
| 6 | + "data_source": "hfmarketdata.io", | |
| 7 | + "confidence_level": 0, | |
| 8 | + "protocol": { | |
| 9 | + "n_boot": 500, | |
| 10 | + "mean_block": 5.0, | |
| 11 | + "seed": 42, | |
| 12 | + "note": "in-sample search survival on TRAIN; OOS = expH" | |
| 13 | + }, | |
| 14 | + "per_block": { | |
| 15 | + "expC 2000-2007": { | |
| 16 | + "days": 2011, | |
| 17 | + "rules": 102, | |
| 18 | + "naive_t196": 58, | |
| 19 | + "rc_p": 0.001996007984031936, | |
| 20 | + "spa_p": 0.001996007984031936, | |
| 21 | + "spa_step1_survivors": [ | |
| 22 | + "+R:AAPL:5min", | |
| 23 | + "+R:MSFT:5min", | |
| 24 | + "-R:NVDA:30min", | |
| 25 | + "+R:NVDA:5min", | |
| 26 | + "-R:AMZN:30min", | |
| 27 | + "+R:JPM:5min", | |
| 28 | + "+R:XOM:1day", | |
| 29 | + "+R:XOM:5min", | |
| 30 | + "-R:UNH:5min", | |
| 31 | + "+R:SPY:5min", | |
| 32 | + "+R:QQQ:5min", | |
| 33 | + "+R:ATRO:30min", | |
| 34 | + "+R:ATRO:5min", | |
| 35 | + "+R:AXDX:30min", | |
| 36 | + "+R:HTD:30min", | |
| 37 | + "+R:HTD:5min", | |
| 38 | + "+R:ICUI:30min", | |
| 39 | + "+R:ICUI:5min", | |
| 40 | + "+R:RPM:30min", | |
| 41 | + "+R:RPM:5min", | |
| 42 | + "+R:SLF:30min", | |
| 43 | + "+R:SLF:5min" | |
| 44 | + ], | |
| 45 | + "best_rule": "+R:HTD:5min", | |
| 46 | + "best_daily_sharpe": 0.6602, | |
| 47 | + "best_annualized_sharpe": 10.48, | |
| 48 | + "dsr_sr0": 0.4949, | |
| 49 | + "dsr": 1.0 | |
| 50 | + }, | |
| 51 | + "expC 2008-2015": { | |
| 52 | + "days": 2015, | |
| 53 | + "rules": 138, | |
| 54 | + "naive_t196": 68, | |
| 55 | + "rc_p": 0.001996007984031936, | |
| 56 | + "spa_p": 0.001996007984031936, | |
| 57 | + "spa_step1_survivors": [ | |
| 58 | + "+R:AAPL:5min", | |
| 59 | + "+R:MSFT:5min", | |
| 60 | + "+R:NVDA:5min", | |
| 61 | + "+R:AMZN:5min", | |
| 62 | + "+R:META:5min", | |
| 63 | + "+R:TSLA:5min", | |
| 64 | + "+R:JPM:5min", | |
| 65 | + "+R:XOM:5min", | |
| 66 | + "+R:SPY:5min", | |
| 67 | + "+R:QQQ:5min", | |
| 68 | + "+R:ATRO:30min", | |
| 69 | + "+R:ATRO:5min", | |
| 70 | + "+R:AXDX:30min", | |
| 71 | + "+R:AXDX:5min", | |
| 72 | + "+R:BKE:5min", | |
| 73 | + "+R:CECO:30min", | |
| 74 | + "+R:CECO:5min", | |
| 75 | + "+R:CKX:1day", | |
| 76 | + "+R:CKX:5min", | |
| 77 | + "+R:HTD:30min", | |
| 78 | + "+R:HTD:5min", | |
| 79 | + "+R:ICUI:30min", | |
| 80 | + "+R:ICUI:5min", | |
| 81 | + "+R:PBYI:5min", | |
| 82 | + "+R:RPM:5min", | |
| 83 | + "+R:SPB:5min" | |
| 84 | + ], | |
| 85 | + "best_rule": "+R:HTD:5min", | |
| 86 | + "best_daily_sharpe": 0.639, | |
| 87 | + "best_annualized_sharpe": 10.144, | |
| 88 | + "dsr_sr0": 0.4028, | |
| 89 | + "dsr": 1.0 | |
| 90 | + }, | |
| 91 | + "expC 1min 2014-2015": { | |
| 92 | + "days": 504, | |
| 93 | + "rules": 24, | |
| 94 | + "naive_t196": 24, | |
| 95 | + "rc_p": 0.001996007984031936, | |
| 96 | + "spa_p": 0.001996007984031936, | |
| 97 | + "spa_step1_survivors": [ | |
| 98 | + "+R:MSFT:1min", | |
| 99 | + "+R:NVDA:1min", | |
| 100 | + "+R:AMZN:1min", | |
| 101 | + "+R:GOOGL:1min", | |
| 102 | + "+R:META:1min", | |
| 103 | + "+R:TSLA:1min", | |
| 104 | + "+R:JPM:1min", | |
| 105 | + "+R:SPY:1min", | |
| 106 | + "+R:QQQ:1min" | |
| 107 | + ], | |
| 108 | + "best_rule": "+R:TSLA:1min", | |
| 109 | + "best_daily_sharpe": 0.695, | |
| 110 | + "best_annualized_sharpe": 11.033, | |
| 111 | + "dsr_sr0": 0.9135, | |
| 112 | + "dsr": 0.0 | |
| 113 | + }, | |
| 114 | + "expD 2006-2007": { | |
| 115 | + "days": 502, | |
| 116 | + "rules": 38, | |
| 117 | + "naive_t196": 38, | |
| 118 | + "rc_p": 0.001996007984031936, | |
| 119 | + "spa_p": 0.001996007984031936, | |
| 120 | + "spa_step1_survivors": [ | |
| 121 | + "+L:SPY->AAPL", | |
| 122 | + "+L:SPY->MSFT", | |
| 123 | + "+L:SPY->NVDA", | |
| 124 | + "+L:SPY->AMZN", | |
| 125 | + "+L:SPY->JPM", | |
| 126 | + "+L:SPY->XOM", | |
| 127 | + "+L:SPY->UNH", | |
| 128 | + "+L:SPY->QQQ", | |
| 129 | + "+L:SPY->XLF", | |
| 130 | + "+L:SPY->XLE", | |
| 131 | + "+L:SPY->XLK", | |
| 132 | + "+L:SPY->XLV", | |
| 133 | + "+L:SPY->XLI", | |
| 134 | + "+L:SPY->XLY", | |
| 135 | + "+L:SPY->XLP", | |
| 136 | + "+L:SPY->XLU", | |
| 137 | + "+L:SPY->XLB", | |
| 138 | + "+L:SPY->BKE", | |
| 139 | + "+L:SPY->HTD" | |
| 140 | + ], | |
| 141 | + "best_rule": "+L:SPY->XLK", | |
| 142 | + "best_daily_sharpe": 2.0004, | |
| 143 | + "best_annualized_sharpe": 31.755, | |
| 144 | + "dsr_sr0": 2.1664, | |
| 145 | + "dsr": 0.0333 | |
| 146 | + }, | |
| 147 | + "expD 2014-2015": { | |
| 148 | + "days": 504, | |
| 149 | + "rules": 54, | |
| 150 | + "naive_t196": 44, | |
| 151 | + "rc_p": 0.001996007984031936, | |
| 152 | + "spa_p": 0.001996007984031936, | |
| 153 | + "spa_step1_survivors": [ | |
| 154 | + "+L:SPY->NVDA", | |
| 155 | + "+L:SPY->GOOGL", | |
| 156 | + "-L:SPY->QQQ", | |
| 157 | + "+L:SPY->XLF", | |
| 158 | + "+L:SPY->XLK", | |
| 159 | + "+L:SPY->XLI", | |
| 160 | + "+L:SPY->XLY", | |
| 161 | + "+L:SPY->XLP", | |
| 162 | + "+L:SPY->XLB", | |
| 163 | + "+L:SPY->ATRO", | |
| 164 | + "+L:SPY->AXDX", | |
| 165 | + "+L:SPY->BKE", | |
| 166 | + "+L:SPY->CECO", | |
| 167 | + "+L:SPY->HTD", | |
| 168 | + "-L:SPX->SPY", | |
| 169 | + "-L:ES->SPY" | |
| 170 | + ], | |
| 171 | + "best_rule": "+L:SPY->BKE", | |
| 172 | + "best_daily_sharpe": 0.8398, | |
| 173 | + "best_annualized_sharpe": 13.331, | |
| 174 | + "dsr_sr0": 0.7717, | |
| 175 | + "dsr": 0.941 | |
| 176 | + }, | |
| 177 | + "expE train": { | |
| 178 | + "days": 4024, | |
| 179 | + "rules": 16, | |
| 180 | + "naive_t196": 0, | |
| 181 | + "rc_p": 0.8522954091816367, | |
| 182 | + "spa_p": 0.8662674650698603, | |
| 183 | + "spa_step1_survivors": [], | |
| 184 | + "best_rule": "+C:turn_of_month", | |
| 185 | + "best_daily_sharpe": 0.0171, | |
| 186 | + "best_annualized_sharpe": 0.271, | |
| 187 | + "dsr_sr0": 0.0194, | |
| 188 | + "dsr": 0.442 | |
| 189 | + } | |
| 190 | + }, | |
| 191 | + "funnel": { | |
| 192 | + "universe_rules": 372, | |
| 193 | + "naive_t196": 232, | |
| 194 | + "fdr_survivors": 226, | |
| 195 | + "spa_step1_survivors": [ | |
| 196 | + "+L:SPY->AAPL", | |
| 197 | + "+L:SPY->AMZN", | |
| 198 | + "+L:SPY->ATRO", | |
| 199 | + "+L:SPY->AXDX", | |
| 200 | + "+L:SPY->BKE", | |
| 201 | + "+L:SPY->CECO", | |
| 202 | + "+L:SPY->GOOGL", | |
| 203 | + "+L:SPY->HTD", | |
| 204 | + "+L:SPY->JPM", | |
| 205 | + "+L:SPY->MSFT", | |
| 206 | + "+L:SPY->NVDA", | |
| 207 | + "+L:SPY->QQQ", | |
| 208 | + "+L:SPY->UNH", | |
| 209 | + "+L:SPY->XLB", | |
| 210 | + "+L:SPY->XLE", | |
| 211 | + "+L:SPY->XLF", | |
| 212 | + "+L:SPY->XLI", | |
| 213 | + "+L:SPY->XLK", | |
| 214 | + "+L:SPY->XLP", | |
| 215 | + "+L:SPY->XLU", | |
| 216 | + "+L:SPY->XLV", | |
| 217 | + "+L:SPY->XLY", | |
| 218 | + "+L:SPY->XOM", | |
| 219 | + "+R:AAPL:5min", | |
| 220 | + "+R:AMZN:1min", | |
| 221 | + "+R:AMZN:5min", | |
| 222 | + "+R:ATRO:30min", | |
| 223 | + "+R:ATRO:5min", | |
| 224 | + "+R:AXDX:30min", | |
| 225 | + "+R:AXDX:5min", | |
| 226 | + "+R:BKE:5min", | |
| 227 | + "+R:CECO:30min", | |
| 228 | + "+R:CECO:5min", | |
| 229 | + "+R:CKX:1day", | |
| 230 | + "+R:CKX:5min", | |
| 231 | + "+R:GOOGL:1min", | |
| 232 | + "+R:HTD:30min", | |
| 233 | + "+R:HTD:5min", | |
| 234 | + "+R:ICUI:30min", | |
| 235 | + "+R:ICUI:5min", | |
| 236 | + "+R:JPM:1min", | |
| 237 | + "+R:JPM:5min", | |
| 238 | + "+R:META:1min", | |
| 239 | + "+R:META:5min", | |
| 240 | + "+R:MSFT:1min", | |
| 241 | + "+R:MSFT:5min", | |
| 242 | + "+R:NVDA:1min", | |
| 243 | + "+R:NVDA:5min", | |
| 244 | + "+R:PBYI:5min", | |
| 245 | + "+R:QQQ:1min", | |
| 246 | + "+R:QQQ:5min", | |
| 247 | + "+R:RPM:30min", | |
| 248 | + "+R:RPM:5min", | |
| 249 | + "+R:SLF:30min", | |
| 250 | + "+R:SLF:5min", | |
| 251 | + "+R:SPB:5min", | |
| 252 | + "+R:SPY:1min", | |
| 253 | + "+R:SPY:5min", | |
| 254 | + "+R:TSLA:1min", | |
| 255 | + "+R:TSLA:5min", | |
| 256 | + "+R:XOM:1day", | |
| 257 | + "+R:XOM:5min", | |
| 258 | + "-L:ES->SPY", | |
| 259 | + "-L:SPX->SPY", | |
| 260 | + "-L:SPY->QQQ", | |
| 261 | + "-R:AMZN:30min", | |
| 262 | + "-R:NVDA:30min", | |
| 263 | + "-R:UNH:5min" | |
| 264 | + ], | |
| 265 | + "blocks_spa_significant": [ | |
| 266 | + "expC 2000-2007", | |
| 267 | + "expC 2008-2015", | |
| 268 | + "expC 1min 2014-2015", | |
| 269 | + "expD 2006-2007", | |
| 270 | + "expD 2014-2015" | |
| 271 | + ], | |
| 272 | + "dsr_by_block": { | |
| 273 | + "expC 2000-2007": 1.0, | |
| 274 | + "expC 2008-2015": 1.0, | |
| 275 | + "expC 1min 2014-2015": 0.0, | |
| 276 | + "expD 2006-2007": 0.0333, | |
| 277 | + "expD 2014-2015": 0.941, | |
| 278 | + "expE train": 0.442 | |
| 279 | + }, | |
| 280 | + "survival_rate": { | |
| 281 | + "naive": 0.6237, | |
| 282 | + "fdr": 0.6075, | |
| 283 | + "spa_step1": 0.1828 | |
| 284 | + } | |
| 285 | + }, | |
| 286 | + "client_stats": { | |
| 287 | + "network_requests": 0, | |
| 288 | + "cache_hits": 582, | |
| 289 | + "rows_fetched": 0, | |
| 290 | + "seconds_waiting": 0.0, | |
| 291 | + "errors_retried": 0, | |
| 292 | + "refreshes": [] | |
| 293 | + }, | |
| 294 | + "manifest": { | |
| 295 | + "author": "Simon-Pierre Boucher", | |
| 296 | + "contact": "contact@spboucher.ai", | |
| 297 | + "project": "anomaly-atlas", | |
| 298 | + "data_source": "hfmarketdata.io", | |
| 299 | + "collected_utc": "2026-08-12T06:59:34.296896+00:00", | |
| 300 | + "chip": { | |
| 301 | + "brand": "Apple M5 Max", | |
| 302 | + "arch": "arm64", | |
| 303 | + "cores_total": 18, | |
| 304 | + "cores_performance": 6, | |
| 305 | + "cores_efficiency": 12, | |
| 306 | + "gpu_cores": 40 | |
| 307 | + }, | |
| 308 | + "memory": { | |
| 309 | + "unified_bytes": 51539607552, | |
| 310 | + "unified_gb": 48.0, | |
| 311 | + "pagesize": 16384 | |
| 312 | + }, | |
| 313 | + "ssd": { | |
| 314 | + "model": "APPLE SSD AP2048Z", | |
| 315 | + "size": "2 TB", | |
| 316 | + "smart_status": "Verified" | |
| 317 | + }, | |
| 318 | + "os": { | |
| 319 | + "product": "macOS", | |
| 320 | + "version": "27.0", | |
| 321 | + "build": "26A5388g", | |
| 322 | + "kernel": "27.0.0" | |
| 323 | + }, | |
| 324 | + "software": { | |
| 325 | + "python": "3.14.4", | |
| 326 | + "numpy": "2.5.2", | |
| 327 | + "pandas": "3.0.5", | |
| 328 | + "polars": "1.43.2", | |
| 329 | + "duckdb": "1.5.5", | |
| 330 | + "statsmodels": "0.14.6", | |
| 331 | + "arch": "8.0.0" | |
| 332 | + }, | |
| 333 | + "git": { | |
| 334 | + "commit": "b22886203558004eeea20218c7b2c7fadec07821", | |
| 335 | + "dirty_tree": true | |
| 336 | + } | |
| 337 | + } | |
| 338 | +} | |
added
src/anomaly_atlas/stats/spa.py
+132 −0
@@ -0,0 +1,132 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Project : anomaly-atlas | |
| 3 | +# File : src/anomaly_atlas/stats/spa.py | |
| 4 | +# Purpose : White Reality Check & Hansen SPA over a rule-return matrix | |
| 5 | +# Author : Simon-Pierre Boucher | |
| 6 | +# Contact : contact@spboucher.ai | |
| 7 | +# Data src : hfmarketdata.io (sole data source) | |
| 8 | +# Created : 2026-08-12 | |
| 9 | +# Modified : 2026-08-12 | |
| 10 | +# Platform : macOS / Apple Silicon (arm64) | |
| 11 | +# License : All rights reserved (research code) | |
| 12 | +# ============================================================================= | |
| 13 | +"""Data-snooping corrections over a searched universe of rules. | |
| 14 | + | |
| 15 | +Inputs are a (T days × N rules) matrix of rule returns. H0: no rule has | |
| 16 | +positive expected return — max_k E[f_k] <= 0. | |
| 17 | + | |
| 18 | +* White (2000) Reality Check: max-statistic over the centered stationary | |
| 19 | + bootstrap (Politis-Romano 1994). | |
| 20 | +* Hansen (2005) SPA: studentized statistic with the recentering threshold, | |
| 21 | + less sensitive to poor/irrelevant rules in the universe. | |
| 22 | + | |
| 23 | +Validated on synthetic ground truth (§8.1 gate: pure noise must not | |
| 24 | +survive; a planted profitable rule must). | |
| 25 | +""" | |
| 26 | + | |
| 27 | +from __future__ import annotations | |
| 28 | + | |
| 29 | +import numpy as np | |
| 30 | + | |
| 31 | + | |
| 32 | +def stationary_bootstrap_indices( | |
| 33 | + n: int, mean_block: float, n_boot: int, seed: int = 42 | |
| 34 | +) -> np.ndarray: | |
| 35 | + """(n_boot, n) index matrix from the Politis-Romano stationary bootstrap. | |
| 36 | + | |
| 37 | + Geometric block lengths with mean `mean_block`, circular wrapping — | |
| 38 | + resamples preserve short-range dependence in expectation. | |
| 39 | + """ | |
| 40 | + rng = np.random.default_rng(seed) | |
| 41 | + p = 1.0 / mean_block | |
| 42 | + idx = np.empty((n_boot, n), dtype=np.int64) | |
| 43 | + for b in range(n_boot): | |
| 44 | + t = 0 | |
| 45 | + while t < n: | |
| 46 | + start = rng.integers(0, n) | |
| 47 | + length = min(int(rng.geometric(p)), n - t) | |
| 48 | + idx[b, t : t + length] = (start + np.arange(length)) % n | |
| 49 | + t += length | |
| 50 | + return idx | |
| 51 | + | |
| 52 | + | |
| 53 | +def reality_check( | |
| 54 | + x: np.ndarray, n_boot: int = 500, mean_block: float = 5.0, seed: int = 42 | |
| 55 | +) -> dict: | |
| 56 | + """White's Reality Check p-value for max_k mean(x_k) > 0. | |
| 57 | + | |
| 58 | + x: (T, N) rule-return matrix (NaN rows dropped listwise). | |
| 59 | + """ | |
| 60 | + x = np.asarray(x, dtype=float) | |
| 61 | + x = x[np.isfinite(x).all(axis=1)] | |
| 62 | + t_len, n_rules = x.shape | |
| 63 | + if t_len < 30 or n_rules == 0: | |
| 64 | + return {"p": float("nan"), "best_rule": None, "v_stat": float("nan")} | |
| 65 | + means = x.mean(axis=0) | |
| 66 | + v = np.sqrt(t_len) * means.max() | |
| 67 | + idx = stationary_bootstrap_indices(t_len, mean_block, n_boot, seed) | |
| 68 | + centered = x - means # White: bootstrap distribution of centered means | |
| 69 | + v_boot = np.empty(n_boot) | |
| 70 | + for b in range(n_boot): | |
| 71 | + v_boot[b] = np.sqrt(t_len) * centered[idx[b]].mean(axis=0).max() | |
| 72 | + p = float((np.sum(v_boot >= v) + 1) / (n_boot + 1)) | |
| 73 | + return {"p": p, "best_rule": int(means.argmax()), "v_stat": float(v), | |
| 74 | + "best_mean_daily": float(means.max())} | |
| 75 | + | |
| 76 | + | |
| 77 | +def spa_test( | |
| 78 | + x: np.ndarray, n_boot: int = 500, mean_block: float = 5.0, seed: int = 42 | |
| 79 | +) -> dict: | |
| 80 | + """Hansen's SPA p-value (consistent variant) for max_k mean(x_k) > 0.""" | |
| 81 | + x = np.asarray(x, dtype=float) | |
| 82 | + x = x[np.isfinite(x).all(axis=1)] | |
| 83 | + t_len, n_rules = x.shape | |
| 84 | + if t_len < 30 or n_rules == 0: | |
| 85 | + return {"p": float("nan"), "best_rule": None} | |
| 86 | + means = x.mean(axis=0) | |
| 87 | + idx = stationary_bootstrap_indices(t_len, mean_block, n_boot, seed) | |
| 88 | + boot_means = np.empty((n_boot, n_rules)) | |
| 89 | + for b in range(n_boot): | |
| 90 | + boot_means[b] = x[idx[b]].mean(axis=0) | |
| 91 | + omega = np.sqrt(t_len) * boot_means.std(axis=0, ddof=1) | |
| 92 | + omega = np.maximum(omega, 1e-12) | |
| 93 | + t_stat = float((np.sqrt(t_len) * means / omega).max()) | |
| 94 | + # Hansen recentering: rules with sufficiently negative means contribute 0 | |
| 95 | + thresh = -omega / np.sqrt(t_len) * np.sqrt(2.0 * np.log(np.log(max(t_len, 3)))) | |
| 96 | + center = np.where(means >= thresh, means, 0.0) | |
| 97 | + t_boot = np.empty(n_boot) | |
| 98 | + for b in range(n_boot): | |
| 99 | + z = np.sqrt(t_len) * (boot_means[b] - center) / omega | |
| 100 | + t_boot[b] = max(z.max(), 0.0) | |
| 101 | + p = float((np.sum(t_boot >= max(t_stat, 0.0)) + 1) / (n_boot + 1)) | |
| 102 | + rule_t = np.sqrt(t_len) * means / omega | |
| 103 | + t95 = float(np.percentile(t_boot, 95)) | |
| 104 | + return {"p": p, "best_rule": int(rule_t.argmax()), "t_stat": t_stat, | |
| 105 | + "rule_t": rule_t.tolist(), "t95": t95, | |
| 106 | + "n_step1_survivors": int((rule_t >= t95).sum()) if np.isfinite(t95) else 0} | |
| 107 | + | |
| 108 | + | |
| 109 | +def deflated_sharpe( | |
| 110 | + sr: float, t_len: int, skew: float, kurt: float, | |
| 111 | + n_trials: int, sr_variance: float, | |
| 112 | +) -> dict: | |
| 113 | + """Bailey & López de Prado (2014) Deflated Sharpe Ratio. | |
| 114 | + | |
| 115 | + `sr` is the per-period (e.g. daily) Sharpe of the BEST rule; `sr_variance` | |
| 116 | + the variance of Sharpe estimates across the searched universe; `kurt` is | |
| 117 | + Pearson kurtosis (normal = 3). Returns the expected max Sharpe under | |
| 118 | + pure selection (`sr0`) and DSR = P[true SR > 0 | selection]. | |
| 119 | + """ | |
| 120 | + from math import sqrt | |
| 121 | + | |
| 122 | + from scipy.stats import norm | |
| 123 | + | |
| 124 | + if n_trials < 2 or sr_variance <= 0 or t_len < 10: | |
| 125 | + return {"sr0": float("nan"), "dsr": float("nan")} | |
| 126 | + gamma = 0.5772156649015329 | |
| 127 | + z1 = norm.ppf(1.0 - 1.0 / n_trials) | |
| 128 | + z2 = norm.ppf(1.0 - 1.0 / (n_trials * np.e)) | |
| 129 | + sr0 = sqrt(sr_variance) * ((1.0 - gamma) * z1 + gamma * z2) | |
| 130 | + denom = sqrt(max(1.0 - skew * sr + (kurt - 1.0) / 4.0 * sr**2, 1e-12)) | |
| 131 | + dsr = float(norm.cdf((sr - sr0) * sqrt(t_len - 1.0) / denom)) | |
| 132 | + return {"sr0": float(sr0), "dsr": dsr} | |
modified
web/lib/charts.js
+35 −1
@@ -281,11 +281,44 @@ function expEFigure(C) { | ||
| 281 | 281 | return out; |
| 282 | 282 | } |
| 283 | 283 | |
| 284 | +// ------------------------------------------------------------------- expF | |
| 285 | +function expFFigure(C) { | |
| 286 | + const res = latestResults(C, "expF_multiple_testing"); | |
| 287 | + if (!res) return ""; | |
| 288 | + const f = res.data.funnel || {}; | |
| 289 | + const stages = [ | |
| 290 | + ["searched universe", f.universe_rules, "all scanned cells/pairs/classes (×2 signs)"], | |
| 291 | + ["naive |t| > 1.96", f.naive_t196, "uncorrected in-sample t-test"], | |
| 292 | + ["BH-FDR 5%", f.fdr_survivors, "false-discovery-rate correction"], | |
| 293 | + ["Hansen SPA step-1", (f.spa_step1_survivors || []).length, "data-snooping correction"], | |
| 294 | + ].filter((s) => Number.isFinite(s[1])); | |
| 295 | + if (stages.length < 3) return ""; | |
| 296 | + const W = 760, ML = 235, MR = 90, MT = 26, RH = 46, MB = 40; | |
| 297 | + const H = MT + stages.length * RH + MB; | |
| 298 | + const iw = W - ML - MR; | |
| 299 | + const max = stages[0][1]; | |
| 300 | + let g = ""; | |
| 301 | + stages.forEach(([label, n, sub], i) => { | |
| 302 | + const y = MT + i * RH; | |
| 303 | + const w = Math.max(2, (n / max) * iw); | |
| 304 | + g += `<text x="${ML - 10}" y="${y + 17}" text-anchor="end" font-size="11.5" fill="${INK}" font-weight="600">${esc(label)}</text> | |
| 305 | +<text x="${ML - 10}" y="${y + 31}" text-anchor="end" font-size="9.5" fill="${MUTED}">${esc(sub)}</text> | |
| 306 | +<rect x="${ML}" y="${y + 6}" width="${w.toFixed(1)}" height="22" rx="4" fill="${i === stages.length - 1 ? ORANGE : BLUE}"><title>${esc(label)}: ${n} rules (${Math.round((n / max) * 100)}%)</title></rect> | |
| 307 | +<text x="${ML + w + 8}" y="${y + 21}" font-size="12" fill="${INK}" font-weight="640">${n} <tspan fill="${MUTED}" font-weight="400" font-size="10.5">(${Math.round((n / max) * 100)}%)</tspan></text>`; | |
| 308 | + }); | |
| 309 | + g += `<text x="${ML}" y="${H - 10}" font-size="10.5" fill="${MUTED}">Survivors are gross and artifact-laden — costs (expG) are the next layer.</text>`; | |
| 310 | + const svg = `<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="expF survival funnel across correction layers">${g}</svg>`; | |
| 311 | + return fig(svg, `expF — the survival curve: what fraction of the searched rule universe survives each ` + | |
| 312 | + `statistical-correction layer on TRAIN. Statistical correction fixes the search, not the mechanism. ` + | |
| 313 | + `Run ${esc(res.run)}, regenerated from results.json.`); | |
| 314 | +} | |
| 315 | + | |
| 284 | 316 | const BUILDERS = { |
| 285 | 317 | expB_artifact_baselines: expBFigure, |
| 286 | 318 | expC_reversion_scan: expCFigure, |
| 287 | 319 | expD_leadlag_scan: expDFigure, |
| 288 | 320 | expE_calendar_scan: expEFigure, |
| 321 | + expF_multiple_testing: expFFigure, | |
| 289 | 322 | }; |
| 290 | 323 | |
| 291 | 324 | /** Figures for an experiment page ("" when none apply). */ |
@@ -300,7 +333,8 @@ function figuresFor(experiment, C) { | ||
| 300 | 333 | |
| 301 | 334 | /** The most recent experiment figure, for the home page. */ |
| 302 | 335 | function homeFigure(C) { |
| 303 | − for (const exp of ["expE_calendar_scan", "expD_leadlag_scan", "expC_reversion_scan", "expB_artifact_baselines"]) { | |
| 336 | + for (const exp of ["expF_multiple_testing", "expE_calendar_scan", "expD_leadlag_scan", | |
| 337 | + "expC_reversion_scan", "expB_artifact_baselines"]) { | |
| 304 | 338 | const html = figuresFor(exp, C); |
| 305 | 339 | if (html) return { experiment: exp, html: html.split("</figure>")[0] + "</figure>" }; |
| 306 | 340 | } |
| 307 | 341 | |