Files
quant-trading/research/lab/short_horizon.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

107 lines
4.6 KiB
Python

"""
Short-horizon and residual stock sleeves for the US book: do they add a
return source the three current sleeves (momentum, low risk, trend) lack?
PYTHONPATH=. uv run --with numba python -m research.lab.short_horizon
Candidates, price/volume only (S&P 500 PIT, next-open fills, 10 bp):
resid_mom residual 12-1 momentum / residual vol (Blitz, Huij & Martens 2011;
Blitz, Hanauer & Vidojevic 2020)
stmom_hiturn 1-month momentum among high-turnover names (Medhat & Schmeling 2022)
rev_sector_21 industry-adjusted 1-month reversal (Da, Liu & Schaumburg 2014)
rev_resid_21 residual 1-month reversal
seasonality same-calendar-month return, years 1-10 (Heston & Sadka 2008)
short_combo price-only subset of the Robeco short-horizon composite (Blitz et
al. 2023): short-term momentum for high-turnover names, residual
reversal for the rest, seasonality, low 1-month idiosyncratic vol
Each is run as top 20 with a rank buffer (hold while rank <= 40), at two
rebalance speeds. Reports stand-alone stats, monthly correlation with the
current sleeves (data/research/results/us_final_sleeves.pkl, from us_final),
and the 2011-2026 book with the candidate as a fourth equal sleeve.
Last, concentration: the momentum sleeve (overnight + rec_126) with 3 or 5
names instead of 10, the other thing a small account can do that a fund
cannot.
"""
from __future__ import annotations
import os
import warnings
import pandas as pd
from research.lab.factor_scan import RESULTS, load
from research.lab.rotation import factor_ranks
from research.lab.us_final import describe, line, monthly_blend
from research.lab.us_sweep import _blend, fmt, log_trials, run
warnings.filterwarnings("ignore")
SPAN = ("2011-01-03", "2026-09-24")
LONG = ("2006-09-27", "2026-09-24")
def candidates(p) -> dict[str, pd.DataFrame]:
pr = factor_ranks(p, "us")
hi = pr["stmom_hiturn"].notna()
directional = pr["stmom_hiturn"].where(hi, pr["rev_resid_21"])
return {
"resid_mom": pr["resid_mom"],
"stmom_hiturn": pr["stmom_hiturn"],
"rev_sector_21": pr["rev_sector_21"],
"rev_resid_21": pr["rev_resid_21"],
"seasonality": pr["seasonality"],
"short_combo": _blend(directional, pr["seasonality"], pr["ivol_20"]),
"resid_mom+short_combo": _blend(pr["resid_mom"], directional, pr["seasonality"], pr["ivol_20"]),
}
def main() -> None:
p, _ = load("us")
base = pd.read_pickle(os.path.join(RESULTS, "us_final_sleeves.pkl"))
cur = base[["momentum", "lowrisk", "trend"]]
rows, R = [], {}
for name, score in candidates(p).items():
for every in (5, 21):
st = run(p, score.where(p.member), [LONG], top_n=20, buffer=2.0, every=every)
key = f"{name} e{every}"
R[key] = st["_rets"]
d = describe(st["_rets"].loc[SPAN[0]:])
dl = describe(st["_rets"])
rows.append({"score": key, **{k: v for k, v in st.items() if not k.startswith("_")}})
print(f"{line(key + ' 2011-', d)} turn {st['turn']:4.1f}x | 2006- sh {dl['sharpe']:.2f} "
f"mdd {dl['mdd']*100:5.1f}%", flush=True)
log_trials(rows, "short_horizon")
R = pd.DataFrame(R)
R.to_pickle(os.path.join(RESULTS, "short_horizon_sleeves.pkl"))
span = pd.concat([cur, R], axis=1).loc[SPAN[0]:SPAN[1]].fillna(0.0)
monthly = (1 + span).resample("ME").prod() - 1
print("\nmonthly correlation with current sleeves, 2011-2026:")
print(monthly.corr().loc[R.columns, ["momentum", "lowrisk", "trend"]].round(2).to_string())
three = {"momentum": 1 / 3, "lowrisk": 1 / 3, "trend": 1 / 3}
print("\n2011-2026 book, monthly rebalanced between sleeves:")
print(line("current 3 sleeves", describe(monthly_blend(span, three))))
for c in R.columns:
four = {"momentum": .25, "lowrisk": .25, "trend": .25, c: .25}
swap = {"momentum": 1 / 3, c: 1 / 3, "trend": 1 / 3}
print(line(f"+ {c} (4 x 1/4)", describe(monthly_blend(span, four))))
print(line(f" {c} replaces lowrisk", describe(monthly_blend(span, swap))))
print("\nconcentration: overnight + rec_126, every 10 days, 2006-2026")
pr = factor_ranks(p, "us")
mom = _blend(pr["overnight_mom"], pr["rec_126"]).where(p.member)
blocks = [(LONG[0], "2010-12-31"), ("2011-01-01", "2015-12-31"), ("2016-01-01", "2020-12-31"),
("2021-01-01", LONG[1])]
for n in (3, 5, 10):
for buf in (1.0, 2.0):
st = run(p, mom, blocks, top_n=n, buffer=buf, every=10)
print(fmt(f"top {n} buffer {buf}", st), flush=True)
if __name__ == "__main__":
main()