research: commit the 2026-10-07/08 studies left in the working tree, overnight mid/small (STOP)
Studies run by earlier sessions and documented in CLAUDE.md, but never committed: FINRA short volume (SVR) and the research-only svr_momentum veto, SEC fails-to-deliver, 13D activists and the 13D event sleeve, 13F institutional flow, GDELT news volume, S&P 400/600 Form 4 insiders, union_shield, shield_lab; plus the mid/small insider and Tiingo fetcher changes and midsmall_rebuild. overnight_midsmall: the overnight-only momentum book on S&P 400/600 earns 0.93 net Sharpe at 3 bp a side (2019-26) with corr 0.83 to the S&P 500 book (STOP). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
126
research/lab/fetch_13d.py
Normal file
126
research/lab/fetch_13d.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Fetch SC 13D / 13G filings (activist and 5% passive stakes) from EDGAR.
|
||||
|
||||
Quarterly full-index files list every filing:
|
||||
|
||||
https://www.sec.gov/Archives/edgar/full-index/YYYY/QTRn/form.idx
|
||||
|
||||
Each is ~40-60 MB fixed-width text. Rows for SC 13D, SC 13D/A, SC 13G and
|
||||
SC 13G/A are kept (filed date, CIK, company, archive path). Amendments are
|
||||
flagged via the /A suffix so an event study can use originals only.
|
||||
|
||||
Writes data/research/edgar_13d.csv (append-safe per quarter; a quarter
|
||||
already present in the CSV is refetched only with --refresh).
|
||||
|
||||
PYTHONPATH=. uv run python -m research.lab.fetch_13d [--quarters N]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
|
||||
import pandas as pd
|
||||
import requests
|
||||
|
||||
from research.lab.fetch_us import _proxy_env
|
||||
|
||||
OUT = os.path.join("data", "research", "edgar_13d.csv")
|
||||
URL = "https://www.sec.gov/Archives/edgar/full-index/{y}/QTR{q}/form.idx"
|
||||
UA = {"User-Agent": "quant-research/0.1 gahow@example.com", "Accept-Encoding": "gzip"}
|
||||
ROW = re.compile(
|
||||
r"^(?P<form>.{1,16}?)\s{2,}(?P<company>.+?)\s+(?P<cik>\d{4,10})\s+"
|
||||
r"(?P<date>\d{4}-\d{2}-\d{2})\s+(?P<file>edgar/\S+)\s*$")
|
||||
KEEP = ("SC 13D", "SC 13G")
|
||||
|
||||
|
||||
def quarters() -> list[tuple[int, int]]:
|
||||
out = []
|
||||
for y in range(2016, 2027):
|
||||
for q in range(1, 5):
|
||||
if y == 2026 and q > 3:
|
||||
continue
|
||||
out.append((y, q))
|
||||
return out
|
||||
|
||||
|
||||
def fetch_quarter(y: int, q: int) -> list[dict] | None:
|
||||
url = URL.format(y=y, q=q)
|
||||
for attempt in range(6):
|
||||
try:
|
||||
r = requests.get(url, headers=UA, timeout=300)
|
||||
r.raise_for_status()
|
||||
# SEC throttles sustained bulk downloads with small non-index pages
|
||||
if len(r.content) < 5_000_000 or "Master Index of EDGAR" not in r.text[:2000]:
|
||||
print(f" {y}Q{q}: throttled or unexpected content "
|
||||
f"({len(r.content)}B), backing off")
|
||||
time.sleep(20 * (attempt + 1))
|
||||
continue
|
||||
rows = []
|
||||
text = io.StringIO(r.text)
|
||||
for line in text:
|
||||
if not (line.startswith("SC 13D") or line.startswith("SC 13G")):
|
||||
continue
|
||||
m = ROW.match(line.rstrip("\n"))
|
||||
if not m:
|
||||
continue
|
||||
d = m.groupdict()
|
||||
if not d["form"].startswith(KEEP):
|
||||
continue
|
||||
rows.append({"quarter": f"{y}Q{q}", "form": d["form"].strip(),
|
||||
"company": d["company"].strip(), "cik": int(d["cik"]),
|
||||
"filed": d["date"], "path": d["file"]})
|
||||
return rows
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if attempt == 5:
|
||||
print(f" FAILED {y}Q{q}: {exc}")
|
||||
else:
|
||||
time.sleep(5 * (attempt + 1))
|
||||
return None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_proxy_env()
|
||||
limit = None
|
||||
if "--quarters" in sys.argv:
|
||||
limit = int(sys.argv[sys.argv.index("--quarters") + 1])
|
||||
done: set[str] = set()
|
||||
prev = None
|
||||
if os.path.exists(OUT):
|
||||
prev = pd.read_csv(OUT)
|
||||
done = set(prev["quarter"].unique())
|
||||
if "--refresh-since" in sys.argv:
|
||||
since = sys.argv[sys.argv.index("--refresh-since") + 1].upper()
|
||||
def ge(quarter: str) -> bool:
|
||||
y, q = int(quarter[:4]), int(quarter[-1])
|
||||
sy, sq = int(since[:4]), int(since[-1])
|
||||
return (y, q) >= (sy, sq)
|
||||
if prev is not None:
|
||||
prev = prev[~prev["quarter"].apply(ge)]
|
||||
done = {q for q in done if not ge(q)}
|
||||
todo = [(y, q) for (y, q) in quarters() if f"{y}Q{q}" not in done]
|
||||
if limit:
|
||||
todo = todo[:limit]
|
||||
print(f"quarters: {len(quarters())}, stored: {len(done)}, fetching: {len(todo)}")
|
||||
frames = [prev] if prev is not None else []
|
||||
for y, q in todo:
|
||||
rows = fetch_quarter(y, q)
|
||||
if rows is None:
|
||||
continue
|
||||
df = pd.DataFrame(rows)
|
||||
if len(df) == 0:
|
||||
print(f" {y}Q{q}: empty, skipped")
|
||||
continue
|
||||
frames.append(df)
|
||||
print(f" {y}Q{q}: {len(df)} SC13 rows")
|
||||
pd.concat(frames).drop_duplicates(subset=["form", "cik", "filed", "path"]).to_csv(OUT, index=False)
|
||||
time.sleep(4)
|
||||
if frames:
|
||||
allrows = pd.concat(frames).drop_duplicates(subset=["form", "cik", "filed", "path"])
|
||||
allrows.to_csv(OUT, index=False)
|
||||
print(f"stored {len(allrows)} rows -> {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user