Warrior_EA/research/altdata/screen_ivol.py

122 lines
5.4 KiB
Python
Raw Permalink Normal View History

research(altdata): instrument-specific implied-vol screen - GVZ is a major find on gold No free historical GEX exists: probed the CBOE chain endpoint with date/dt query params (both silently ignored, returned today) and dated/historical paths (403), and the CBOE index-history CSVs are 403 too. The forward recorder stays the only path to GEX history. But the options market publishes its per-instrument view of future range as the CBOE vol indices, and FRED carries the whole family free with 15-25 years of history - screenable today with the existing collector and harness. Fetched GVZ (gold), OVX (oil), VXN, VXD, RVX, VIX3M. HEADLINE - XAUUSD: gvz_chg5 (gold IV 5-day change) MI 0.02103, MI|vol 0.01971, p=0.002. That is 3.6x the trailing-range positive control and 4.6x the vix_chg5 this project currently ships on gold - the second-largest incremental MI of the whole campaign, on a symbol that carries exactly one screened feature today. Vol-change is incremental on all four symbols: SP500 (known), USDJPY vxd_chg5 0.00492, and EURUSD vxd_chg5 0.00412 / vix_chg5 0.00379 - notable because EURUSD has no screened features at all and its own trailing range is a weak control there, so external vol carries information its own history does not. Caveats recorded in the script and memory: SP500 within-family ordering (VXN > VIX3M > VIX, all ~0.031-0.038 conditional) is a best-of-N artifact and must not be cherry-picked; XAUUSD noise control misbehaved this run (MI|vol 0.00271 p=0.002), so anything under ~0.003 conditional on gold is unresolved - gvz_chg5 at 7x that floor is unaffected; EVZ (euro IV) is DISCONTINUED since 2025-03 and must never be wired. Nothing wired - the EA is mid-deploy and this would re-key every model again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:06:28 -04:00
"""Instrument-specific implied-volatility screen.
The options market publishes its own forecast of an instrument's future range,
and CBOE's per-instrument vol indices carry 15-25 years of free daily history
on FRED - unlike GEX, which has no free history at all and has to be recorded
forward. If the options market's view of range is worth anything to us, this is
the version we can test TODAY.
Motivation: SP500's `vix`/`vix_chg5` already survived both the family-wise bar
and the incremental (conditional-on-trailing-range) test. Gold, oil, Nasdaq,
Dow and Russell each have their OWN vol index, so the obvious question is
whether the instrument-specific one beats the equity VIX we currently ship.
XAUUSD is the decisive case: it ships vix_chg5 today, and GVZ is gold's own.
Everything is measured against forward RANGE (the proven channel), with the
trailing-range positive control, a noise negative control, a family-wise bar
over the maximum of the null, and the decisive MI|vol column.
Usage:
python -m altdata.screen_ivol [--perms 499]
"""
import argparse
import numpy as np
import pandas as pd
from .common import DATA_ROOT
from .screen import _tercile, asof_join, cmi_3x3x3, load_bars, mi_3x3, true_range
#--- symbol -> (index series, human label). The cross-pairings are deliberate:
#--- an instrument's own vol index should beat a foreign one, and if it does not,
#--- that is evidence the whole family is just measuring "vol is vol".
PLAN = {
"XAUUSD": [("gvz", "GVZCLS", "GOLD's own IV"),
("vix", "VIXCLS", "equity VIX - what we ship today"),
("ovx", "OVXCLS", "OIL IV - cross-check, should be weaker")],
"SP500": [("vix", "VIXCLS", "equity VIX - known clean, the benchmark"),
("vix3m", "VXVCLS", "3-month VIX"),
("vxn", "VXNCLS", "Nasdaq IV - correlated cousin"),
("gvz", "GVZCLS", "GOLD IV - cross-check, should be weaker")],
"EURUSD": [("vix", "VIXCLS", "equity VIX"),
("vxd", "VXDCLS", "Dow IV")],
"USDJPY": [("vix", "VIXCLS", "equity VIX"),
("vxd", "VXDCLS", "Dow IV")],
}
def ivol_frame(series_id: str, name: str) -> pd.DataFrame:
f = pd.read_csv(DATA_ROOT / "fred" / f"{series_id}.csv",
parse_dates=["observed", "published"])
v = f["value"]
out = pd.DataFrame({"published": f["published"]})
out[f"{name}"] = v / 100.0 # level, same a-priori scale as vix
out[f"{name}_chg5"] = (v - v.shift(5)) / 10.0 # 5-day change
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--sym", nargs="*", default=list(PLAN))
ap.add_argument("--perms", type=int, default=499)
ap.add_argument("--seed", type=int, default=11)
args = ap.parse_args()
rng = np.random.default_rng(args.seed)
for sym in args.sym:
bars = load_bars(sym)
tr = true_range(bars)
atr = tr.ewm(alpha=1 / 14, min_periods=14).mean()
trailing = tr.rolling(5).sum() / atr
y_raw = tr.shift(-1).rolling(5).sum().shift(-4) / atr # forward 5-day range
feats = pd.DataFrame(index=bars.index)
for name, sid, _desc in PLAN[sym]:
j = asof_join(bars, ivol_frame(sid, name))
for c in j.columns:
feats[c] = j[c].to_numpy()
#--- term structure: contango = calm regime, backwardation = stress
if "vix3m" in feats and "vix" in feats:
feats["vix_term"] = feats["vix3m"] / feats["vix"].replace(0, np.nan) - 1.0
feats["CTRL_trailing_range"] = trailing
feats["CTRL_noise"] = rng.standard_normal(len(bars))
valid = y_raw.notna() & trailing.notna()
y_bin = _tercile(y_raw[valid].to_numpy())
z_bin = _tercile(trailing[valid].to_numpy())
print(f"\n=== {sym}: {int(valid.sum())} D1 bars, forward-5d range, {args.perms} perms ===")
for name, sid, desc in PLAN[sym]:
print(f" {name:6s} = {sid:8s} {desc}")
results, null_max = [], np.zeros(args.perms)
for col in feats.columns:
x = feats.loc[valid, col].to_numpy(dtype=float)
ok = ~np.isnan(x)
if ok.sum() < 500:
print(f" {col:22s} SKIPPED - only {ok.sum()} usable rows")
continue
xo, yo, zo = x[ok], y_bin[ok], z_bin[ok]
n = len(xo)
obs, obs_c = mi_3x3(xo, yo), cmi_3x3x3(xo, yo, zo)
null = np.empty(args.perms)
null_c = np.empty(args.perms)
for k in range(args.perms):
xs = np.roll(xo, rng.integers(63, n - 63))
null[k] = mi_3x3(xs, yo)
null_c[k] = cmi_3x3x3(xs, yo, zo)
p = (np.sum(null >= obs) + 1) / (args.perms + 1)
p_c = (np.sum(null_c >= obs_c) + 1) / (args.perms + 1)
results.append((col, obs, p, obs_c, p_c, n))
if not col.startswith("CTRL_"):
null_max = np.maximum(null_max, null)
fam = np.quantile(null_max, 0.95)
print(f" family-wise 5% bar (max of null over the candidates): {fam:.5f}")
for col, obs, p, obs_c, p_c, n in sorted(results, key=lambda r: -r[3]):
mark = "FAMILY" if obs > fam else " "
inc = "INCREMENTAL" if p_c <= 0.05 else " "
print(f" {col:22s} n={n:5d} MI {obs:.5f} p={p:.4f} {mark} | "
f"MI|vol {obs_c:.5f} p={p_c:.4f} {inc}")
if __name__ == "__main__":
main()