61 行
3.6 KiB
Python
61 行
3.6 KiB
Python
"""
| |||
PRE-REGISTERED conditional-direction scan (2026-10-08): "given vol regime x recent move x weekday, where does price go?"
| |||
Universe: SP500/NAS100/US30/DAX40 D1, pooled. Features known at the close of day t:
| |||
VRP bucket {<=0, 0-5, >5} (VIX - RV10 of SP500, same as macro_gates)
| |||
ret1 bucket {<=-1%, mid, >=+1%} (that index's own prior-day return)
| |||
ret5 bucket {<=-2%, mid, >=+2%} (5-day return)
| |||
weekday {Mon..Fri} => 135 cells. Outcome: open(t+1) -> close(t+3), minus real round-trip spread.
| |||
Walk-forward: for test year Y = 2013..2026 select on data < Y: cells with n>=40 and |t| >= 2.5 on the
| |||
(excess over train mean) outcome, top 5 by |t|, trade each in the sign of its train excess in year Y.
| |||
Statistic: mean net bp per selected-cell trade over all OOS years. NULL: the identical procedure with the
| |||
cell label of each day randomly permuted within each index (500 permutations) -> p = share of null >= observed.
| |||
Pass: observed stat > 0 and permutation p <= 0.05. One config, no re-runs with other numbers.
| |||
"""
| |||
import sys, numpy as np, pandas as pd
| |||
sys.path.insert(0, ".")
| |||
import backtest as bt, macro_gates as mg
| |||
H = 3
| |||
def build():
| |||
spx = bt.load("SP500", "PERIOD_D1")
| |||
gt = mg.gate_table(pd.Series(spx["c"], index=pd.DatetimeIndex(spx["ts"]).normalize()))
| |||
rows = []
| |||
for s in mg.IDX:
| |||
d = bt.load(s, "PERIOD_D1"); ts = pd.DatetimeIndex(d["ts"]).normalize(); c, o = d["c"], d["o"]; n = len(c)
| |||
df = pd.DataFrame({"sym": s, "date": ts, "c": c}, index=range(n))
| |||
df["r1"] = pd.Series(c).pct_change(); df["r5"] = pd.Series(c).pct_change(5)
| |||
df["vrp"] = gt.vrp.reindex(ts).to_numpy(); df["dow"] = ts.dayofweek
| |||
fwd = np.full(n, np.nan);
| |||
for i in range(n - H - 1):
| |||
fwd[i] = (c[i + H + 1] - o[i + 1]) / o[i + 1] - d["cost"][i + 1] / o[i + 1] # buy next open, sell close of t+4? (H+1 bars later)
| |||
df["fwd"] = fwd * 1e4; df["cost"] = d["cost"] / np.r_[o[1:], o[-1]] * 1e4
| |||
rows.append(df)
| |||
X = pd.concat(rows, ignore_index=True).dropna(subset=["r1", "r5", "vrp", "fwd"])
| |||
X["cell"] = (pd.cut(X.vrp, [-1e9, 0, 5, 1e9], labels=False) * 45 + pd.cut(X.r1, [-1, -.01, .01, 1], labels=False) * 15
| |||
+ pd.cut(X.r5, [-1, -.02, .02, 1], labels=False) * 5 + X.dow).astype(int)
| |||
X["year"] = X.date.dt.year
| |||
return X
| |||
| |||
def procedure(X, cell, years=range(2013, 2027), K=5, MINN=40, TH=2.5):
| |||
pnl = []
| |||
for Y in years:
| |||
tr, te = X.year < Y, X.year == Y
| |||
g = X[tr].assign(cell=cell[tr]).groupby("cell").fwd
| |||
m, sd, nn = g.mean(), g.std(), g.count(); mu = X[tr].fwd.mean()
| |||
t = (m - mu) / (sd / np.sqrt(nn)); t = t[(nn >= MINN) & (t.abs() >= TH)]
| |||
pick = t.reindex(t.abs().sort_values(ascending=False).index)[:K]
| |||
for cl, tv in pick.items():
| |||
sel = X[te & (cell == cl)]
| |||
if len(sel): pnl += list(np.sign(tv) * (sel.fwd + sel.cost) - sel.cost) # direction flips the gross, cost always paid
| |||
return np.array(pnl)
| |||
| |||
if __name__ == "__main__":
| |||
X = build(); cell = X.cell.copy()
| |||
obs = procedure(X, cell)
| |||
print(f"cells {X.cell.nunique()} rows {len(X)} OOS selected trades {len(obs)} mean net {obs.mean():.1f} bp hit {(obs>0).mean():.1%}")
| |||
rng = np.random.default_rng(0); null = []
| |||
for k in range(500):
| |||
sh = X.groupby("sym").cell.transform(lambda v: rng.permutation(v.to_numpy()))
| |||
p = procedure(X, sh); null.append(p.mean() if len(p) else np.nan)
| |||
null = np.array(null); null = null[~np.isnan(null)]
| |||
print(f"null mean {null.mean():.1f} sd {null.std():.1f} p(null>=obs) = {(null >= obs.mean()).mean():.3f}")
| |||
print("VERDICT:", "PASS" if obs.mean() > 0 and (null >= obs.mean()).mean() <= .05 else "REJECTED")
|