research: books without leveraged ETFs vs QQQ for the $10k IBKR account (none beats QQQ on the gates)

QQQ trend held in QQQ (no TQQQ), with Reg T margin above 1x, and its blends
with the momentum sleeve, at $1 an order on $10k, compared with QQQ raw and
at QQQ's design-period volatility. No construction raises Sharpe by 0.10 in
both periods. The trend's gain is crash avoidance (MaxDD -28% vs -83% since
1990); exploratory: at 2x margin it earns 17.0% vs 14.9% with MaxDD -50%.
The single-phase momentum series behind us_momentum_qqq_trend's 27.9% was 6pp
a year above the phase-averaged, delisting-aware stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 23:44:35 -07:00
parent fd44842570
commit f2d8b53898
2 changed files with 263 additions and 0 deletions

View File

@@ -122,6 +122,7 @@ PYTHONPATH=. uv run python -m research.lab.fetch_options snapshot|status # dail
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.auction_audit [fetch] # overnight sleeve: Yahoo open/close vs the primary exchange's opening/closing cross prints (Alpaca SIP trades), 800 nights 2016-26 (preregistered; PASS 2026-10-12: +0.20 bp a side incl. SEC fee, the 1 bp backtest cost is conservative)
PYTHONPATH=. uv run python -m research.lab.no_letf_book # books without leveraged ETFs for the $10k IBKR account: QQQ trend held in QQQ (+ Reg T margin), momentum blends, vs QQQ at matched risk (preregistered; NONE BEATS QQQ 2026-10-13: Sharpe gain < 0.10; trend's gain is crashes, MaxDD -28% vs -83% since 1990; x2 margin 17.0% vs 14.9%)
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)

View File

@@ -0,0 +1,262 @@
"""
Can a book that holds no leveraged ETF beat QQQ? The live account (2026-10-13)
is $10k at IBKR Pro without the Complex or Leveraged ETP permission, trades
whole shares only (IBKR's API refuses fractional orders), and may borrow on
Reg T margin (at most 2x). The QQQ trend sleeve of us_momentum_qqq_trend held
TQQQ; here it holds QQQ itself and borrows for exposure above 1x.
PYTHONPATH=. uv run python -m research.lab.fetch_long_history # once
PYTHONPATH=. uv run python -m research.lab.no_letf_book
Constructions (fixed before the run; no parameter is searched):
qqq QQQ total return, buy and hold: the yardstick
qqq_trend_1x E = 1{QQQ > 200-day average} x min(1.0, 30% / sigma) in QQQ, the
rest in SHY; sigma = QQQ's EWMA vol (20-day half-life). The rule of
strategies/qqq_trend.py (QQQVolTrend) with the cap cut to 1x
qqq_trend_m15 the same with QQQVolTrend's own cap 1.5: exposure above 1x is
borrowed at T-bill + 1.5% (IBKR Pro under $100k)
momentum overnight_recovery_top10 held all day (stream_portfolio streams.pkl)
mom_qqq_1x momentum + qqq_trend_1x, equal capital, month-start reset when a
share is more than 25% off (managed_futures_book.band_blend)
mom_qqq_m15 momentum + qqq_trend_m15 (book exposure at most 1.25x)
Trend fills: decided on closes through t-1, traded at the close of t ("lag" in
research/lab/vol_trend.py, the live MOC timing); the book trades back to target
when the signal flips or E is more than 25% off; 10 bp per unit traded. Every
construction also pays $1 an order on a $10k account (IBKR Pro Fixed, the
commission the paper account's what-if returned): 2 orders a rebalance for the
trend (QQQ and SHY legs) and 142 a year for momentum (the web engine's 1,250
orders over 2017-12..2026-10).
Risk-matched reading: each construction is scaled by L* = (QQQ's volatility /
its own) over the design period, capped so that gross exposure stays within
Reg T's 2x; L* above 1 borrows at T-bill + 1.5%, below 1 leaves cash at the
T-bill rate. L* is fixed on the design period and applied unchanged to the
holdout.
Periods: QQQ-only constructions design 1990-2016, holdout 2017-2026-09 (QQQ
from ^NDX + 0.5%/yr dividends before 1999-03, research/lab/letf_history.py);
momentum constructions design 2006-10..2016, holdout 2017..2026-09. The
momentum signal was chosen on this panel, the panel is survivor-biased before
about 2014, and us_momentum_qqq_trend's TQQQ version was seen on 2026-09-29:
the holdout is not independent out-of-sample evidence.
"Beats QQQ" needs all three:
G1 excess Sharpe above QQQ's by at least 0.10 in the design period and the holdout
G2 MaxDD over the construction's span shallower than QQQ's over the same span
G3 risk-matched holdout CAGR above QQQ's holdout CAGR
The rule is written to prereg.json before the run; the script refuses to
overwrite results.json.
Result (2026-10-13): NONE BEATS QQQ. G1 (Sharpe +0.10 in both periods) fails
for every construction; the QQQ trends pass G2 and G3.
construction from CAGR Sharpe design / holdout Sharpe MaxDD L* matched holdout CAGR
qqq (yardstick) 1990 14.9% 0.56 0.47 / 0.86 -83% 1.00 21.6%
qqq_trend_1x 1990 12.1% 0.60 0.51 / 0.85 -28% 1.57 22.1%
qqq_trend_m15 1990 13.8% 0.57 0.47 / 0.85 -36% 1.24 24.0%
momentum 2006 18.6% 0.64 0.54 / 0.77 (QQQ .60/.86) -69% (QQQ -53%) 0.62 16.1%
mom_qqq_1x 2006 14.9% 0.69 0.55 / 0.84 -45% 0.99 19.0%
mom_qqq_m15 2006 16.4% 0.70 0.57 / 0.84 -46% 0.91 19.6%
The momentum stream is phase-averaged and carries Tiingo's delisted names
(20.3% a year before the $1 orders). The single-phase series of beat_qqq.py is
6pp a year higher with corr 0.98, so us_momentum_qqq_trend's 27.9% vs 21.5%
for 2017-26 (2026-09-29) was optimistic.
Reported after the verdict (exploratory, not gated):
- The trend's gain is crash avoidance, not Sharpe. 2000-02: +0.2% vs QQQ
-73%; 2008: -8% vs -42%; 2022: -23% vs -33%. 14 orders a year.
- Margin frontier, qqq_trend_1x x L, 1990-2026, borrowing at T-bill + 1.5%
(charged even on the SHY leg, so conservative):
x1.5 14.9% (= QQQ), holdout 21.4%, MaxDD -40%
x2.0 17.0% vs 14.9%, holdout 26.1% vs 21.6%, MaxDD -50% vs -83%,
worst year -43% (2022)
- mom_qqq_1x x1.25 matches QQQ's 2006+ MaxDD (-53%) at 17.2% vs 16.6%.
- QQQ held overnight only (gross, Yahoo opens): Sharpe 0.59 / 1.08 / 1.26 /
0.80 in 2000-09 / 2010-16 / 2017-20 / 2021-26, intraday -0.35 / 0.35 /
0.53 / 0.40. Two orders a night cost 1.9% (IBKR Tiered) to 5% (Fixed) a
year at $10k.
- IBKR's API refuses fractional orders (error 10243, what-if 2026-10-13). At
$10k the momentum half holds $500 a name; 5 of the 2026-10-12 top 10 cost
more than that.
"""
from __future__ import annotations
import json
import os
import pickle
import numpy as np
import pandas as pd
from research.lab.factor_scan import RESULTS
from research.lab.letf_history import SRC, build
from research.lab.levered_book import stats
from research.lab.managed_futures_book import band_blend
from research.lab.overnight_book import STREAMS
OUT = os.path.join(RESULTS, "no_letf_book")
END = "2026-09-24"
QQQ_SPAN, MOM_SPAN = ("1990-01-02", END), ("2006-10-02", END)
HOLDOUT = "2017-01-01"
MA, TARGET, HALF_LIFE = 200, 0.30, 20
COST, BAND = 0.001, 0.25
SPREAD = 0.015 # IBKR Pro margin: benchmark + 1.5% under $100k
ACCOUNT, PER_ORDER = 10_000.0, 1.0
MOMENTUM_ORDERS_A_YEAR = 142
REG_T = 2.0
PREREG = {
"question": "does a book without leveraged ETFs beat QQQ, raw and at QQQ's volatility",
"constructions": ["qqq", "qqq_trend_1x", "qqq_trend_m15", "momentum", "mom_qqq_1x", "mom_qqq_m15"],
"trend_rule": "1{QQQ > MA200} x min(cap, 30% / EWMA vol hl20), lag fills, 25% band, 10 bp a unit",
"costs": "$1 an order on $10k: trend 2 an rebalance, momentum 142 a year",
"financing": "T-bill + 1.5% on exposure above 1x; cash earns SHY (trend) or T-bill (scaling)",
"risk_matched": "L* = design-period vol(QQQ) / vol(construction), gross exposure <= 2x, fixed for the holdout",
"periods": {"qqq_only": [QQQ_SPAN[0], "2016-12-31", HOLDOUT, END],
"momentum": [MOM_SPAN[0], "2016-12-31", HOLDOUT, END]},
"gates": {"G1": "Sharpe >= QQQ + 0.10 in design and holdout",
"G2": "MaxDD shallower than QQQ over the same span",
"G3": "risk-matched holdout CAGR > QQQ holdout CAGR"},
"data_status": "momentum signal and the TQQQ version of the blend were examined before; not independent OOS",
}
def trend(syn: pd.DataFrame, rf: pd.Series, cap: float) -> tuple[pd.Series, pd.Series, pd.Series]:
"""Daily returns, exposure held and orders sent of the QQQ trend held in QQQ (margin above 1x)."""
px = syn["QQQ"]
r = px.pct_change().fillna(0.0)
on = (px > px.rolling(MA).mean()).astype(float)
sigma = np.sqrt((r ** 2).ewm(halflife=HALF_LIFE, min_periods=40).mean() * 252)
E = (on * (TARGET / sigma).clip(upper=cap)).fillna(0.0)
E.iloc[:MA] = 0.0
tgt = E.shift(2).fillna(0.0).to_numpy() # held over t: decided at t-2's close, traded at t-1's
rq, rs = r.to_numpy(), syn["SHY"].pct_change().fillna(0.0).to_numpy()
fin = ((rf.reindex(px.index).ffill().fillna(0.0) + SPREAD) / 252).to_numpy()
w = 0.0
out, held, orders = np.zeros(len(tgt)), np.zeros(len(tgt)), np.zeros(len(tgt))
for t in range(1, len(tgt)):
g = 1 + w * rq[t] + ((1 - w) * rs[t] if w <= 1 else -(w - 1) * fin[t])
out[t] = g - 1
w = w * (1 + rq[t]) / g
want = tgt[t + 1] if t + 1 < len(tgt) else w
if (want == 0) != (w == 0) or abs(want - w) > BAND * max(w, 1e-9):
legs = (want < 1 or w < 1) + 1 # the SHY leg trades too while either side holds cash
out[t] -= abs(want - w) * COST * legs
orders[t] = legs
w = want
held[t] = w
idx = px.index
return pd.Series(out, index=idx), pd.Series(held, index=idx), pd.Series(orders, index=idx)
def with_commissions(r: pd.Series, orders: pd.Series, scale: float = 1.0) -> pd.Series:
"""$1 an order, charged on the day it is sent, per dollar of a sleeve holding ``scale`` of the $10k account."""
return r - orders.reindex(r.index).fillna(0.0) * PER_ORDER / (ACCOUNT * scale)
def scaled(r: pd.Series, rf: pd.Series, L: float) -> pd.Series:
cash = rf.reindex(r.index).ffill().fillna(0.0) / 252
return L * r + (1 - L) * cash - max(L - 1, 0.0) * SPREAD / 252
def describe(r: pd.Series, rf: pd.Series) -> dict:
s = stats(r, rf)
yr = (1 + r).groupby(r.index.year).prod() - 1
return {"cagr": float(s["cagr"]), "sharpe": float(s["sharpe"]), "vol": float(s["vol"]), "mdd": float(s["mdd"]),
"worst_yr": float(yr.min()), "years": {int(y): float(v) for y, v in yr.items()}}
def main() -> None:
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)
with open(SRC, "rb") as fh:
src = pickle.load(fh)
syn, _ = build(src)
fred = src["fred"].ffill()
rf = (fred["DTB3"].reindex(syn.index.union(fred.index)).ffill().reindex(syn.index) / 100).fillna(0.0)
qqq = syn["QQQ"].pct_change().fillna(0.0)
t1, e1, o1 = trend(syn, rf, 1.0)
t15, e15, o15 = trend(syn, rf, 1.5)
R = pd.DataFrame({"qqq": qqq, "qqq_trend_1x": with_commissions(t1, o1),
"qqq_trend_m15": with_commissions(t15, o15)})
streams = pd.read_pickle(STREAMS)
mom = streams["momentum"].reindex(R.index)
mom_orders = pd.Series(MOMENTUM_ORDERS_A_YEAR / 252, index=R.index)
R["momentum"] = with_commissions(mom, mom_orders)
span = R.loc[MOM_SPAN[0]:MOM_SPAN[1]]
for name, leg, orders in (("mom_qqq_1x", "qqq_trend_1x", o1), ("mom_qqq_m15", "qqq_trend_m15", o15)):
# Each sleeve already pays its own orders at full-account scale; on half the
# capital the same order count costs twice as much per dollar of the sleeve.
X = pd.DataFrame({"momentum": with_commissions(mom, mom_orders, 0.5),
leg: with_commissions(t1 if leg == "qqq_trend_1x" else t15, orders, 0.5)}).loc[span.index]
R.loc[span.index, name], _ = band_blend(X, rf.reindex(span.index))
max_exposure = {"qqq": 1.0, "qqq_trend_1x": 1.0, "qqq_trend_m15": 1.5, "momentum": 1.0,
"mom_qqq_1x": 1.0, "mom_qqq_m15": 1.25}
res = {"constructions": {}, "verdict": {}}
rows = []
for name in PREREG["constructions"]:
start = QQQ_SPAN[0] if name.startswith("qqq") else MOM_SPAN[0]
r = R[name].loc[start:END].dropna()
q = R["qqq"].loc[r.index]
des, hol = r.loc[:"2016-12-31"], r.loc[HOLDOUT:]
qd, qh = q.loc[:"2016-12-31"], q.loc[HOLDOUT:]
L = min(qd.std() / des.std(), REG_T / max_exposure[name])
rl = scaled(r, rf, L)
d = {"span": [str(r.index[0].date()), END], "full": describe(r, rf), "design": describe(des, rf),
"holdout": describe(hol, rf), "qqq_full": describe(q, rf), "qqq_design": describe(qd, rf),
"qqq_holdout": describe(qh, rf), "L_star": float(L),
"matched_full": describe(rl, rf), "matched_holdout": describe(rl.loc[HOLDOUT:], rf)}
if name.startswith("qqq_trend"):
e = e1 if name == "qqq_trend_1x" else e15
o = o1 if name == "qqq_trend_1x" else o15
d["avg_exposure"] = float(e.loc[r.index].mean())
d["orders_a_year"] = float(o.loc[r.index].sum() / (len(r) / 252))
d["crashes"] = {k: float((1 + r.loc[a:b]).prod() - 1) for k, (a, b) in
{"2000-02": ("2000-01-01", "2002-12-31"), "2008": ("2008-01-01", "2008-12-31"),
"2022": ("2022-01-01", "2022-12-31")}.items()}
g = {"G1": d["design"]["sharpe"] >= d["qqq_design"]["sharpe"] + 0.10
and d["holdout"]["sharpe"] >= d["qqq_holdout"]["sharpe"] + 0.10,
"G2": d["full"]["mdd"] > d["qqq_full"]["mdd"],
"G3": d["matched_holdout"]["cagr"] > d["qqq_holdout"]["cagr"]}
d["gates"] = g
res["constructions"][name] = d
rows.append({"construction": name, "span": d["span"][0][:4], "cagr": d["full"]["cagr"],
"sharpe": d["full"]["sharpe"], "mdd": d["full"]["mdd"],
"sh_des": d["design"]["sharpe"], "sh_hold": d["holdout"]["sharpe"],
"cagr_hold": d["holdout"]["cagr"], "L*": L, "matched_cagr_hold": d["matched_holdout"]["cagr"],
"matched_mdd": d["matched_full"]["mdd"], "qqq_sh_des": d["qqq_design"]["sharpe"],
"qqq_sh_hold": d["qqq_holdout"]["sharpe"], "qqq_cagr_hold": d["qqq_holdout"]["cagr"],
"qqq_mdd": d["qqq_full"]["mdd"], **g})
T = pd.DataFrame(rows)
pd.set_option("display.width", 250)
print(T.to_string(index=False, float_format=lambda x: f"{x:.3f}"))
for name in ("qqq_trend_1x", "qqq_trend_m15"):
d = res["constructions"][name]
print(f"{name}: avg exposure {d['avg_exposure']:.2f}, {d['orders_a_year']:.1f} orders a year, "
f"crashes {({k: round(v, 3) for k, v in d['crashes'].items()})}")
passing = [n for n, d in res["constructions"].items() if n != "qqq" and all(d["gates"].values())]
res["verdict"] = {"passing": passing,
"decision": "BEATS QQQ: " + ", ".join(passing) if passing else "NONE BEATS QQQ"}
print(f"\n-> {res['verdict']['decision']}")
with open(res_path, "w") as fh:
json.dump(res, fh, indent=2)
R.to_pickle(os.path.join(OUT, "daily.pkl"))
from research.lab.trials import append_trials
append_trials(os.path.join(RESULTS, "trials_us.jsonl"),
[{"name": f"no_letf_book:{n}", **{k: v for k, v in d["full"].items() if k != "years"}}
for n, d in res["constructions"].items()],
{"study": "no_letf_book", "role": "preregistered"})
if __name__ == "__main__":
main()