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>
107 lines
4.6 KiB
Python
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()
|