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%

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>
Simon-Pierre Boucher committed 3 h ago (Aug 12, 2026) parent b228862

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