""" Pre-registered test (see NN_PLAN.md): can a walk-forward neural net on CROSS-INDEX / MARKET-STATE features improve the vol-gated dip-z PORTFOLIO, as a take/skip filter or as a size multiplier? Run: python research/nn_cross_index.py (stdout only; NN_RESULTS.md is written from its output) Trade generator, gate and indicators are imported from the existing research modules; nothing about the rule is re-implemented here. """ from __future__ import annotations import sys import warnings import numpy as np from numpy.lib.stride_tricks import sliding_window_view as swv sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else ".") import backtest as bt # noqa: E402 from run_screen import zscore_entries # noqa: E402 from vol_filter_test import vol_pctile # noqa: E402 from sklearn.metrics import roc_auc_score # noqa: E402 from sklearn.preprocessing import StandardScaler # noqa: E402 from sklearn.neural_network import MLPClassifier, MLPRegressor # noqa: E402 from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier # noqa: E402 from sklearn.linear_model import LogisticRegression # noqa: E402 from sklearn.model_selection import KFold # noqa: E402 warnings.filterwarnings("ignore") IDX = ["SP500", "NAS100", "US30", "DAX40"] PERIOD = "PERIOD_H4" STOP_ATR = 3.0 RISK = 0.0025 CAP = 0.0075 EMBARGO = 12 H4 = np.timedelta64(4, "h") BLOCKS = [("2023", "2023-01-01", "2024-01-01"), ("2024", "2024-01-01", "2025-01-01"), ("2025", "2025-01-01", "2026-01-01"), ("2026", "2026-01-01", "2027-01-01")] OOS0, MID, OOS1 = np.datetime64("2023-01-01"), np.datetime64("2024-11-01"), np.datetime64("2026-09-01") CUTS = (0.45, 0.50, 0.55) PRIMARY_CUT = 0.50 RNG = np.random.default_rng(12345) # ------------------------------------------------------------------ helpers def lagdiff(x, k): out = np.full(len(x), np.nan) out[k:] = x[k:] - x[:-k] return out def lag(x, k): out = np.full(len(x), np.nan) out[k:] = x[:-k] return out def roll(x, n, fn): out = np.full(len(x), np.nan) if len(x) >= n: out[n - 1:] = fn(swv(x, n), axis=-1) return out # ------------------------------------------------------------ data + bars D = {} for s in IDX: d = bt.load(s, PERIOD) c, h, l, o = d["c"], d["h"], d["l"], d["o"] e, xma = zscore_entries(d, 20, -1.5, 0) sd = bt.rolling_std(c, 20) d["z20"] = (c - xma) / np.where(sd > 0, sd, np.nan) d["xma"] = xma d["a14"] = bt.atr(h, l, c, 14) d["a100"] = bt.atr(h, l, c, 100) d["sma200"] = bt.sma(c, 200) d["vp"] = vol_pctile(d) d["raw_e"] = e d["gated"] = e & (np.nan_to_num(d["vp"], nan=-1) >= 0.50) D[s] = d print(f"loaded {s}: {len(c)} bars {str(d['ts'][0])[:10]}..{str(d['ts'][-1])[:10]}, " f"gated signal bars {int(d['gated'].sum())}") # union grid: every bar open time any index has; forward-fill = last bar with open <= u U = np.unique(np.concatenate([D[s]["ts"] for s in IDX])) MAP_U = {s: np.searchsorted(D[s]["ts"], U, side="right") - 1 for s in IDX} lr = [] for s in IDX: j = MAP_U[s] cf = np.where(j >= 0, D[s]["c"][np.maximum(j, 0)], np.nan) lr.append(np.concatenate([[np.nan], np.diff(np.log(cf))])) LR = np.vstack(lr) # 4 x len(U) CORR60 = np.full(len(U), np.nan) for u in range(60, len(U)): w = LR[:, u - 59:u + 1] if np.isfinite(w).all(): cm = np.corrcoef(w) CORR60[u] = cm[np.triu_indices(4, 1)].mean() # per-index per-bar arrays used by the cross set for s in IDX: d = D[s] c, a = d["c"], d["a14"] d["r5"] = lagdiff(c, 5) / a d["r30"] = lagdiff(c, 30) / a d["lr20"] = np.log(c / lag(c, 20)) def cross_block(s): """CROSS feature matrix for every bar of symbol s (rows aligned to D[s] bars).""" d = D[s] ts = d["ts"] cols, names = [], [] zs, lr20s = [], [] for k in IDX: dk = D[k] j = np.searchsorted(dk["ts"], ts, side="right") - 1 # last bar with open <= t ok = j >= 0 jj = np.maximum(j, 0) for f in ("z20", "vp", "r5", "r30"): v = np.where(ok, dk[f][jj], np.nan) cols.append(v) names.append(f"{f}_{k}") zs.append(np.where(ok, dk["z20"][jj], np.nan)) lr20s.append(np.where(ok, dk["lr20"][jj], np.nan)) Z = np.vstack(zs) cols.append(np.nansum(Z <= -1.5, axis=0).astype(float)); names.append("breadth15") cols.append(np.nansum(Z <= -1.0, axis=0).astype(float)); names.append("breadth10") hh = roll(d["h"], 120, np.max) cols.append((hh - d["c"]) / d["a14"]); names.append("dd120") cols.append(lagdiff(d["sma200"], 20) / d["a14"]); names.append("slope200") u = np.searchsorted(U, ts) cols.append(CORR60[u]); names.append("corr60") cols.append(np.nanstd(np.vstack(lr20s), axis=0)); names.append("disp20") for k in IDX: cols.append(np.full(len(ts), 1.0 if k == s else 0.0)); names.append(f"is_{k}") return np.column_stack(cols), names def bar_block(s): """The old signal-bar-state set (the in-terminal DipMeta baseline).""" d = D[s] o, h, l, c, a, ts = d["o"], d["h"], d["l"], d["c"], d["a14"], d["ts"] dn = np.zeros(len(c)) for i in range(1, len(c)): dn[i] = dn[i - 1] + 1 if c[i] < c[i - 1] else 0 ll = roll(l, 20, np.min) hh = roll(h, 20, np.max) rng = h - l absd = np.abs(np.concatenate([[np.nan], np.diff(c)])) er = np.abs(lagdiff(c, 10)) / roll(np.nan_to_num(absd), 10, np.sum) r1 = np.concatenate([[np.nan], np.diff(np.log(c))]) r4 = roll(np.nan_to_num(r1), 4, np.sum) vr = roll(r4, 60, np.var) / (4 * roll(np.nan_to_num(r1), 60, np.var)) dow = ((ts.astype("datetime64[D]").astype(int) + 3) % 7).astype(float) # Mon=0 hour = (ts.astype("datetime64[h]").astype(int) % 24).astype(float) cols = [d["z20"], (c - d["sma200"]) / a, lagdiff(c, 1) / a, lagdiff(c, 5) / a, dn, (c - ll) / np.where(hh - ll > 0, hh - ll, np.nan), rng / a, (c - l) / np.where(rng > 0, rng, np.nan), (o - lag(c, 1)) / a, a / d["a100"], er, vr, (c > d["sma200"]).astype(float), dow, hour] names = ["b_z20", "b_dist200", "b_ret1", "b_ret5", "b_downstreak", "b_pos20", "b_range", "b_clv", "b_gap", "b_atrratio", "b_er10", "b_vr", "b_regime", "b_dow", "b_hour"] for k in IDX: cols.append(np.full(len(ts), 1.0 if k == s else 0.0)); names.append(f"is_{k}") return np.column_stack(cols), names FEAT = {} for s in IDX: Xc, nc = cross_block(s) Xb, nb = bar_block(s) FEAT[s] = {"CROSS": Xc, "BAR": Xb, "BOTH": np.column_stack([Xc, Xb[:, :-4]])} NAMES = {"CROSS": nc, "BAR": nb, "BOTH": nc + nb[:-4]} # ----------------------------------------------------------------- trades def trades_from(mask_fn): out = [] for s in IDX: d = D[s] tr = bt.simulate(d, mask_fn(s), side=1, exit_ma=d["xma"], max_bars=10, stop_atr=STOP_ATR) tr = bt.add_r(tr, d, stop_atr=STOP_ATR) for t in tr: t["symbol"] = s t["sig_i"] = t["entry_i"] - 1 t["sig_t"] = d["ts"][t["sig_i"]] t["exit_close"] = d["ts"][t["exit_i"]] + H4 out += tr out.sort(key=lambda t: t["t"]) return out GATED = trades_from(lambda s: D[s]["gated"]) UNGATED = trades_from(lambda s: D[s]["raw_e"]) print(f"gated trades {len(GATED)}, ungated {len(UNGATED)}\n") def rows(trades, fset, canary=False): X = np.array([FEAT[t["symbol"]][fset][t["sig_i"]] for t in trades]) if canary: cn = np.array([(D[t["symbol"]]["c"][t["entry_i"]] - D[t["symbol"]]["o"][t["entry_i"]]) / D[t["symbol"]]["a14"][t["sig_i"]] for t in trades]) X = np.column_stack([X, cn]) y = np.array([1 if t["r"] > 0 else 0 for t in trades]) r = np.array([t["r"] for t in trades]) return X, y, r def train_mask(trades, b0): keep = [] for t in trades: d = D[t["symbol"]] k0 = np.searchsorted(d["ts"], b0) keep.append(t["sig_t"] < b0 and t["exit_i"] <= k0 - EMBARGO) return np.array(keep) def val_mask(trades, b0, b1): return np.array([b0 <= t["sig_t"] < b1 for t in trades]) # ----------------------------------------------------------------- models class Model: def __init__(self, kind, reg=False): self.kind, self.reg = kind, reg def fit(self, X, y): self.med = np.nanmedian(X, axis=0) self.med = np.where(np.isfinite(self.med), self.med, 0.0) X = self._imp(X) self.sc = StandardScaler().fit(X) Xs = self.sc.transform(X) k = self.kind if k == "MLP": cls = MLPRegressor if self.reg else MLPClassifier self.m = [cls(hidden_layer_sizes=(16,), alpha=1.0, solver="lbfgs", max_iter=2000, random_state=sd).fit(Xs, y) for sd in range(5)] elif k == "RF": self.m = [RandomForestClassifier(n_estimators=500, min_samples_leaf=15, max_features="sqrt", random_state=0, n_jobs=-1).fit(Xs, y)] elif k == "GB": self.m = [GradientBoostingClassifier(n_estimators=150, max_depth=2, learning_rate=0.05, subsample=0.7, random_state=0).fit(Xs, y)] elif k == "LR": self.m = [LogisticRegression(C=0.1, max_iter=2000).fit(Xs, y)] return self def _imp(self, X): X = np.array(X, float) bad = ~np.isfinite(X) X[bad] = np.take(self.med, np.where(bad)[1]) return X def predict(self, X): Xs = self.sc.transform(self._imp(X)) if self.reg: return np.mean([m.predict(Xs) for m in self.m], axis=0) return np.mean([m.predict_proba(Xs)[:, 1] for m in self.m], axis=0) def oof_edges(kind, X, y, reg=False): """Tercile edges of P from time-ordered 3-fold out-of-fold predictions.""" p = np.full(len(y), np.nan) for tr, te in KFold(3, shuffle=False).split(X): if len(np.unique(y[tr])) < 2 and not reg: continue p[te] = Model(kind, reg).fit(X[tr], y[tr]).predict(X[te]) return np.nanpercentile(p, [100 / 3, 200 / 3]) def walk_forward(kind, fset, train_trades=GATED, val_trades=GATED, canary=False, shuffle=False, reg=False, need_bars=False, need_edges=False): """Returns per-block results, pooled OOS arrays, per-bar P for every gated bar.""" Xall_tr, yall_tr, rall_tr = rows(train_trades, fset, canary) Xall_v, yall_v, rall_v = rows(val_trades, fset, canary) blocks, P, Y, R, BL, TR = [], [], [], [], [], [] pbar = {s: np.full(len(D[s]["ts"]), np.nan) for s in IDX} edges = {} models = {} for name, b0s, b1s in BLOCKS: b0, b1 = np.datetime64(b0s), np.datetime64(b1s) tm = train_mask(train_trades, b0) vm = val_mask(val_trades, b0, b1) Xtr, ytr = Xall_tr[tm], (rall_tr[tm] if reg else yall_tr[tm]) if shuffle: ytr = RNG.permutation(ytr) m = Model(kind, reg).fit(Xtr, ytr) models[name] = m p = m.predict(Xall_v[vm]) auc = roc_auc_score(yall_v[vm], p) if len(np.unique(yall_v[vm])) == 2 else np.nan blocks.append((name, int(tm.sum()), int(vm.sum()), auc)) P.append(p); Y.append(yall_v[vm]); R.append(rall_v[vm]); BL.append(np.full(vm.sum(), name)) TR += [t for t, v in zip(val_trades, vm) if v] if need_edges: edges[name] = oof_edges(kind, Xtr, ytr, reg) if need_bars: for s in IDX: ts = D[s]["ts"] ix = np.where(D[s]["gated"] & (ts >= b0) & (ts < b1))[0] if len(ix): pbar[s][ix] = m.predict(FEAT[s][fset][ix]) return dict(blocks=blocks, p=np.concatenate(P), y=np.concatenate(Y), r=np.concatenate(R), bl=np.concatenate(BL), trades=TR, pbar=pbar, edges=edges, models=models, Xv=Xall_v, vtr=val_trades) def auc_stats(p, y, nboot=2000, nperm=1000): auc = roc_auc_score(y, p) n = len(y) bs = [] for _ in range(nboot): ix = RNG.integers(0, n, n) if len(np.unique(y[ix])) == 2: bs.append(roc_auc_score(y[ix], p[ix])) lo, hi = np.percentile(bs, [2.5, 97.5]) perm = np.array([roc_auc_score(RNG.permutation(y), p) for _ in range(nperm)]) pval = (np.sum(perm >= auc) + 1) / (nperm + 1) return auc, lo, hi, pval # -------------------------------------------------------------- portfolio def build(mask_fn, mult_fn=None): """OOS trades with the entry mask, open-risk cap applied chronologically.""" tr = trades_from(lambda s: mask_fn(s) & (D[s]["ts"] >= OOS0) & (D[s]["ts"] < OOS1)) kept, open_ = [], [] for t in tr: risk = RISK * (mult_fn(t) if mult_fn else 1.0) open_ = [(x, rk) for x, rk in open_ if x > t["t"]] if sum(rk for _, rk in open_) + risk > CAP + 1e-12: continue t = dict(t, risk=risk) open_.append((t["exit_close"], risk)) kept.append(t) return kept def stats(trades, t0, t1): tr = [t for t in trades if t0 <= t["t"] < t1] months = (t1 - t0) / np.timedelta64(1, "D") / 30.4375 if not tr: return dict(n=0, permo=0, ret=0, dd_x=0, dd_m=0, rdd=np.nan, mult=np.nan) eq = [1.0] for t in sorted(tr, key=lambda x: x["exit_close"]): eq.append(eq[-1] * (1 + t["risk"] * t["r"])) eq = np.array(eq) dd_x = ((np.maximum.accumulate(eq) - eq) / np.maximum.accumulate(eq)).max() # mark-to-market: every open trade marked at each of its bar closes (additive) ev = [] for t in tr: d = D[t["symbol"]] i, e0 = t["sig_i"], d["o"][t["entry_i"]] rd = STOP_ATR * d["a14"][i] prev = 0.0 for j in range(t["entry_i"], t["exit_i"] + 1): v = t["r"] if j == t["exit_i"] else (d["c"][j] - e0 - d["cost"][t["entry_i"]]) / rd ev.append((d["ts"][j] + H4, t["risk"] * (v - prev))) prev = v ev.sort(key=lambda x: x[0]) times = np.array([x[0] for x in ev]) cum = 1.0 + np.cumsum([x[1] for x in ev]) last = np.r_[times[1:] != times[:-1], True] em = np.r_[1.0, cum[last]] pk = np.maximum.accumulate(em) dd_m = ((pk - em) / pk).max() ret = eq[-1] - 1 return dict(n=len(tr), permo=len(tr) / months, ret=ret, dd_x=dd_x, dd_m=dd_m, rdd=ret / dd_m if dd_m > 1e-12 else np.nan, mult=np.mean([t["risk"] for t in tr]) / RISK) HALVES = [("H1 2023-01..2024-10", OOS0, MID), ("H2 2024-11..2026-08", MID, OOS1), ("OOS all", OOS0, OOS1)] def port_rows(tag, trades): out = [] for hn, a, b in HALVES: st = stats(trades, a, b) out.append((tag, hn, st)) return out def fmt_port(rowsl): lines = ["| variant | window | n | /mo | mean risk x | return | maxDD exit | maxDD MTM | ret/DD (MTM) |", "|---|---|---|---|---|---|---|---|---|"] for tag, hn, s in rowsl: lines.append(f"| {tag} | {hn} | {s['n']} | {s['permo']:.1f} | {s['mult']:.2f} | " f"{s['ret']:+.2%} | {s['dd_x']:.2%} | {s['dd_m']:.2%} | {s['rdd']:.2f} |") return "\n".join(lines) def block_of(ts): for name, b0, b1 in BLOCKS: if np.datetime64(b0) <= ts < np.datetime64(b1): return name return None def variants(tag, wf): """Filter at each cut + tercile sizer, from the per-bar P of a walk-forward run.""" pb = wf["pbar"] out = [] for cut in CUTS: out += port_rows(f"{tag} filter P>={cut:.2f}", build(lambda s, c=cut: D[s]["gated"] & (np.nan_to_num(pb[s], nan=-1) >= c))) def mult(t): p = pb[t["symbol"]][t["sig_i"]] lo, hi = wf["edges"][block_of(t["sig_t"])] return 0.5 if p < lo else (1.5 if p >= hi else 1.0) out += port_rows(f"{tag} sizer 0.5/1/1.5", build(lambda s: D[s]["gated"], mult)) return out # ------------------------------------------------------------------- main if __name__ == "__main__": md = [] P = lambda x="": (print(x), md.append(x)) # noqa: E731 P("## 1. Walk-forward AUC (gated universe, label R>0)\n") P("| model | features | per-block AUC (train n / val n) | mean AUC n>=100 | pooled OOS AUC [95% CI] | perm p |") P("|---|---|---|---|---|---|") WF = {} for fset in ("CROSS", "BAR", "BOTH"): for kind in ("MLP", "RF", "GB", "LR"): prim = (kind == "MLP" and fset == "CROSS") wf = walk_forward(kind, fset, need_bars=True, need_edges=prim) WF[(kind, fset)] = wf # primary: 20k resamples so the CI bound is not Monte-Carlo noise, and the # SAME numbers are used by the verdict below auc, lo, hi, pv = auc_stats(wf["p"], wf["y"], nboot=20000 if prim else 2000) if prim: PRIM_STATS = (auc, lo, hi, pv) big = [a for _, _, n, a in wf["blocks"] if n >= 100] blk = "; ".join(f"{nm} {a:.3f} ({ntr}/{nv})" for nm, ntr, nv, a in wf["blocks"]) P(f"| {kind}{' (PRIMARY)' if prim else ''} | {fset} | {blk} | " f"{np.mean(big) if big else np.nan:.3f} | {auc:.3f} [{lo:.3f}, {hi:.3f}] | {pv:.3f} |") P("\n## 2. Sanity checks (MLP, CROSS)\n") P("| check | per-block AUC | pooled OOS AUC [95% CI] |") P("|---|---|---|") for tag, kw in (("labels shuffled in training", dict(shuffle=True)), ("canary: + first-bar return (future)", dict(canary=True))): wf = walk_forward("MLP", "CROSS", **kw) auc, lo, hi, _ = auc_stats(wf["p"], wf["y"], nboot=500, nperm=10) blk = "; ".join(f"{nm} {a:.3f}" for nm, _, _, a in wf["blocks"]) P(f"| {tag} | {blk} | {auc:.3f} [{lo:.3f}, {hi:.3f}] |") P("\n## 3. Secondary: MLP trained on the UNGATED dip-z trades, scored on gated (not in pass bar)\n") wfu = walk_forward("MLP", "CROSS", train_trades=UNGATED, need_bars=True, need_edges=True) auc, lo, hi, pv = auc_stats(wfu["p"], wfu["y"]) P("per-block: " + "; ".join(f"{nm} {a:.3f} ({ntr}/{nv})" for nm, ntr, nv, a in wfu["blocks"])) P(f"\npooled OOS AUC {auc:.3f} [{lo:.3f}, {hi:.3f}], perm p {pv:.3f}") P("\n## 4. Permutation importance, primary model (drop in pooled OOS AUC, 20 repeats)\n") wf = WF[("MLP", "CROSS")] base = roc_auc_score(wf["y"], wf["p"]) vtr = wf["vtr"] Xv = wf["Xv"] imp = [] for j, nm in enumerate(NAMES["CROSS"]): drops = [] for _ in range(20): pp = [] for name, b0s, b1s in BLOCKS: vm = val_mask(vtr, np.datetime64(b0s), np.datetime64(b1s)) X = Xv[vm].copy() X[:, j] = RNG.permutation(X[:, j]) pp.append(wf["models"][name].predict(X)) drops.append(base - roc_auc_score(wf["y"], np.concatenate(pp))) imp.append((nm, np.mean(drops), np.std(drops))) imp.sort(key=lambda x: -x[1]) P("| feature | AUC drop | sd |") P("|---|---|---|") for nm, m_, s_ in imp[:10]: P(f"| {nm} | {m_:+.4f} | {s_:.4f} |") P("\n## 5. Portfolio, OOS 2023-01 .. 2026-08 (risk 0.25 %, open-risk cap 0.75 %, no Friday flat / swap)\n") rows_ = port_rows("BASELINE gated", build(lambda s: D[s]["gated"])) rows_ += variants("MLP-CROSS (PRIMARY)", WF[("MLP", "CROSS")]) P(fmt_port(rows_)) base_rows = {hn: s for tag, hn, s in rows_ if tag == "BASELINE gated"} P("\n### Context (not in pass bar)\n") ctx = [] for key in (("MLP", "BAR"), ("MLP", "BOTH"), ("RF", "CROSS"), ("GB", "CROSS"), ("LR", "CROSS")): wfk = WF[key] pb = wfk["pbar"] ctx += port_rows(f"{key[0]}-{key[1]} filter P>=0.50", build(lambda s, pb=pb: D[s]["gated"] & (np.nan_to_num(pb[s], nan=-1) >= 0.5))) ctx += variants("MLP-CROSS ungated-trained", wfu) wfr = walk_forward("MLP", "CROSS", reg=True, need_bars=True, need_edges=True) rc = np.corrcoef(wfr["p"], wfr["r"])[0, 1] def multr(t): p = wfr["pbar"][t["symbol"]][t["sig_i"]] lo, hi = wfr["edges"][block_of(t["sig_t"])] return 0.5 if p < lo else (1.5 if p >= hi else 1.0) ctx += port_rows("MLPRegressor-R sizer", build(lambda s: D[s]["gated"], multr)) P(fmt_port(ctx)) P(f"\nMLPRegressor: OOS corr(predicted R, realised R) = {rc:+.3f} (n {len(wfr['r'])})") # ------------------------------------------------------------ verdict auc, lo, hi, pv = PRIM_STATS big = [a for _, _, n, a in wf["blocks"] if n >= 100] auc_ok = auc >= 0.55 and lo > 0.50 and big and np.mean(big) >= 0.55 prim = {(tag, hn): s for tag, hn, s in rows_} f_ok = all(prim[(f"MLP-CROSS (PRIMARY) filter P>={PRIMARY_CUT:.2f}", hn)]["rdd"] > base_rows[hn]["rdd"] and prim[(f"MLP-CROSS (PRIMARY) filter P>={PRIMARY_CUT:.2f}", hn)]["permo"] >= 2 for hn, _, _ in HALVES[:2]) s_ok = all(prim[("MLP-CROSS (PRIMARY) sizer 0.5/1/1.5", hn)]["rdd"] > base_rows[hn]["rdd"] for hn, _, _ in HALVES[:2]) P("\n## 6. Pre-registered verdict\n") P(f"- AUC criterion (pooled >= 0.55, CI lo > 0.50, mean n>=100 blocks >= 0.55): " f"pooled {auc:.3f} [{lo:.3f}, {hi:.3f}], mean big-block {np.mean(big) if big else np.nan:.3f} " f"-> {'PASS' if auc_ok else 'FAIL'}") P(f"- Filter (cut {PRIMARY_CUT}) ret/DD > baseline in both halves & cadence >= 2/mo: " f"{'yes' if f_ok else 'no'} -> FILTER {'PASS' if (auc_ok and f_ok) else 'FAIL'}") P(f"- Sizer ret/DD > baseline in both halves: {'yes' if s_ok else 'no'} " f"-> SIZER {'PASS' if (auc_ok and s_ok) else 'FAIL'}") # -------------------------------------------- post-registration checks P("\n## 7. Robustness checks added AFTER seeing section 6 (they cannot create a PASS; " "they test whether one is real)\n") # (a) is the harness able to see the canary at all? univariate AUC of the future column Xc, yc, _ = rows(wf["vtr"], "CROSS", canary=True) P(f"- canary column alone, OOS rows: univariate AUC {roc_auc_score(yc, Xc[:, -1]):.3f}") # (b) MLP seed sensitivity: same spec, a different 5-seed ensemble class M2(Model): def fit(self, X, y): super().fit(X, y) if self.kind == "MLP": Xs = self.sc.transform(self._imp(X)) self.m = [MLPClassifier(hidden_layer_sizes=(16,), alpha=1.0, solver="lbfgs", max_iter=2000, random_state=sd).fit(Xs, y) for sd in range(100, 105)] return self _M = Model globals()["Model"] = M2 wf2 = walk_forward("MLP", "CROSS", need_bars=True) globals()["Model"] = _M a2, l2, h2, _ = auc_stats(wf2["p"], wf2["y"], nboot=20000, nperm=10) P(f"- MLP-CROSS, seeds 100-104 instead of 0-4: pooled OOS AUC {a2:.3f} [{l2:.3f}, {h2:.3f}]; " "blocks " + "; ".join(f"{nm} {a:.3f}" for nm, _, _, a in wf2["blocks"])) r2 = port_rows("seeds 100-104 filter P>=0.50", build(lambda s: D[s]["gated"] & (np.nan_to_num(wf2["pbar"][s], nan=-1) >= 0.5))) P("") P(fmt_port(r2)) # (c) random-skip control at the primary filter's skip rate: does skipping ANY signal # bar (and re-entering on a later, deeper bar) improve ret/DD by itself? pb = wf["pbar"] allp = np.concatenate([pb[s][np.isfinite(pb[s])] for s in IDX]) q = float((allp < PRIMARY_CUT).mean()) wins = {hn: [] for hn, _, _ in HALVES} for seed in range(200): rg = np.random.default_rng(seed) rnd = {s: rg.random(len(D[s]["ts"])) for s in IDX} for tag, hn, st in port_rows("rnd", build(lambda s: D[s]["gated"] & (rnd[s] >= q))): wins[hn].append(st["rdd"]) prim_f = {hn: st for tag, hn, st in rows_ if tag == f"MLP-CROSS (PRIMARY) filter P>={PRIMARY_CUT:.2f}"} P(f"\n- random skip of {q:.1%} of gated signal bars (the primary filter's skip rate), 200 seeds:") P("\n| window | baseline ret/DD | primary filter ret/DD | random-skip median [5%, 95%] | share of random >= primary | share of random > baseline |") P("|---|---|---|---|---|---|") both = None for hn, _, _ in HALVES: v = np.array(wins[hn]) P(f"| {hn} | {base_rows[hn]['rdd']:.2f} | {prim_f[hn]['rdd']:.2f} | {np.median(v):.2f} " f"[{np.percentile(v, 5):.2f}, {np.percentile(v, 95):.2f}] | {(v >= prim_f[hn]['rdd']).mean():.1%} | " f"{(v > base_rows[hn]['rdd']).mean():.1%} |") v1, v2 = np.array(wins[HALVES[0][0]]), np.array(wins[HALVES[1][0]]) P(f"\n- random skips that beat baseline in BOTH halves: {((v1 > base_rows[HALVES[0][0]]['rdd']) & (v2 > base_rows[HALVES[1][0]]['rdd'])).mean():.1%}")