""" Quick check: is the volatility risk premium (short VIX futures, out when the VIX term structure inverts) a stream with a Sharpe high enough to lever toward 50% a year, and does it survive 2008, 2018-02 and 2020-03? PYTHONPATH=. uv run python -m research.lab.short_vol [--no-proxy] Data (Yahoo): ^SHORTVOL Cboe short VIX futures index from 2005-12: daily -1x short-term VIX futures (daily corr with -VIXY 0.987); treated as an excess return, so collateral earns the T-bill ^VIX, ^VIX3M spot VIX and 3-month VIX (from 2006-07) Executable as SVXY (-0.5x, 0.95%/yr) or short VX / VXM futures. Strategy (Cooper 2013 "Easy Volatility Investing", published 2013-11): signal short vol for day t+1 when VIX/VIX3M at the close of t < 1 (contango) size weight = min(1, 20% / EWMA-21 vol of ^SHORTVOL through t) return rf + w x shortvol - 0.95%/yr x w - 10 bp x |dw| Reported only: no filter; filter at 0.95; 30% vol target; unlevered -0.5x (SVXY-like, no sizing). Periods: 2006-07..2013-12 (before publication, includes 2008) and 2014-01..2026-09 (after). The rule is written to prereg.json before the run; the script refuses to overwrite results.json. The project has not tested volatility selling before. Result (2026-10-08): STOP. C2 and C4 pass; C1 and C3 fail. - vrp_ts_vt20, full span: 12.9% / Sharpe 0.61 / MaxDD -36.5%. - Before publication (2006-13): Sharpe 1.05. - After (2014-26): 7.8% / Sharpe 0.37. - Worst day -15.7% (2007-02-27). Bad years: 2018 -24%, 2024 -10%, 2020 -8%. - The term-structure filter holds MaxDD to -36% (no filter: -52%) but does not lift the post-2014 Sharpe (0.33-0.37 in every variant). - SVXY-like -0.5x held throughout: 2014-26 Sharpe 0.52, MaxDD -64%, worst day -21% (2024-08-05). - Monthly corr: SPY 0.58, TrendRider 0.33. It is equity beta with a short crash option, not a diversifier. Interpretation: the volatility premium shrank after 2013. What is left does not pay for the tails it carries, at any leverage. """ from __future__ import annotations import argparse import json import os import numpy as np import pandas as pd from research.lab.factor_scan import RESULTS from research.lab.levered_book import stats, tbill OUT = os.path.join(RESULTS, "short_vol") PRE, POST = ("2006-08-01", "2013-12-31"), ("2014-01-01", "2026-09-24") FEE, COST = 0.0095, 0.001 PREREG = { "question": "Is term-structure-filtered short VIX futures a high-Sharpe stream that survives its crashes?", "primary": "vrp_ts_vt20: short ^SHORTVOL when VIX/VIX3M < 1 at the prior close, sized to 20% vol (cap 1x)", "continue_if_all": [ "C1 2014-2026 (post-publication) excess Sharpe >= 1.0", "C2 2006-2013 excess Sharpe >= 0.5", "C3 full-span MaxDD >= -35% and worst single day >= -10%", "C4 monthly corr with TrendRider V10 <= 0.5", ], "decision": {"CONTINUE": "preregister a VRP sleeve in the book (instrument SVXY / VXM, leverage, tail limits)", "STOP": "drop short volatility"}, } def strategy(sv: pd.Series, ratio: pd.Series, rf: pd.Series, thr: float | None, target: float | None, fixed: float | None = None) -> tuple[pd.Series, pd.Series]: vol = sv.ewm(span=21, min_periods=21).std() * np.sqrt(252) if fixed is not None: w = pd.Series(fixed, index=sv.index) else: w = (target / vol).clip(upper=1.0) if thr is not None: w = w.where(ratio < thr, 0.0) w = w.shift(1).fillna(0.0) ret = rf / 252 + w * sv.fillna(0.0) - w * FEE / 252 - w.diff().abs().fillna(0.0) * COST return ret, w def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--no-proxy", action="store_true") if not ap.parse_args().no_proxy: from research.lab.fetch_us import _proxy_env _proxy_env() os.makedirs(OUT, exist_ok=True) res_path = os.path.join(OUT, "results.json") if os.path.exists(res_path): raise SystemExit(f"{res_path} exists; this study does not overwrite results") with open(os.path.join(OUT, "prereg.json"), "w") as fh: json.dump(PREREG, fh, indent=2) from research.lab.fetch_long_history import fetch_yahoo c, _ = fetch_yahoo(["^SHORTVOL", "^VIX", "^VIX3M"], start="2005-01-01") c = c.loc[:POST[1]].dropna(subset=["^SHORTVOL"]) sv = c["^SHORTVOL"].pct_change(fill_method=None) ratio = c["^VIX"] / c["^VIX3M"] rf = tbill(c.index).fillna(0.0) variants = { "vrp_ts_vt20": (1.0, 0.20, None), "vrp_ts95_vt20": (0.95, 0.20, None), "vrp_ts_vt30": (1.0, 0.30, None), "vrp_nofilter_vt20": (None, 0.20, None), "svxy_like_half_nofilter": (None, None, 0.5), } R, res = {}, {"variants": {}} for k, (thr, tgt, fixed) in variants.items(): r, w = strategy(sv, ratio, rf, thr, tgt, fixed) r = r.loc[PRE[0]:POST[1]] R[k] = r row = {} for name, (a, b) in {"full": (PRE[0], POST[1]), "pre": PRE, "post": POST}.items(): s = stats(r.loc[a:b], rf.loc[a:b]) row[name] = {x: float(s[x]) for x in ("cagr", "sharpe", "vol", "mdd", "worst_yr")} row["worst_day"] = float(r.min()) row["worst_days"] = {str(d.date()): float(v) for d, v in r.nsmallest(5).items()} row["time_in"] = float((w.loc[PRE[0]:] > 0).mean()) row["avg_w"] = float(w.loc[PRE[0]:].mean()) res["variants"][k] = row f, p, q = row["full"], row["pre"], row["post"] print(f"{k:24s} full {f['cagr']*100:6.1f}% xsh {f['sharpe']:5.2f} mdd {f['mdd']*100:6.1f}% | pre xsh " f"{p['sharpe']:5.2f} mdd {p['mdd']*100:6.1f}% | post xsh {q['sharpe']:5.2f} cagr {q['cagr']*100:5.1f}% " f"mdd {q['mdd']*100:6.1f}% | worst day {row['worst_day']*100:5.1f}% in {row['time_in']:.2f} " f"avg w {row['avg_w']:.2f}") print(" worst days:", {d: round(v * 100, 1) for d, v in row["worst_days"].items()}) yr = (1 + R["vrp_ts_vt20"]).groupby(R["vrp_ts_vt20"].index.year).prod() - 1 print("vrp_ts_vt20 yearly %:", (yr * 100).round(1).to_dict()) res["yearly_primary"] = {int(k): float(v) for k, v in yr.items()} S = pd.read_pickle(os.path.join(RESULTS, "stream_portfolio", "streams.pkl")) m = lambda x: (1 + x.dropna()).resample("ME").prod() - 1 res["corr_trend"] = float(m(R["vrp_ts_vt20"]).corr(m(S["trend"]))) res["corr_spy"] = float(m(R["vrp_ts_vt20"]).corr(m(S["spy"]))) print(f"monthly corr: TrendRider {res['corr_trend']:.2f}, SPY {res['corr_spy']:.2f}") p = res["variants"]["vrp_ts_vt20"] checks = {"C1": p["post"]["sharpe"] >= 1.0, "C2": p["pre"]["sharpe"] >= 0.5, "C3": p["full"]["mdd"] >= -0.35 and p["worst_day"] >= -0.10, "C4": res["corr_trend"] <= 0.5} res["verdict"] = {"checks": checks, "decision": "CONTINUE" if all(checks.values()) else "STOP"} print(f"checks {checks} -> {res['verdict']['decision']}") with open(res_path, "w") as fh: json.dump(res, fh, indent=2) pd.DataFrame(R).to_pickle(os.path.join(OUT, "returns.pkl")) from research.lab.trials import append_trials append_trials(os.path.join(RESULTS, "trials_us.jsonl"), [{"name": f"short_vol:{k}", **v["full"]} for k, v in res["variants"].items()], {"study": "short_vol", "role": "preregistered quick check"}) if __name__ == "__main__": main()