- 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>
157 lines
7.2 KiB
Python
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()
|