576 行
24 KiB
Python
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%}")
|