research: options and futures for leverage and S&P 500 put hedges in $10k-$100k accounts (no route reaches 50%; puts FAIL)

Reg T is the binding limit: 3x ETFs need 75% margin and stocks 50%, so the
three-sleeve overnight book caps at L 1.71, about 36%/yr at MaxDD -27%.
MES futures add S&P beta, not the book's alpha. Box financing adds about
+0.9pp if a Reg T account allows it (unverified). 90%-strike calls do worse.
Puts held to expiry, reconstructed from Cboe PPUT3M/PPUT, cost 2.0-2.5%/yr
per unit of notional and buy at most +1.0pp alongside futures (gate +2pp).
At $10k no futures or index-option contract fits the account.

v1 built the hedge as the daily difference PPUT3M - S&P 500 TR. That embeds
a short S&P position equal to the put's value, so v2 holds the put to
expiry. Both are recorded; the A verdict does not depend on the hedge model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:02:45 -07:00
parent 31a684fe70
commit 4ab86d47b7
2 changed files with 564 additions and 0 deletions

View File

@@ -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)

View File

@@ -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)