Files
quant-trading/research/lab/short_vol.py
Gahow Wang 65b8db7a12 research: 50%-target search — conservative book, daily quick checks, Alpaca intraday
- managed_futures_book: three sleeves + managed futures (DBMF, tsmom24 proxy
  before 2019-05) cuts MaxDD -37% -> -27% but CAGR 16-19%, Sharpe unchanged
  (REJECT).
- small_cap_reversal / turn_of_month / short_vol: preregistered quick checks,
  all STOP (net of spread costs, post-publication decay, post-2013 VRP 0.37).
- fetch_alpaca: resume-safe Alpaca SIP intraday bars 2016+ (1-min ETFs,
  5-min liquid S&P 1500), keys from .env and never logged.
- intraday_momentum: the SPY noise-area strategy replicates (yearly corr
  1.00 with the paper) but SPY Sharpe 0.99, ~0 after publication, and only
  QQQ of five other ETFs works (STOP).
- orb_stocks_in_play: preregistered 5-min ORB replication (not yet run).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 00:58:47 -07:00

157 lines
7.2 KiB
Python

"""
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()