| """
|
| 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%}")
|