Warrior_EA/research/nn_cross_index.py

576 行
24 KiB
Python

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