diff --git a/CLAUDE.md b/CLAUDE.md index 025648e..3a6f236 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,6 +113,7 @@ PYTHONPATH=. uv run --with numba python -m research.lab.svr_combo # low_svr2 PYTHONPATH=. uv run python -m research.lab.fetch_options snapshot|status # daily S&P 500 option chains after the close: indicative quotes (forward-only from 2026-10-12) + OPRA daily bars + print-vs-quote quality sample; data/options/us/; timer quant-trading-options PYTHONPATH=. uv run python -m research.lab.fetch_options backfill # OPRA daily option bars since 2024-02 for every S&P 500 member since then (resumable, ~2-3 h; pauses 16:10-16:55 NY); then builds volume_panel.csv.gz PYTHONPATH=. uv run python -m research.lab.fetch_options volume-panel # date x underlying option volume (all 0-100 DTE strikes and the 5-100 DTE / 0.7-1.3 band), os_volume, pc_volume +PYTHONPATH=. uv run python -m research.lab.option_overlay [fetch] # leverage routes (Reg T margin, box, MES futures, calls) and S&P 500 puts (Cboe PPUT3M/PPUT held to expiry) for $10k-$100k accounts (preregistered; 2026-10-12: no route reaches 50% under Reg T, cap L 1.71 -> 36%/yr; puts B FAIL +1.0pp) PYTHONPATH=. uv run --with numba python -m research.lab.option_volume # Johnson-So O/S and put/call volume on OPRA bars 2024-03..2026-09 (preregistered; STOP 2026-10-12: O/S sign reversed, t -1.6, corr -0.65 with momentum; put/call t 0.4) uv run python -m research.lab.fetch_us_fundamentals --universe mid # EDGAR PIT facts for S&P 400 ever-members (name-checked CIKs) uv run python -m research.lab.fetch_us_fundamentals --universe small # the same for S&P 600 (IJR) diff --git a/research/lab/option_overlay.py b/research/lab/option_overlay.py new file mode 100644 index 0000000..d12a9cf --- /dev/null +++ b/research/lab/option_overlay.py @@ -0,0 +1,563 @@ +""" +Can a $10k-$100k account lever the overnight book toward 50% a year (A), and +do S&P 500 puts make that leverage safer (B)? + + PYTHONPATH=. uv run python -m research.lab.option_overlay fetch # once: Cboe indices + S&P 500 TR + PYTHONPATH=. uv run python -m research.lab.option_overlay # v2: puts held to expiry + PYTHONPATH=. uv run python -m research.lab.option_overlay --hedge diff # v1 (kept for the record) + +Book: three_sleeve_overnight (research/lab/overnight_book.py books.pkl, the +paper-tested book: overnight momentum + lowrisk + trend_v7, equal shares), +daily 2006-10-02..2026-09-24, 1x, costs inside. Reported: +three_sleeve_overnight_12_1, two_sleeve_overnight_trend. + +Account rules (US retail Reg T margin account; portfolio margin needs $110k+ +and is out of scope): + stocks initial margin 50% (Reg T), so 2x + 3x ETFs 75% (FINRA Notice 09-53: 3x the 25% maintenance). The trend + sleeve holds them, so levering the whole book at L needs + 0.5 x stock share x L + 0.75 x 3x-ETF share x L <= 1; + equal thirds give L <= 1.71 + margin loan T-bill + 1.5% (IBKR Pro, first $100k); reported + 2.5% (IBKR Lite) + MES futures $5 x S&P 500 = $38.5k a contract (S&P 7,701, 2026-09-28); + margin 8% of notional, taken from the same equity + SPY/XSP options 100 x $778 = $77.8k a contract; MES options $38.5k + whole contracts today's notional / account size is the exposure step: + MES 3.85 / 1.54 / 0.77 / 0.385 of equity at $10k / $25k / $50k / $100k, + SPY options 7.78 / 3.11 / 1.56 / 0.78 + +Routes (A), each on a grid of book leverage L (step 0.05) and index exposure +k (whole contracts, k <= 3): + A0 reference L x book at T-bill + 1%, no cap (overnight_book's line; what + portfolio margin would allow) + A1 Reg T L x book up to the margin cap, T-bill + 1.5% + A2 Reg T + box A1 financed with SPX/XSP box spreads at T-bill + 0.5%. + Reported only: sources say a short box belongs in a portfolio + margin account; its Reg T treatment is unverified + A3 Reg T + MES A1 plus k S&P 500 futures: S&P 500 TR minus T-bill minus + 0.1%/yr; margin 0.08 k joins the cap + A4 calls A3 with the futures replaced by 90%-strike SPY calls rolled + quarterly (call = forward + put): futures + the put_10q + hedge on k, financing T-bill + 0.5%, 11% of notional paid in + cash, steps of 0.9 x the SPY-option notional +Hedges (B), added to A1 and A3: puts on c = (L - 1) + k of S&P 500 notional +(the borrowed exposure), rounded to whole contracts (c can round to 0): + put_10q (gated) 10% OTM quarterly SPX puts: Cboe PPUT3M minus the S&P 500 TR + (real SPX option prices: VWAP purchase, mid marks) + put_5m 5% OTM monthly: Cboe PPUT minus the S&P 500 TR (neighbour) + vix_tail Cboe VXTH minus the S&P 500 TR (reported; VIX calls are small + contracts, so c is not rounded) + retail cost buying at the ask instead of the VWAP: 3% of the premium on + SPY/XSP, 10% on MES options, on an assumed premium of 1.0% + (put_10q) / 0.8% (put_5m) of notional per roll +Account sizes: $10k, $25k, $50k, $100k. + +Metrics per route x account size x hedge: + M35 highest CAGR among configs with MaxDD >= -35% + D50 among configs with CAGR >= 50%, the shallowest MaxDD (or none) + halves (2006-10..2016-12, 2017-01..2026-09) CAGR, MaxDD and Calmar at the M35 config + +Gates (fixed before the Cboe hedge series were computed on): + A a route is viable at an account size if D50 exists with MaxDD >= -40% + (A0 is the reference and A2 is flagged, so neither counts) + B PASS if at $100k on A3 with SPY/XSP contracts: put_10q M35 >= unhedged + M35 + 2pp, put_5m M35 >= unhedged M35, and put_10q's M35 config has + Calmar >= the unhedged M35 config's in both halves +Decision: + A viable at a size -> write the live spec for the best viable route there + (paper first); none -> 50%/yr needs portfolio margin or a larger account; + report M35 per size + B PASS -> hedge spec (contract, strike, roll); FAIL -> no option hedge, cap + leverage by drawdown + +Data status: the book returns and their uniform-leverage table (overnight_book, +T-bill + 1%) were seen before this rule. The Cboe PPUT, PPUT3M and VXTH files +were downloaded, and only their first and last rows were printed. The Cboe PUT +(put-write), QYLD/XYLD/TAIL/SWAN/JEPI numbers were looked at the same day; put +selling is not a hedge and is not tested here. + +Result (2026-10-12, v2): A: no route viable at any account size. B: FAIL +(B1 and B3 fail; B2 passes). + +three_sleeve_overnight, 2006-10..2026-09. M35 is the best CAGR with +MaxDD >= -35%; "-" means no contract fits. + + route $10k $25k $50k $100k config at $100k + A0 reference (no cap, portfolio margin) 47.0% 47.0% 47.0% 47.0% L 2.20, MaxDD -34.6%; 50% needs L 2.35 (-36.8%) + A1 Reg T margin (T-bill + 1.5%) 36.2% 36.2% 36.2% 36.2% L 1.70 (cap), MaxDD -27.3% + A2 + box financing (Reg T unverified) 37.1% 37.1% 37.1% 37.1% L 1.70, MaxDD -27.0% + A3 + MES futures 36.2% 36.2% 36.2% 36.2% best k = 0 + A3 + MES + 10% OTM quarterly puts (SPY) - 35.6% 37.2% 37.2% L 1.60, k 0.77, puts 1.56, MaxDD -31.3% + A3 + MES + 5% OTM monthly puts (SPY) - - 36.3% 36.3% L 1.65, k 0.39, puts 0.78, MaxDD -26.2% + A1 + 10% OTM quarterly puts (SPY) - - - 33.6% L 1.70, puts 0.78, MaxDD -23.5% + A4 90%-strike calls - - - 31.8% L 1.25, k 0.70, MaxDD -35.0% + reported, $100k: three_sleeve_overnight_12_1 A1 40.9% (-25.8%), A3 + 5% puts 43.1%; + two_sleeve_overnight_trend A1 45.8% (-32.7%), A3 reaches 50% only at MaxDD -52.5% + + - The ceiling is Reg T, not the drawdown budget. 3x ETFs need 75% and + stocks 50%, so equal thirds cap L at 1.71. At the cap the book makes + 36%/yr with MaxDD -27%, and options and futures do not lift that cap. + - Futures add S&P 500 beta, not the book's alpha. Inside -35% they add + nothing to the primary book (best k = 0) and +0.5pp to the 12_1 book. + - The puts cost 2.0%/yr (10% OTM quarterly) and 2.5%/yr (5% OTM monthly) + per unit of notional, against the S&P 500 TR. On A1, puts on the borrowed + 0.78 cut MaxDD from -27.3% to -23.5% for -2.6pp of CAGR. Paired with + futures, they buy +1.0pp (37.2%) at MaxDD -31.3%; the gate asked +2pp. + The second half of that config is better and the first half worse (Calmar + fails in 2006-16). + - Small accounts: + - At $10k nothing fits: one MES is 3.85x the account and one SPY option + 7.8x. + - At $25k one MES is 1.54x, and the nearest SPY put lot over-hedges by 2x. + - v1 (option_overlay/results.json, B FAIL) built the hedge as the daily + difference PPUT3M - S&P 500 TR. That holds the puts plus a short S&P + position as large as the put's value, so it understated the 2008 payoff + (+4.2% vs the index spread's +11.4pp). v2 holds the puts to expiry, + reconstructed from the Cboe index (yearly rebuild error: median 0.30pp, + 2008 +2.9pp in the hedge's favour). The A verdict does not depend on the + hedge model. + - Decision: + - No options or futures in the live books. + - Under Reg T, a $10k-$100k account gets about 36%/yr from + three_sleeve_overnight and 41-46% from the 12_1 and two-sleeve variants. + These are backtest numbers, before any live haircut. + - 50% needs portfolio margin ($110k+ at IBKR, NEEDS EVIDENCE on current + terms) or a different book mix: levering only the stock sleeves is not + tested here. + +Boundary: k and c are held as constant fractions of equity (daily +rebalanced); real contracts drift between rolls. The margin cap uses the +book's base shares; the band blend drifts them by up to 25%. The overnight +sleeve's 1 bp a side is not yet measured (overnight_paper.py). FINRA counts +selling an overnight position and rebuying it the same day as no day trade, +but a broker's matching can differ (PDT under $25k). +""" + +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, "option_overlay") +RAW = os.path.join("data", "research", "option_overlay") +BOOKS = os.path.join(RESULTS, "overnight_book", "books.pkl") +SPAN = ("2006-10-02", "2026-09-24") +HALVES = [("2006-10-02", "2016-12-30"), ("2017-01-03", "2026-09-24")] +PRIMARY = "three_sleeve_overnight" +REPORTED_BOOKS = ["three_sleeve_overnight_12_1", "two_sleeve_overnight_trend"] +LETF_SHARE = {"three_sleeve_overnight": 1 / 3, "three_sleeve_overnight_12_1": 1 / 3, + "two_sleeve_overnight_trend": 1 / 2} + +ACCOUNTS = [10_000, 25_000, 50_000, 100_000] +SPX_LEVEL, SPY_PRICE = 7701.08, 778.10 +MES_NOTIONAL = 5 * SPX_LEVEL +SPY_OPT_NOTIONAL = 100 * SPY_PRICE +M_STOCK, M_LETF, M_FUT = 0.50, 0.75, 0.08 +REF_SPREAD, MARGIN_SPREAD, MARGIN_SPREAD_HI, BOX_SPREAD = 0.01, 0.015, 0.025, 0.005 +FUT_COST = 0.001 +CALL_DELTA, CALL_CASH = 0.9, 0.11 +HEDGES = {"put_10q": ("PPUT3M", 4, 0.010), "put_5m": ("PPUT", 12, 0.008), "vix_tail": ("VXTH", 0, 0.0)} +ASK_COST = {"spy": 0.03, "mes": 0.10} +L_GRID = np.round(np.arange(1.0, 3.0001, 0.05), 2) +K_MAX = 3.0 +DD_BUDGET, TARGET, DD_VIABLE = -0.35, 0.50, -0.40 +CBOE = "https://cdn-api.cboe.com/api/global/us_indices/daily_prices/{}_History.csv" +LONG = os.path.join("data", "research", "long_history.pkl") +# v2: puts bought at each Cboe roll and held to expiry (source index, strike / S, roll months) +STATIC = {"put_10q": ("PPUT3M", 0.90, (3, 6, 9, 12)), "put_5m": ("PPUT", 0.95, None)} + +PREREG = { + "question": "Which leverage route reaches 50%/yr on three_sleeve_overnight in a $10k-$100k Reg T account, " + "and do S&P 500 puts on the borrowed exposure raise the CAGR available at MaxDD >= -35%?", + "span": SPAN, "halves": HALVES, "book": PRIMARY, "reported_books": REPORTED_BOOKS, + "account_sizes": ACCOUNTS, + "rules": {"stock_margin": M_STOCK, "letf_3x_margin": M_LETF, "futures_margin": M_FUT, + "margin_loan": "T-bill + 1.5% (reported + 2.5%)", "box": "T-bill + 0.5% (reported, Reg T unverified)", + "contract_notional": {"MES": MES_NOTIONAL, "SPY_XSP_option": SPY_OPT_NOTIONAL, + "MES_option": MES_NOTIONAL}, + "futures_cost": "0.1%/yr", "calls": "90% strike quarterly = futures + put_10q, T-bill + 0.5%, " + "11% cash, steps 0.9 x SPY-option notional"}, + "hedges": {"put_10q (gated)": "Cboe PPUT3M - S&P 500 TR", "put_5m": "Cboe PPUT - S&P 500 TR", + "vix_tail (reported)": "Cboe VXTH - S&P 500 TR", + "size": "c = (L - 1) + k, whole contracts", "ask_cost": "3% (SPY/XSP) / 10% (MES opt) of a " + "1.0% / 0.8% premium per roll"}, + "metrics": {"M35": "max CAGR with MaxDD >= -35%", "D50": "shallowest MaxDD with CAGR >= 50%"}, + "gates": {"A": "route viable at a size if D50 exists with MaxDD >= -40% (A0 reference, A2 flagged: excluded)", + "B": "at $100k on A3 (SPY/XSP): put_10q M35 >= unhedged + 2pp; put_5m M35 >= unhedged; " + "put_10q M35 config Calmar >= unhedged M35 config Calmar in both halves"}, + "decision": {"A viable": "live spec for the best viable route at that size, paper first", + "A none": "50%/yr needs portfolio margin or a larger account; report M35 per size", + "B PASS": "hedge spec (contract, strike, roll)", + "B FAIL": "no option hedge; cap leverage by drawdown"}, + "data_status": "book returns and their T-bill+1% leverage table seen before; Cboe PPUT/PPUT3M/VXTH downloaded, " + "first/last rows only; PUT and covered-call/tail ETF summaries seen (not tested here)", +} + + +# --------------------------------------------------------------------------- +# data +# --------------------------------------------------------------------------- + +def fetch() -> None: + import requests + import yfinance as yf + from research.lab.fetch_us import _proxy_env + _proxy_env() + os.makedirs(RAW, exist_ok=True) + for name in ("PPUT", "PPUT3M", "VXTH"): + r = requests.get(CBOE.format(name), headers={"User-Agent": "Mozilla/5.0"}, timeout=60) + r.raise_for_status() + with open(os.path.join(RAW, f"{name}.csv"), "wb") as fh: + fh.write(r.content) + print(f"{name}: {len(r.content)} bytes") + h = yf.Ticker("^SP500TR").history(period="max", auto_adjust=True)["Close"] + h.index = h.index.tz_localize(None).normalize() + h.rename("SP500TR").to_csv(os.path.join(RAW, "SP500TR.csv")) + print(f"SP500TR: {len(h)} rows") + + +def load_levels(index: pd.DatetimeIndex) -> pd.DataFrame: + cols = {} + for name in ("PPUT", "PPUT3M", "VXTH"): + d = pd.read_csv(os.path.join(RAW, f"{name}.csv")) + d.index = pd.to_datetime(d["DATE"], format="%m/%d/%Y") + cols[name] = pd.to_numeric(d[name], errors="coerce") + s = pd.read_csv(os.path.join(RAW, "SP500TR.csv"), index_col=0, parse_dates=True).iloc[:, 0] + cols["SP500TR"] = s + lv = pd.DataFrame(cols).sort_index() + return lv.reindex(lv.index.union(index)).ffill().reindex(index) + + +def roll_days(index: pd.DatetimeIndex, months) -> pd.DatetimeIndex: + """Third Friday of each roll month; on a holiday, the preceding session.""" + out = [] + for p in pd.period_range(index[0], index[-1], freq="M"): + if months and p.month not in months: + continue + third = pd.date_range(p.start_time, p.end_time, freq="W-FRI")[2] + prior = index[(index <= third) & (index >= p.start_time)] + if len(prior): + out.append(prior[-1]) + return pd.DatetimeIndex(out) + + +def static_put(lv: pd.DataFrame, spx_open: pd.Series, spx_close: pd.Series, src: str, mny: float, + months) -> tuple[pd.Series, pd.Series, pd.Series]: + """The Cboe index's put, held from one roll to the next, per unit of S&P 500 notional. + + Between rolls a and b the index holds S + P (Cboe methodology 3.1), so with + y = I/I_a - T/T_a and s = T/T_a (I the Cboe index, T the S&P 500 TR) the + put's value per unit of S_a is x = y (1 + p) + p s, where p = P_a / S_a. p is + solved on the session before b from x = max(0, K - S) / S_a, with K = mny x + S_a rounded up to 5 points; on b the put settles at max(0, K - open) / S_a. + Returns dx (daily change of x inside a period), pid (period number, 0 + before the first roll) and the premium p paid at each roll (on the roll day). + """ + idx = lv.index + rolls = roll_days(idx, months) + I, T = lv[src], lv["SP500TR"] + S, O = spx_close.reindex(idx).ffill(), spx_open.reindex(idx).ffill() + dx = pd.Series(0.0, index=idx) + pid = pd.Series(0, index=idx) + prem = pd.Series(0.0, index=idx) + ps = [] + bounds = list(zip(rolls[:-1], rolls[1:])) + [(rolls[-1], idx[-1])] + for n, (a, b) in enumerate(bounds, start=1): + seg = idx[(idx >= a) & (idx <= b)] + if len(seg) < 2: + continue + y = I.loc[seg] / I.loc[a] - T.loc[seg] / T.loc[a] + s = T.loc[seg] / T.loc[a] + K = np.ceil(mny * S.loc[a] / 5) * 5 + complete = b in rolls and len(seg) >= 3 + if complete: + pre = seg[-2] + x_pre = max(0.0, K - S.loc[pre]) / S.loc[a] + p = float(np.clip((x_pre - y.loc[pre]) / (y.loc[pre] + s.loc[pre]), 0.0, 0.5)) + else: # the last, unfinished period + p = float(np.median(ps[-4:])) if ps else 0.01 + ps.append(p) + x = y * (1 + p) + p * s + x.loc[a] = p + if complete: + x.loc[b] = max(0.0, K - O.loc[b]) / S.loc[a] + dx.loc[seg[1:]] = x.diff().loc[seg[1:]].values + pid.loc[seg[1:]] = n + prem.loc[a] = p + return dx, pid, prem + + +def with_static(r: pd.Series, dx: pd.Series, pid: pd.Series, prem: pd.Series, c: float, ask: float) -> pd.Series: + """r plus puts on c x (equity at the last roll) of S&P 500 notional, held to + expiry: E_t = E_t-1 (1 + r_t) + c E_roll dx_t, so E_t / E_roll = + g_t (1 + c sum dx_u / g_u) with g the period's growth of r. The ask cost + (ask x premium x c) is charged on each roll day.""" + on = pid > 0 + g = (1 + r.where(on, 0.0)).groupby(pid).cumprod() + e = g * (1 + c * (dx / g).groupby(pid).cumsum()) + prev = e.groupby(pid).shift(1).fillna(1.0) + return (e / prev - 1).where(on, r) - c * ask * prem + + +# --------------------------------------------------------------------------- +# routes +# --------------------------------------------------------------------------- + +def margin_used(L: float, letf: float, k: float = 0.0, k_margin: float = M_FUT) -> float: + return M_STOCK * (1 - letf) * L + M_LETF * letf * L + k_margin * k + + +def steps(step: float, cap: float = K_MAX) -> list[float]: + return [round(n * step, 4) for n in range(int(cap / step) + 1)] if step <= cap else [0.0] + + +def rounded(x: float, step: float) -> float: + return float(np.floor(x / step + 0.5) * step) + + +def summarize(r: pd.Series, rf: pd.Series) -> dict: + s = stats(r, rf) + d = {"cagr": float(s["cagr"]), "mdd": float(s["mdd"]), "sharpe": float(s["sharpe"]), + "worst_yr": float(s["worst_yr"])} + for i, (a, b) in enumerate(HALVES): + h = stats(r.loc[a:b], rf) + d[f"h{i + 1}_cagr"], d[f"h{i + 1}_mdd"] = float(h["cagr"]), float(h["mdd"]) + return d + + +def configs(book: str, r: pd.Series, rf_d: pd.Series, fut_x: pd.Series, hedge, hedges: dict, + size: int) -> list[dict]: + """Every feasible config of every route at one account size. ``hedge(r, name, + c, contract)`` returns r with the named hedge on c of S&P 500 notional.""" + letf = LETF_SHARE[book] + fut_step = MES_NOTIONAL / size + spy_step, mes_opt_step = SPY_OPT_NOTIONAL / size, MES_NOTIONAL / size + call_step = CALL_DELTA * SPY_OPT_NOTIONAL / size + out = [] + + def base(L, spread): + return L * r - (L - 1) * (rf_d + spread / 252) + + def add(route, L, k, r_cfg, hedge="none", contract="-", c=0.0): + out.append({"route": route, "L": float(L), "k": float(k), "hedge": hedge, "contract": contract, + "c": float(c), "_r": r_cfg}) + + for L in L_GRID: + add("A0_reference", L, 0.0, base(L, REF_SPREAD)) + if margin_used(L, letf) <= 1 + 1e-9: + add("A1_regt", L, 0.0, base(L, MARGIN_SPREAD)) + add("A1_regt_lite_rate", L, 0.0, base(L, MARGIN_SPREAD_HI)) + add("A2_regt_box", L, 0.0, base(L, BOX_SPREAD)) + for k in steps(fut_step): + if margin_used(L, letf, k) > 1 + 1e-9: + continue + r3 = base(L, MARGIN_SPREAD) + k * (fut_x - FUT_COST / 252) + add("A3_regt_mes", L, k, r3) + for hname in hedges: + want = (L - 1) + k + options = (("vix", None),) if hname == "vix_tail" else (("spy", spy_step), ("mes", mes_opt_step)) + for contract, step in options: + c = want if step is None else rounded(want, step) + if c <= 0: + continue + for route, rr in (("A3_regt_mes", r3),) + ((("A1_regt", base(L, MARGIN_SPREAD)),) if k == 0 else ()): + add(route, L, k, hedge(rr, hname, c, contract), hname, contract, c) + for k in steps(call_step): + if k == 0 or margin_used(L, letf, k, CALL_CASH) > 1 + 1e-9: + continue + r4 = base(L, MARGIN_SPREAD) + k * (fut_x - (FUT_COST + BOX_SPREAD) / 252) + add("A4_calls", L, k, hedge(r4, "put_10q", k, "spy"), "embedded put_10q", "spy", k) + return out + + +def best(rows: list[dict]) -> tuple[dict | None, dict | None]: + ok = [x for x in rows if x["mdd"] >= DD_BUDGET] + m35 = max(ok, key=lambda x: x["cagr"]) if ok else None + hit = [x for x in rows if x["cagr"] >= TARGET] + d50 = max(hit, key=lambda x: x["mdd"]) if hit else None + return m35, d50 + + +def calmar(x: dict, h: int) -> float: + return x[f"h{h}_cagr"] / abs(x[f"h{h}_mdd"]) if x[f"h{h}_mdd"] < 0 else float("inf") + + +PREREG_V2 = { + **PREREG, + "version": 2, + "hedge_model": "puts bought at each Cboe roll and held to expiry, sized on equity at the roll; the put's " + "value per unit of S&P 500 notional is reconstructed from the Cboe index (static_put); the ask " + "cost is ask x the reconstructed premium per roll; vix_tail dropped (no VIX roll model)", + "why_v2": "v1 used the daily difference PPUT3M - S&P 500 TR, which holds puts plus a short S&P position equal " + "to the put's value; after a crash that short is large and loses in the rebound (2008: v1 hedge " + "+4.2% vs index spread +11.4pp). Found after reading v1's results; gates and every other rule unchanged.", + "data_status": PREREG["data_status"] + "; v1 results (option_overlay/results.json) seen before v2", +} + + +def main(model: str = "static") -> None: + out_dir = OUT if model == "diff" else OUT + "_v2" + os.makedirs(out_dir, exist_ok=True) + res_path = os.path.join(out_dir, "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_dir, "prereg.json"), "w") as fh: + json.dump(PREREG if model == "diff" else PREREG_V2, fh, indent=2) + + B = pd.read_pickle(BOOKS).loc[SPAN[0]:SPAN[1]] + idx = B.index + rf = tbill(idx).fillna(0.0) + rf_d = rf / 252 + lv = load_levels(idx) + ret = lv.pct_change(fill_method=None).fillna(0.0) + fut_x = ret["SP500TR"] - rf_d + res = {"model": model, "hedge_series": {}, "books": {}} + if model == "diff": + hedges = list(HEDGES) + hedge_x = {h: ret[src] - ret["SP500TR"] for h, (src, _, _) in HEDGES.items()} + + def hedge(r_, name, c, contract): + _, rolls, prem = HEDGES[name] + return r_ + c * (hedge_x[name] - rolls * ASK_COST[contract] * prem / 252) + else: + hedges = list(STATIC) + L_ = pd.read_pickle(LONG) + spx_o, spx_c = L_["open"]["^GSPC"].dropna(), L_["close"]["^GSPC"].dropna() + paths = {h: static_put(lv, spx_o, spx_c, src, mny, months) for h, (src, mny, months) in STATIC.items()} + + def hedge(r_, name, c, contract): + dx, pid, prem = paths[name] + return with_static(r_, dx, pid, prem, c, ASK_COST[contract]) + + print("=== static puts: reconstruction check (S&P 500 TR + 1 put per unit vs the Cboe index)") + for h, (src, mny, months) in STATIC.items(): + dx, pid, prem = paths[h] + p = prem[prem > 0] + rebuilt = with_static(ret["SP500TR"], dx, pid, prem, 1.0, 0.0) + yr = pd.DataFrame({"rebuilt": rebuilt, "cboe": ret[src], "sp500tr": ret["SP500TR"]}) + yr = (1 + yr).groupby(yr.index.year).prod() - 1 + err = (yr["rebuilt"] - yr["cboe"]).abs() + d = {"rolls": int(len(p)), "premium_median": float(p.median()), "premium_max": float(p.max()), + "premium_max_date": str(p.idxmax().date()), "yearly_abs_err_median": float(err.median()), + "yearly_abs_err_max": float(err.max()), "worst_err_year": int(err.idxmax()), + "cost_pa_vs_sp500tr": float(((1 + rebuilt).prod() ** (252 / len(rebuilt)) + - (1 + ret["SP500TR"]).prod() ** (252 / len(rebuilt)))), + "years": {int(y): {k: float(v) for k, v in row.items()} for y, row in yr.loc[[2008, 2009, 2020, 2022]].iterrows()}} + res["hedge_series"][h] = d + print(f" {h}: {d['rolls']} rolls, premium median {d['premium_median']*100:.2f}% max " + f"{d['premium_max']*100:.1f}% ({d['premium_max_date']}); rebuilt vs Cboe yearly |err| median " + f"{d['yearly_abs_err_median']*100:.2f}pp, max {d['yearly_abs_err_max']*100:.2f}pp ({d['worst_err_year']}); " + f"hedged S&P vs S&P {d['cost_pa_vs_sp500tr']*100:+.2f}%/yr") + print(" " + " ".join(f"{y}: rebuilt {v['rebuilt']*100:+.1f}% cboe {v['cboe']*100:+.1f}% " + f"S&P {v['sp500tr']*100:+.1f}%" for y, v in d["years"].items())) + hedge_x = {} + + if hedge_x: + print("=== hedge series (per unit of S&P 500 notional, before retail costs)") + for h, x in hedge_x.items(): + yr = (1 + x).groupby(x.index.year).prod() - 1 + d = {"mean_pa": float(x.mean() * 252), "y2008": float(yr.get(2008, np.nan)), + "y2020": float(yr.get(2020, np.nan)), "y2022": float(yr.get(2022, np.nan)), + "corr_book_monthly": float(((1 + x).resample("ME").prod() - 1).corr( + (1 + B[PRIMARY]).resample("ME").prod() - 1))} + res["hedge_series"][h] = d + print(f" {h:9s} mean {d['mean_pa']*100:+5.2f}%/yr 2008 {d['y2008']*100:+5.1f}% 2020 {d['y2020']*100:+5.1f}% " + f"2022 {d['y2022']*100:+5.1f}% monthly corr with book {d['corr_book_monthly']:+.2f}") + + all_rows = [] + for book in [PRIMARY] + REPORTED_BOOKS: + r = B[book] + cap = 1 / (M_STOCK * (1 - LETF_SHARE[book]) + M_LETF * LETF_SHARE[book]) + res["books"][book] = {"regt_cap_L": cap, "sizes": {}} + print(f"\n=== {book}: Reg T cap L <= {cap:.2f}") + for size in ACCOUNTS: + rows = configs(book, r, rf_d, fut_x, hedge, hedges, size) + for x in rows: + x.update(summarize(x.pop("_r"), rf)) + x.update({"book": book, "size": size}) + all_rows += rows + groups = {} + for x in rows: + groups.setdefault((x["route"], x["hedge"], x["contract"]), []).append(x) + table = {} + for key, g in sorted(groups.items()): + m35, d50 = best(g) + table["|".join(key)] = {"M35": m35, "D50": d50} + res["books"][book]["sizes"][size] = table + if book != PRIMARY and size != 100_000: + continue + print(f" -- ${size // 1000}k (MES step {MES_NOTIONAL / size:.2f}, SPY-option step " + f"{SPY_OPT_NOTIONAL / size:.2f})") + for key, t in table.items(): + m, d = t["M35"], t["D50"] + ms = (f"M35 {m['cagr']*100:5.1f}% (L {m['L']:.2f} k {m['k']:.2f} c {m['c']:.2f}, mdd {m['mdd']*100:5.1f}%," + f" halves {m['h1_cagr']*100:5.1f}/{m['h2_cagr']*100:5.1f}%)") if m else "M35 none" + ds = (f"D50 mdd {d['mdd']*100:5.1f}% (L {d['L']:.2f} k {d['k']:.2f} c {d['c']:.2f})") if d else "D50 none" + print(f" {key:34s} {ms} {ds}") + + # gates on the primary book + sizes = res["books"][PRIMARY]["sizes"] + viable = {} + for size in ACCOUNTS: + cands = [] + for key, t in sizes[size].items(): + if key.startswith(("A0", "A2")) or "lite_rate" in key: + continue + d = t["D50"] + if d and d["mdd"] >= DD_VIABLE: + cands.append((d["mdd"], key, d)) + cands.sort(key=lambda z: -z[0]) + viable[size] = [{"route": k, "mdd": d["mdd"], "cagr": d["cagr"], "L": d["L"], "k": d["k"], "c": d["c"]} + for _, k, d in cands] + t100 = sizes[100_000] + un, p10, p5 = (t100.get(k, {}).get("M35") for k in + ("A3_regt_mes|none|-", "A3_regt_mes|put_10q|spy", "A3_regt_mes|put_5m|spy")) + b_checks = { + "B1_put10q_plus_2pp": bool(un and p10 and p10["cagr"] >= un["cagr"] + 0.02), + "B2_put5m_not_worse": bool(un and p5 and p5["cagr"] >= un["cagr"]), + "B3_calmar_both_halves": bool(un and p10 and all(calmar(p10, h) >= calmar(un, h) for h in (1, 2))), + } + res["verdict"] = { + "A_viable_routes_by_size": viable, + "A_decision": {s: (v[0]["route"] if v else "none") for s, v in viable.items()}, + "B_checks": b_checks, "B_decision": "PASS" if all(b_checks.values()) else "FAIL", + } + print("\n=== verdict") + for s, v in viable.items(): + top = v[0] if v else None + print(f" A ${s // 1000}k: " + (f"viable, best {top['route']} mdd {top['mdd']*100:.1f}% " + f"(L {top['L']:.2f} k {top['k']:.2f} c {top['c']:.2f})" if top else "no viable route")) + print(f" B {b_checks} -> {res['verdict']['B_decision']}") + + with open(res_path, "w") as fh: + json.dump(res, fh, indent=2, default=float) + pd.DataFrame(all_rows).to_csv(os.path.join(out_dir, "configs.csv.gz"), index=False) + from research.lab.trials import append_trials + trial_rows = [] + for size, table in sizes.items(): + for key, t in table.items(): + if t["M35"]: + trial_rows.append({"name": f"option_overlay{'' if model == 'diff' else '_v2'}:{key}:{size}", + **{x: t["M35"][x] for x in ("cagr", "mdd", "sharpe", "L", "k", "c")}}) + append_trials(os.path.join(RESULTS, "trials_us.jsonl"), trial_rows, + {"study": "option_overlay" + ("" if model == "diff" else "_v2"), + "role": "preregistered; M35 config per route/size"}) + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("cmd", nargs="?", default="run", choices=["run", "fetch"]) + ap.add_argument("--hedge", choices=["static", "diff"], default="static", + help="static: puts held to expiry (v2, default); diff: daily index difference (v1)") + a = ap.parse_args() + if a.cmd == "fetch": + fetch() + else: + main(a.hedge)