research: separate S&P 400 sleeve family vs IJH (all REJECT)
After S&P 400 names failed inside the large-cap momentum top 10, test them as their own sleeve: top 30, monthly, buffer 1, 20 bp, judged by alpha vs IJH and by whether 1/3 of the book in the sleeve beats 1/3 in IJH. Four hypotheses and a control, definitions and rule written before the run. Holdout 2017-01..2026-09: mid-cap momentum -1.0%/yr vs IJH (corr 0.83 with the large-cap book), lottery-excluded momentum -1.7%, SUE earnings momentum -1.7%, quality-screened momentum +3.5% (t 1.28), quality composite +3.4% (t 1.23; book 0.91 vs 0.85 with IJH). Quality is positive in both halves but underpowered; nothing is adopted. fetch_us_fundamentals gains --universe mid: EDGAR facts for S&P 400 ever-members, CIKs accepted only when an SEC name matches the iShares holding name. fundamentals.load_mid builds the same factors on that panel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,8 +4,9 @@ every S&P 500 ever-member in the research price panel, plus Yahoo split
|
||||
history (needed to turn split-adjusted prices back into market caps).
|
||||
|
||||
uv run python -m research.lab.fetch_us_fundamentals
|
||||
uv run python -m research.lab.fetch_us_fundamentals --universe mid # S&P 400 (IJH) ever-members
|
||||
|
||||
Writes data/research/us_fund_raw.pkl:
|
||||
Writes data/research/us_fund_raw.pkl (us_mid_fund_raw.pkl for --universe mid):
|
||||
{"facts": DataFrame[ticker, tag, start, end, val, filed, form],
|
||||
"splits": DataFrame (dates x tickers, split ratio on split days)}
|
||||
|
||||
@@ -26,6 +27,7 @@ import yfinance as yf
|
||||
from research.lab.fetch_us import _proxy_env
|
||||
|
||||
OUT = os.path.join("data", "research", "us_fund_raw.pkl")
|
||||
MID_OUT = os.path.join("data", "research", "us_mid_fund_raw.pkl")
|
||||
UA = {"User-Agent": "quant-research/0.1 gahow@example.com", "Accept-Encoding": "gzip"}
|
||||
FORMS = {"10-K", "10-Q", "10-K/A", "10-Q/A", "10-KT", "20-F", "40-F"}
|
||||
|
||||
@@ -137,10 +139,79 @@ def fetch_splits(tickers: list[str], chunk: int = 60) -> tuple[pd.DataFrame, pd.
|
||||
return cat(splits).fillna(0.0), cat(closes)
|
||||
|
||||
|
||||
_SUFFIXES = {"INC", "CORP", "CORPORATION", "CO", "COMPANY", "LTD", "PLC", "HOLDINGS", "HOLDING",
|
||||
"GROUP", "THE", "CLASS", "CL", "A", "B", "NEW", "LLC", "LP", "SA", "NV", "TRUST"}
|
||||
|
||||
|
||||
def _name_tokens(name: str) -> set[str]:
|
||||
words = "".join(ch if ch.isalnum() else " " for ch in str(name).upper()).split()
|
||||
return {w for w in words if w not in _SUFFIXES and len(w) > 1}
|
||||
|
||||
|
||||
def mid_ciks(tickers: list[str]) -> tuple[dict[str, int], pd.DataFrame]:
|
||||
"""CIKs for S&P 400 tickers. Candidates come from sec_sic.csv and
|
||||
mid_sec_sic.csv (earlier runs), then today's company_tickers.json; both
|
||||
map a ticker to whoever holds it NOW, so a candidate counts only if one of
|
||||
its SEC names (current or former) shares a word with an iShares holding
|
||||
name of that ticker. Returns the accepted map and a per-ticker audit."""
|
||||
import json
|
||||
from research.lab.fetch_us_mid import load_holdings
|
||||
base = os.path.join("data", "research")
|
||||
cand: dict[str, int] = {}
|
||||
for f in ("sec_sic.csv", "mid_sec_sic.csv"):
|
||||
d = pd.read_csv(os.path.join(base, f)).dropna(subset=["cik"])
|
||||
cand.update({t: int(c) for t, c in zip(d["ticker"], d["cik"]) if t not in cand})
|
||||
live = cik_map()
|
||||
for t in tickers:
|
||||
cand.setdefault(t, live.get(t, 0))
|
||||
names: dict[str, list[str]] = {}
|
||||
for f in ("sec_names.json", "mid_sec_names.json"):
|
||||
with open(os.path.join(base, f)) as fh:
|
||||
names.update(json.load(fh))
|
||||
for t in tickers:
|
||||
c = cand.get(t, 0)
|
||||
if c and str(c) not in names:
|
||||
r = requests.get(f"https://data.sec.gov/submissions/CIK{c:010d}.json", headers=UA, timeout=60)
|
||||
time.sleep(0.15)
|
||||
if r.ok:
|
||||
js = r.json()
|
||||
names[str(c)] = [js.get("name", "")] + [x.get("name", "") for x in js.get("formerNames", [])]
|
||||
hold = load_holdings()
|
||||
hnames = hold[hold["fund"] == "IJH"].groupby("ticker")["name"].apply(lambda s: set(s.dropna()))
|
||||
rows, ok = [], {}
|
||||
for t in tickers:
|
||||
c = cand.get(t, 0)
|
||||
sec = names.get(str(c), []) if c else []
|
||||
hw = set().union(*[_name_tokens(n) for n in hnames.get(t, set())]) if t in hnames else set()
|
||||
sw = set().union(*[_name_tokens(n) for n in sec]) if sec else set()
|
||||
# token overlap, or the same leading letters once spacing and
|
||||
# punctuation are gone (MACYS / Macy's, SIRIUSXM / Sirius XM, VF / V F)
|
||||
squash = lambda n: "".join(w for w in "".join(ch if ch.isalnum() else " " for ch in str(n).upper()).split()
|
||||
if w not in _SUFFIXES)
|
||||
hs = {squash(n)[:5] for n in hnames.get(t, set())} - {""}
|
||||
ss = {squash(n)[:5] for n in sec} - {""}
|
||||
match = bool(hw & sw) or bool(hs & ss)
|
||||
if c and match:
|
||||
ok[t] = c
|
||||
rows.append({"ticker": t, "cik": c or None, "ishares_names": " | ".join(sorted(hnames.get(t, set()))),
|
||||
"sec_names": " | ".join(sec), "accepted": bool(c and match)})
|
||||
return ok, pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import sys
|
||||
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
|
||||
tickers = [t for t in pickle.load(fh)["close"].columns]
|
||||
global OUT
|
||||
if "--universe" in sys.argv and sys.argv[sys.argv.index("--universe") + 1] == "mid":
|
||||
from research.lab.panel_mid import load_us_mid
|
||||
OUT = MID_OUT
|
||||
tickers = list(load_us_mid("mid").close.columns)
|
||||
ciks, audit = mid_ciks(tickers)
|
||||
audit.to_csv(os.path.join("data", "research", "us_mid_fund_cik_audit.csv"), index=False)
|
||||
print(f"{len(ciks)}/{len(tickers)} S&P 400 tickers have a name-checked CIK", flush=True)
|
||||
else:
|
||||
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
|
||||
tickers = [t for t in pickle.load(fh)["close"].columns]
|
||||
ciks = None
|
||||
if "--yahoo-only" in sys.argv: # refresh splits/closes of an existing file
|
||||
with open(OUT, "rb") as fh:
|
||||
saved = pickle.load(fh)
|
||||
@@ -150,7 +221,7 @@ def main() -> None:
|
||||
pickle.dump(saved, fh)
|
||||
print(f"splits: {int((saved['splits'] > 0).sum().sum())} events; saved {OUT}")
|
||||
return
|
||||
ciks = cik_map()
|
||||
ciks = ciks if ciks is not None else cik_map()
|
||||
todo = [(t, ciks[t]) for t in tickers if t in ciks]
|
||||
print(f"{len(todo)}/{len(tickers)} tickers map to a CIK", flush=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user