| """
|
| PRE-REGISTERED macro-state gates on the D1 4-index dip-z book (2026-10-08).
|
|
|
| Written BEFORE the first run. Trial counter N = 6 (3 gates x 2 directions);
|
| nothing here may be re-thresholded after seeing results -- any new variant
|
| increments N and is reported as such.
|
|
|
| Data (research/macro_data, free daily): CBOE VIX/VIX3M, FRED BAA10Y (Moody's
|
| Baa-10Y spread; FRED's ICE HY OAS is truncated to 2023-10), index closes.
|
| Causality: macro value of the SIGNAL bar's date (its close is known when the
|
| bar closes); fill is the next open, as in backtest.simulate.
|
|
|
| Gates (entry ALLOWED when true):
|
| G1a VIX/VIX3M <= 1.0 (no backwardation) G1b VIX/VIX3M > 1.0
|
| G2a VRP = VIX - RV10 > 5 G2b VRP <= 5
|
| G3a BAA10Y 5d change <= 0 (credit not widening) G3b 5d change > 0
|
| Hypotheses: block-stress (a) per the outside brief vs pay-for-stress (b) per
|
| our own finding that expectancy rises with the vol regime. Both registered.
|
|
|
| Survival rule (ALL required): vs the ungated book, ret/DD improves in 2008-09,
|
| 2022, IS (<=2016) and OOS (2017+); overall cadence stays >= 2/month; and
|
| the gated book keeps >= 40% of trades.
|
| """
|
| from __future__ import annotations
|
| import sys, numpy as np, pandas as pd
|
| sys.path.insert(0, ".")
|
| import backtest as bt, portfolio as pf
|
| from run_screen import zscore_entries
|
|
|
| MD = "macro_data/"
|
| IDX = pf.IDX
|
| STOP = 3.0
|
|
|
| def series():
|
| v = pd.read_csv(MD + "cboe_VIX.csv", parse_dates=["DATE"], date_format="%m/%d/%Y").set_index("DATE")["CLOSE"]
|
| v3 = pd.read_csv(MD + "cboe_VIX3M.csv", parse_dates=["DATE"], date_format="%m/%d/%Y").set_index("DATE")["CLOSE"]
|
| cr = pd.read_csv(MD + "BAA10Y.csv", index_col=0, parse_dates=True).iloc[:, 0].replace(".", np.nan).astype(float)
|
| return v, v3, cr
|
|
|
| def gate_table(spx_close: pd.Series):
|
| v, v3, cr = series()
|
| df = pd.DataFrame({"vix": v, "vix3m": v3, "cr": cr}).sort_index().ffill()
|
| rv = np.log(spx_close / spx_close.shift(1)).rolling(10).std() * np.sqrt(252) * 100
|
| df["rv"] = rv.reindex(df.index).ffill()
|
| df["ratio"] = df.vix / df.vix3m
|
| df["vrp"] = df.vix - df.rv
|
| df["cr5"] = df.cr - df.cr.shift(5)
|
| return df
|
|
|
| GATES = {
|
| "G1a": lambda g: g.ratio <= 1.0, "G1b": lambda g: g.ratio > 1.0,
|
| "G2a": lambda g: g.vrp > 5, "G2b": lambda g: g.vrp <= 5,
|
| "G3a": lambda g: g.cr5 <= 0, "G3b": lambda g: g.cr5 > 0,
|
| }
|
|
|
| def collect(gate=None, gt=None):
|
| out = []
|
| for s in IDX:
|
| d = bt.load(s, "PERIOD_D1")
|
| e, xma = zscore_entries(d, 20, -1.5, 0)
|
| if gate is not None:
|
| ok = gate(gt).reindex(pd.DatetimeIndex(d["ts"]).normalize()).to_numpy()
|
| ok = np.where(pd.isna(ok), False, ok).astype(bool)
|
| e = e & ok
|
| tr = bt.simulate(d, e, side=1, exit_ma=xma, max_bars=10, stop_atr=STOP)
|
| tr = bt.add_r(tr, d, stop_atr=STOP)
|
| for t in tr:
|
| t["symbol"] = s; t["exit_t"] = d["ts"][t["exit_i"]]
|
| out += tr
|
| return out
|
|
|
| WINDOWS = {"2008-09": ("2008-01-01", "2009-04-01"), "2022": ("2022-01-01", "2023-01-01"),
|
| "IS<=2016": ("2008-01-01", "2017-01-01"), "OOS2017+": ("2017-01-01", "2030-01-01"),
|
| "FULL": ("2008-01-01", "2030-01-01")}
|
|
|
| def window_stats(trades, lo, hi, risk=0.005):
|
| ts = [t for t in trades if np.datetime64(lo) <= t["t"] < np.datetime64(hi)]
|
| if len(ts) < 3: return None
|
| ts.sort(key=lambda t: t["exit_t"])
|
| eq = np.cumprod([1.0] + [1 + risk * t["r"] for t in ts]); pk = np.maximum.accumulate(eq)
|
| dd = ((pk - eq) / pk).max(); tot = eq[-1] - 1
|
| a = min(t["t"] for t in ts); b = max(t["exit_t"] for t in ts)
|
| mo = max((b - a) / np.timedelta64(365, "D") * 12, 1e-9)
|
| return dict(n=len(ts), pm=len(ts) / mo, bp=np.mean([t["ret"] for t in ts]) * 1e4,
|
| R=np.mean([t["r"] for t in ts]), tot=tot, dd=dd, rdd=(tot / dd if dd > 1e-9 else np.nan))
|
|
|
| def main():
|
| spx = bt.load("SP500", "PERIOD_D1")
|
| gt = gate_table(pd.Series(spx["c"], index=pd.DatetimeIndex(spx["ts"]).normalize()))
|
| base = collect()
|
| runs = {"BASE": base}
|
| for k, f in GATES.items():
|
| runs[k] = collect(f, gt)
|
| print(f"{'run':<6}{'window':<10}{'n':>5}{'/mo':>6}{'bp':>7}{'meanR':>7}{'tot':>8}{'maxDD':>7}{'ret/DD':>8}")
|
| res = {}
|
| for k, tr in runs.items():
|
| for w, (lo, hi) in WINDOWS.items():
|
| s = window_stats(tr, lo, hi); res[(k, w)] = s
|
| if s: print(f"{k:<6}{w:<10}{s['n']:>5}{s['pm']:>6.1f}{s['bp']:>7.1f}{s['R']:>7.3f}{s['tot']:>8.1%}{s['dd']:>7.1%}{s['rdd']:>8.2f}")
|
| else: print(f"{k:<6}{w:<10} <3 trades")
|
| print()
|
| print("SURVIVAL (rule: rdd up in all 4 sub-windows, cadence>=2/mo, >=40% of trades)")
|
| for k in GATES:
|
| ok = []
|
| for w in ("2008-09", "2022", "IS<=2016", "OOS2017+"):
|
| g, b = res[(k, w)], res[("BASE", w)]
|
| ok.append(bool(g and b and np.nan_to_num(g["rdd"], nan=-9) > np.nan_to_num(b["rdd"], nan=-9)))
|
| f = res[(k, "FULL")]
|
| keep = len(runs[k]) / len(base)
|
| card = f is not None and f["pm"] >= 2
|
| print(f"{k}: windows improved {ok} cadence {f['pm'] if f else 0:.1f}/mo kept {keep:.0%} -> {'SURVIVES' if all(ok) and card and keep >= .4 else 'REJECTED'}")
|
|
|
| if __name__ == "__main__":
|
| main()
|