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>
84 lines
3.5 KiB
Python
84 lines
3.5 KiB
Python
"""
|
|
Industry momentum (Moskowitz & Grinblatt 1999) on S&P 500 members, with
|
|
industries from SEC SIC codes (data/research/sec_sic.csv, written by
|
|
research/lab/fetch_lazy_prices.py) grouped to 2-digit SIC.
|
|
|
|
PYTHONPATH=. uv run --with numba python -m research.lab.industry
|
|
|
|
Industry return = equal-weight mean of its PIT members' daily returns (groups
|
|
with fewer than 3 members that day are left out). Scores, known at each close:
|
|
ind_mom_6_1 industry return over months 6..1 (skip the last month)
|
|
ind_mom_12_1 same over 12..1
|
|
Uses: (a) a sleeve holding the top-20 stocks by their industry's momentum,
|
|
(b) the momentum sleeve restricted to stocks in the top half of industries.
|
|
Alpha against QQQ, 2006-2016 vs 2017-2026.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import os
|
|
import warnings
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from research.lab.factors import _ret
|
|
|
|
warnings.filterwarnings("ignore")
|
|
SIC = os.path.join("data", "research", "sec_sic.csv")
|
|
|
|
|
|
def industries(p) -> pd.Series:
|
|
s = pd.read_csv(SIC).drop_duplicates("ticker").set_index("ticker")["sic"]
|
|
s = pd.to_numeric(s, errors="coerce").dropna().astype(int) // 100
|
|
return s.reindex(p.close.columns)
|
|
|
|
|
|
def industry_momentum(p, lookback: int, skip: int = 21) -> pd.DataFrame:
|
|
"""Each stock gets its industry's cumulative return over [t-lookback, t-skip]."""
|
|
ind = industries(p)
|
|
r = _ret(p).where(p.member)
|
|
out = pd.DataFrame(np.nan, index=p.dates, columns=p.close.columns)
|
|
for g in ind.dropna().unique():
|
|
cols = list(ind.index[ind == g])
|
|
n = r[cols].notna().sum(axis=1)
|
|
gr = r[cols].mean(axis=1).where(n >= 3)
|
|
cum = np.log1p(gr.fillna(0.0)).rolling(lookback - skip, min_periods=(lookback - skip) // 2).sum().shift(skip)
|
|
out[cols] = np.repeat(cum.where(n >= 3).to_numpy()[:, None], len(cols), axis=1)
|
|
return out.where(p.member)
|
|
|
|
|
|
def main() -> None:
|
|
from research.lab.alpha import alpha_stats, bench_returns, fmt_alpha
|
|
from research.lab.factor_scan import load
|
|
from research.lab.rotation import factor_ranks
|
|
from research.lab.us_sweep import _blend, run
|
|
|
|
p, _ = load("us")
|
|
ind = industries(p)
|
|
print(f"{ind.notna().sum()}/{len(ind)} tickers with SIC; {ind.nunique()} 2-digit groups")
|
|
b = bench_returns(p.dates)
|
|
pr = factor_ranks(p, "us")
|
|
mom = _blend(pr["overnight_mom"], pr["rec_126"])
|
|
span = [("2006-09-27", "2026-09-24")]
|
|
|
|
def show(name, r):
|
|
a, d, h = alpha_stats(r, b), alpha_stats(r.loc[:"2016"], b), alpha_stats(r.loc["2017":], b)
|
|
print(f"{fmt_alpha(name, a)} | 06-16 a {d['alpha']*100:5.1f}% t {d['t']:5.2f} | 17-26 a {h['alpha']*100:5.1f}% t {h['t']:5.2f}", flush=True)
|
|
|
|
for lb in (126, 252):
|
|
im = industry_momentum(p, lb)
|
|
rk = im.rank(axis=1, pct=True)
|
|
show(f"industry mom {lb}d top20 e21", run(p, rk, span, top_n=20, buffer=2.0, every=21)["_rets"])
|
|
tie = rk + 0.001 * pr["mom_12_1"].fillna(0) # stocks of the best industries, by own momentum
|
|
show(f"industry mom {lb}d (+stock tiebreak) top20", run(p, tie.where(p.member), span, top_n=20, buffer=2.0, every=21)["_rets"])
|
|
keep = rk >= 0.5
|
|
pm = dataclasses.replace(p, member=p.member & keep.fillna(False))
|
|
show(f"momentum top10, top-half industries ({lb}d)", run(pm, mom.where(pm.member), span, top_n=10, buffer=2.0, every=10)["_rets"])
|
|
show("momentum top10 (reference)", run(p, mom.where(p.member), span, top_n=10, buffer=2.0, every=10)["_rets"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|