281 lines
15 KiB
Python
281 lines
15 KiB
Python
"""
|
|
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.
|
|
- Two checks run after that:
|
|
- The margin-levered trend does not beat QQQ outside the crash years. From
|
|
2010, x2 earns 16.4% at MaxDD -50% vs QQQ's 19.2% at -35%. From 2006 the
|
|
two are even: 17.1% / -50% vs 16.6% / -53%.
|
|
- What does beat QQQ in every window is the overnight momentum sleeve alone
|
|
(overnight_only.py sleeve_score, 1x, no leveraged ETF), provided orders
|
|
are cheap. Cost per side = 0.2 bp measured + $0.35 / order size + 0.1 bp:
|
|
|
|
venue / account 2006-26 CAGR / MaxDD / Sharpe 2010-26 2017-26
|
|
QQQ 16.6% / -53% / 0.73 19.2% / -35% / 0.88 21.6% / -35% / 0.86
|
|
$0 commission 35.7% / -30% / 1.40 29.9% / -30% / 1.32 34.2% / -30% / 1.31
|
|
IBKR Tiered $10k (3.8bp) 13.2% / -36% / 0.59 8.3% / -36% / 0.42 12.0% / -36% / 0.51
|
|
IBKR Tiered $25k (1.7bp) 25.9% / -31% / 1.06 20.4% / -31% / 0.95 24.5% / -31% / 0.98
|
|
IBKR Tiered $50k (1.0bp) 30.4% / -31% / 1.22 24.7% / -31% / 1.12 28.9% / -31% / 1.13
|
|
|
|
Same caveats as overnight_only.py: the signal was chosen on this panel,
|
|
the panel is survivor-biased before about 2014, and live fills are
|
|
untested.
|
|
"""
|
|
|
|
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()
|