forked from mnbvc188199/Warrior_EA
122 lines
5.4 KiB
Python
122 lines
5.4 KiB
Python
|
|
"""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()
|