Files
quant-trading/research/lab/beat_qqq.py
Gahow Wang ad569f1cea feat: us_momentum_qqq_trend; alpha-vs-QQQ research across new data sources
Register us_momentum_qqq_trend: half overnight_recovery_top10, half a QQQ
trend sleeve (strategies/qqq_trend.py: 200-day switch times a 30% vol target,
held as TQQQ + SHY). Mixed stock+ETF, US only (extra tickers QQQ, SHY, TQQQ).

Research (research/lab/): alpha against QQQ with a 2006-16 design / 2017-26
holdout split (alpha.py, qqq_scan.py, beat_qqq.py); leverage and drawdown
frontier (levered_book.py, us_target50.py); synthetic 3x ETFs from 1990 and
TrendRider/QQQ-trend stress tests (fetch_long_history.py, letf_history.py,
vol_trend.py); short-horizon sleeves, diversifiers, industry momentum,
earnings-announcement returns; new point-in-time data sources: SEC Form 4
insider trades (fetch_insider.py, insider.py), 10-K/10-Q text similarity,
8-K earnings dates and SIC codes (fetch_lazy_prices.py, lazy_prices.py), and
an S&P 400/600 universe from iShares holdings (fetch_us_mid.py, panel_mid.py).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-29 13:40:15 +08:00

140 lines
6.2 KiB
Python

"""
Can a US book beat QQQ with alpha that holds out of sample? Assembles the
sleeves that showed alpha against QQQ and checks them on 2017-2026 with
choices made on 2006-2016 only.
PYTHONPATH=. uv run --with numba python -m research.lab.qqq_scan # factor alphas
PYTHONPATH=. uv run python -m research.lab.vol_trend # QQQ trend grid
PYTHONPATH=. uv run --with numba python -m research.lab.beat_qqq
Sleeves (all long-only, 10 bp costs, next-open fills):
momentum overnight-return momentum + distance from 126d low, top 10, every
10 days, buffer 2 (the live overnight_recovery_top10)
quality mean rank of the K fundamental factors with the highest design-
period (2006-2016) alpha t-stat against QQQ in qqq_scan_factors.csv,
top 20, monthly, buffer 2. K and the list are fixed before the
holdout is looked at.
qqq_trend QQQ exposure = 1{QQQ > 200-day average} x min(1.5, 30% / EWMA vol),
held as TQQQ + 2-year Treasuries (research/lab/vol_trend.py; the
200-day rule and a vol target near QQQ's own are the textbook
choices, not fitted)
Books are equal capital, reset monthly. Everything is reported against QQQ:
alpha (Newey-West t), CAGR per half, MaxDD, and the calendar years won.
"""
from __future__ import annotations
import json
import os
import pickle
import warnings
import numpy as np
import pandas as pd
from research.lab import fundamentals as fu
from research.lab.alpha import alpha_stats, bench_returns, fmt_alpha
from research.lab.factor_scan import RESULTS, load
from research.lab.rotation import factor_ranks
from research.lab.us_final import monthly_blend
from research.lab.us_sweep import _blend, run, sector_rank
warnings.filterwarnings("ignore")
LONG = ("2006-09-27", "2026-09-24")
DESIGN_END = "2016-12-31"
def pick_fundamentals(k: int = 5) -> list[tuple[str, bool]]:
"""(factor, sector-neutral) with the best design-period alpha t vs QQQ,
one variant per factor."""
d = pd.read_csv(os.path.join(RESULTS, "qqq_scan_factors.csv"))
d = d[d["name"].str.startswith("f:")].sort_values("design_t", ascending=False)
out, seen = [], set()
for name in d["name"]:
base = name[2:].replace(" (sector)", "")
if base in seen:
continue
seen.add(base)
out.append((base, name.endswith("(sector)")))
if len(out) == k:
break
return out
def sleeves(p) -> tuple[pd.DataFrame, list]:
pr = factor_ranks(p, "us")
F = fu.load(p)
picks = pick_fundamentals()
ranks = [sector_rank(F[f], p) if sec else F[f].where(p.member).rank(axis=1, pct=True) for f, sec in picks]
quality = _blend(*ranks)
mom = _blend(pr["overnight_mom"], pr["rec_126"])
R = {"momentum": run(p, mom.where(p.member), [LONG], top_n=10, buffer=2.0, every=10)["_rets"],
"quality": run(p, quality.where(p.member), [LONG], top_n=20, buffer=2.0, every=21)["_rets"]}
with open(os.path.join(RESULTS, "vol_trend.pkl"), "rb") as fh:
R["qqq_trend"] = pickle.load(fh)["returns"]["QQQ ma200 vt30 cap1.5"].reindex(p.dates)
return pd.DataFrame(R).loc[LONG[0]:LONG[1]].fillna(0.0), picks
def show(name: str, r: pd.Series, b: pd.Series) -> dict:
a, d, h = alpha_stats(r, b), alpha_stats(r.loc[:DESIGN_END], b), alpha_stats(r.loc["2017":], b)
e = (1 + r).cumprod()
mdd = float((e / e.cummax() - 1).min())
print(f"{fmt_alpha(name, a)} mdd {mdd*100:4.0f}% | 06-16 a {d['alpha']*100:5.1f}% t {d['t']:5.2f} "
f"cagr {d['cagr']*100:5.1f} vs {d['bench_cagr']*100:5.1f} | 17-26 a {h['alpha']*100:5.1f}% "
f"t {h['t']:5.2f} cagr {h['cagr']*100:5.1f} vs {h['bench_cagr']*100:5.1f}")
return {"full": a, "design": d, "holdout": h, "mdd": mdd}
def main() -> None:
p, _ = load("us")
R, picks = sleeves(p)
b = bench_returns(R.index)
print("fundamentals picked on 2006-2016 alpha t:", picks)
m = (1 + R).resample("ME").prod() - 1
print("monthly correlation:\n", m.corr().round(2).to_string())
out = {"picks": picks}
for c in R.columns:
out[c] = show(c, R[c], b)
books = {"momentum + qqq_trend": {"momentum": .5, "qqq_trend": .5},
"momentum + quality": {"momentum": .5, "quality": .5},
"momentum + quality + qqq_trend": {"momentum": 1 / 3, "quality": 1 / 3, "qqq_trend": 1 / 3},
"momentum .5 + quality .25 + qqq_trend .25": {"momentum": .5, "quality": .25, "qqq_trend": .25}}
for name, w in books.items():
r = monthly_blend(R, w)
out[name] = show(name, r, b)
r = monthly_blend(R, books["momentum + quality + qqq_trend"])
yr = pd.DataFrame({"book": (1 + r).groupby(r.index.year).prod() - 1,
"QQQ": (1 + b).groupby(b.index.year).prod() - 1})
print("\nyearly, momentum + quality + qqq_trend vs QQQ (%):")
print((yr * 100).round(1).assign(beat=yr["book"] > yr["QQQ"]).to_string())
print("\nodds of beating QQQ over a horizon (historical rolling windows | stationary block bootstrap, 63-day blocks)")
cands = {"momentum": R["momentum"], "qqq_trend": R["qqq_trend"],
"momentum + qqq_trend": monthly_blend(R, books["momentum + qqq_trend"]),
"momentum + quality + qqq_trend": r}
rng = np.random.default_rng(0)
for name, x in cands.items():
cells = []
for years in (1, 3, 5):
h = 252 * years
lx, lb = np.log1p(x.to_numpy()), np.log1p(b.reindex(x.index).fillna(0.0).to_numpy())
cx, cb = np.r_[0, np.cumsum(lx)], np.r_[0, np.cumsum(lb)]
hist = float(((cx[h:] - cx[:-h]) > (cb[h:] - cb[:-h])).mean())
wins = 0
for _ in range(2000):
idx = []
while len(idx) < h:
s = rng.integers(0, len(lx))
idx.extend(range(s, min(s + rng.geometric(1 / 63), len(lx))))
idx = idx[:h]
wins += lx[idx].sum() > lb[idx].sum()
cells.append(f"{years}y {hist*100:3.0f}% | {wins / 20:3.0f}%")
print(f" {name:32s} " + " ".join(cells))
with open(os.path.join(RESULTS, "beat_qqq.json"), "w") as fh:
json.dump(out, fh, indent=1, default=float)
R.to_pickle(os.path.join(RESULTS, "beat_qqq_sleeves.pkl"))
if __name__ == "__main__":
main()