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

373 lines
19 KiB
Python

"""
Fetch SEC "Insider Transactions Data Sets" (structured Form 3/4/5 data,
quarterly zips from 2006Q1) and keep the open-market trades of officers and
directors of S&P 500 ever-members in the research price panel.
uv run python -m research.lab.fetch_insider
Source: https://www.sec.gov/data-research/sec-markets-data/insider-transactions-data-sets
The zip links are read from that page (the 2026Q2 file moved to a new path).
Zips are cached in data/research/ext/insider/ and never downloaded twice.
Every request sends the SEC User-Agent, goes out directly (no proxy) and is
spaced >= 0.25 s from the previous one (SEC allows 10/s; we stay under 5/s).
Kept rows (NONDERIV_TRANS joined to SUBMISSION and REPORTINGOWNER):
* DOCUMENT_TYPE "4". Amendments (4/A, ~3% of Form 4s) are dropped: they
mostly restate the original's trades, so keeping both double counts, and
the original is what the market saw on its filing date.
* TRANS_CODE P (open-market purchase, acquired) or S (open-market sale,
disposed) of a non-preferred equity security, no equity swap.
* The reporting owner is an officer and/or director. Pure 10% owners and
"other" are dropped. A joint filing (rare) is attributed to one officer/
director owner (lowest CIK) so a trade is never counted twice.
* Exact duplicate lines within a filing (same date, code, shares, price,
shares owned after, ownership form) are dropped.
Issuer -> panel ticker: the current SEC ticker map (company_tickers.json) by
issuer CIK; share classes of one CIK (GOOG/GOOGL, ...) all map to it. Issuers
missing from it (renamed, reorganized, taken private) fall back to the
filing's own ISSUERTRADINGSYMBOL, limited to the dates that symbol was used
and to dates before the ticker's current owner first filed, and kept only if
trade prices match the ticker's unadjusted closes (median error < 25%).
Writes data/research/insider_trades.pkl:
{"trades": DataFrame, one row per kept transaction: ticker, cik,
insider_cik, role, title, ten_pct, filed, trans_date, code,
shares, price, value, price_ok, plan_10b51, plan_fn, ownership,
accession
"tickers": DataFrame[ticker, cik, source, first, last, n_filings, price_err]
(which issuer CIK covers each panel ticker, and when),
"unmapped": panel tickers with no issuer}
Point in time: a trade is usable from the first trading day strictly after
``filed`` (FILING_DATE). ``trans_date`` is only used to classify traders
(calendar month and year), never to decide when a trade is known.
``value`` = shares x reported price; where the price is missing or more than
2x off the issuer's usual price / unadjusted-close ratio (``price_ok`` False:
mostly total proceeds typed as the price), shares x the unadjusted close.
``plan_10b51`` is the Rule 10b5-1 checkbox (Form 4 from April 2023, NaN
before); ``plan_fn`` is True when a footnote or remark of the filing mentions
Rule 10b5-1 (available in every year).
"""
from __future__ import annotations
import csv
import os
import pickle
import re
import time
import zipfile
from urllib.parse import urljoin
import numpy as np
import pandas as pd
import requests
from research.lab.fetch_us_fundamentals import UA, cik_map
PAGE = "https://www.sec.gov/data-research/sec-markets-data/insider-transactions-data-sets"
ZIP_DIR = os.path.join("data", "research", "ext", "insider")
OUT = os.path.join("data", "research", "insider_trades.pkl")
FUND_RAW = os.path.join("data", "research", "us_fund_raw.pkl")
SUB_COLS = ["ACCESSION_NUMBER", "FILING_DATE", "DOCUMENT_TYPE", "ISSUERCIK",
"ISSUERTRADINGSYMBOL", "REMARKS", "AFF10B5ONE"]
OWN_COLS = ["ACCESSION_NUMBER", "RPTOWNERCIK", "RPTOWNER_RELATIONSHIP", "RPTOWNER_TITLE"]
TRN_COLS = ["ACCESSION_NUMBER", "SECURITY_TITLE", "TRANS_DATE", "TRANS_CODE", "EQUITY_SWAP_INVOLVED",
"TRANS_SHARES", "TRANS_PRICEPERSHARE", "TRANS_ACQUIRED_DISP_CD",
"SHRS_OWND_FOLWNG_TRANS", "DIRECT_INDIRECT_OWNERSHIP"]
PLAN_RE = r"10b5\s*-\s*1|10b-5-1|10b5one"
NOT_COMMON = r"prefer|pfd|note|debenture|warrant|bond|option"
_last = [0.0]
def _session() -> requests.Session:
s = requests.Session()
s.trust_env = False # direct connection: ignore proxy env vars
s.headers.update(UA)
return s
def _get(sess: requests.Session, url: str, **kw) -> requests.Response:
for attempt in range(5):
wait = 0.25 - (time.time() - _last[0])
if wait > 0:
time.sleep(wait)
_last[0] = time.time()
try:
r = sess.get(url, timeout=300, **kw)
r.raise_for_status()
return r
except Exception as exc: # noqa: BLE001 — retry transient SEC errors
print(f" {url}: {exc}; retry {attempt + 1}", flush=True)
time.sleep(5 * (attempt + 1))
raise RuntimeError(f"failed: {url}")
def zip_urls(sess: requests.Session) -> dict[str, str]:
html = _get(sess, PAGE).text
found = re.findall(r'href="([^"]*?(\d{4}q[1-4])_form345\.zip)"', html)
return {q: urljoin(PAGE, href) for href, q in sorted(found, key=lambda x: x[1])}
def download(sess: requests.Session) -> list[str]:
os.makedirs(ZIP_DIR, exist_ok=True)
urls = zip_urls(sess)
print(f"{len(urls)} quarterly zips listed ({min(urls)} .. {max(urls)})", flush=True)
paths = []
for q, url in urls.items():
path = os.path.join(ZIP_DIR, f"{q}_form345.zip")
if not (os.path.exists(path) and zipfile.is_zipfile(path)):
r = _get(sess, url)
with open(path + ".part", "wb") as fh:
fh.write(r.content)
os.replace(path + ".part", path)
print(f" {q}: {len(r.content) / 1e6:.1f} MB", flush=True)
paths.append(path)
return paths
# ---------------------------------------------------------------------------
# parse one quarter
# ---------------------------------------------------------------------------
def _read(z: zipfile.ZipFile, name: str, cols: list[str]) -> pd.DataFrame:
with z.open(name) as fh:
return pd.read_csv(fh, sep="\t", dtype=str, quoting=csv.QUOTE_NONE,
usecols=lambda c: c in cols, encoding_errors="replace")
def _date(s: pd.Series) -> pd.Series:
return pd.to_datetime(s, format="%d-%b-%Y", errors="coerce")
def parse_quarter(path: str) -> tuple[pd.DataFrame, pd.DataFrame]:
"""(kept trades, every Form 4 filing's issuer CIK / symbol / date)."""
z = zipfile.ZipFile(path)
sub = _read(z, "SUBMISSION.tsv", SUB_COLS)
sub = sub[sub["DOCUMENT_TYPE"].str.strip() == "4"].copy()
sub["filed"] = _date(sub["FILING_DATE"])
sub["cik"] = pd.to_numeric(sub["ISSUERCIK"], errors="coerce")
sub = sub.dropna(subset=["filed", "cik"])
filings = sub[["ACCESSION_NUMBER", "cik", "ISSUERTRADINGSYMBOL", "filed"]]
own = _read(z, "REPORTINGOWNER.tsv", OWN_COLS)
own = own[own["ACCESSION_NUMBER"].isin(sub["ACCESSION_NUMBER"])]
rel = own["RPTOWNER_RELATIONSHIP"].fillna("").str.lower()
own = own.assign(officer=rel.str.contains("officer"), director=rel.str.contains("director"),
ten_pct=rel.str.contains("tenpercent"),
insider_cik=pd.to_numeric(own["RPTOWNERCIK"], errors="coerce"))
own = own[(own["officer"] | own["director"]) & own["insider_cik"].notna()]
own = own.sort_values(["ACCESSION_NUMBER", "insider_cik"]).drop_duplicates("ACCESSION_NUMBER")
own["role"] = np.where(own["officer"] & own["director"], "officer+director",
np.where(own["officer"], "officer", "director"))
tr = _read(z, "NONDERIV_TRANS.tsv", TRN_COLS)
code = tr["TRANS_CODE"].str.strip()
ad = tr["TRANS_ACQUIRED_DISP_CD"].str.strip()
keep = (((code == "P") & (ad == "A")) | ((code == "S") & (ad == "D")))
keep &= tr["ACCESSION_NUMBER"].isin(own["ACCESSION_NUMBER"])
keep &= ~tr["SECURITY_TITLE"].fillna("").str.contains(NOT_COMMON, case=False, regex=True)
keep &= ~tr["EQUITY_SWAP_INVOLVED"].fillna("0").str.strip().isin(["1", "true"])
tr = tr[keep].copy()
tr["code"] = tr["TRANS_CODE"].str.strip()
tr["trans_date"] = _date(tr["TRANS_DATE"])
for src, dst in (("TRANS_SHARES", "shares"), ("TRANS_PRICEPERSHARE", "price"),
("SHRS_OWND_FOLWNG_TRANS", "owned_after")):
tr[dst] = pd.to_numeric(tr[src], errors="coerce")
tr["ownership"] = tr["DIRECT_INDIRECT_OWNERSHIP"].str.strip()
tr = tr[tr["shares"] > 0]
tr = tr.drop_duplicates(["ACCESSION_NUMBER", "trans_date", "code", "shares", "price",
"owned_after", "ownership"])
# Rule 10b5-1: the 2023 checkbox, and any footnote / remark that cites the rule
fn = _read(z, "FOOTNOTES.tsv", ["ACCESSION_NUMBER", "FOOTNOTE_TXT"])
fn = fn[fn["ACCESSION_NUMBER"].isin(tr["ACCESSION_NUMBER"])]
cited = set(fn.loc[fn["FOOTNOTE_TXT"].str.contains(PLAN_RE, case=False, na=False, regex=True),
"ACCESSION_NUMBER"])
cited |= set(sub.loc[sub["REMARKS"].str.contains(PLAN_RE, case=False, na=False, regex=True),
"ACCESSION_NUMBER"])
box = (sub["AFF10B5ONE"].str.strip().str.lower().map({"1": 1.0, "true": 1.0, "0": 0.0, "false": 0.0})
if "AFF10B5ONE" in sub else pd.Series(np.nan, index=sub.index))
sub = sub.assign(plan_10b51=box)
out = (tr.merge(own[["ACCESSION_NUMBER", "insider_cik", "role", "RPTOWNER_TITLE", "ten_pct"]],
on="ACCESSION_NUMBER")
.merge(sub[["ACCESSION_NUMBER", "cik", "ISSUERTRADINGSYMBOL", "filed", "plan_10b51"]],
on="ACCESSION_NUMBER"))
out["plan_fn"] = out["ACCESSION_NUMBER"].isin(cited)
out = out.rename(columns={"ACCESSION_NUMBER": "accession", "RPTOWNER_TITLE": "title",
"ISSUERTRADINGSYMBOL": "symbol"})
return (out[["accession", "cik", "symbol", "insider_cik", "role", "title", "ten_pct", "filed",
"trans_date", "code", "shares", "price", "plan_10b51", "plan_fn", "ownership"]],
filings)
# ---------------------------------------------------------------------------
# issuer CIK -> panel ticker
# ---------------------------------------------------------------------------
def norm_symbols(sym) -> list[str]:
"""'(WST)' -> [WST]; 'NYSE: KRC' -> [KRC]; 'CBS, CBS.A' -> [CBS, CBS-A]."""
if not isinstance(sym, str):
return []
s = re.sub(r'["()\[\]]', "", sym.upper())
s = re.sub(r"\b(NYSE|NASDAQ|NYSE AMERICAN|AMEX|NMS|OTC)\s*:", "", s)
parts = re.split(r"[,;/&]| AND ", s)
return [re.sub(r"\s+", "", x).replace(".", "-") for x in parts if x.strip()]
def raw_closes(tickers: list[str]) -> pd.DataFrame | None:
"""Unadjusted closes (split-adjusted close x later split ratios), from the
fundamentals fetch; used to check that an issuer's trade prices belong to
the ticker, and to value trades whose reported price is off."""
if not os.path.exists(FUND_RAW):
return None
with open(FUND_RAW, "rb") as fh:
raw = pickle.load(fh)
close_sa = raw["close_sa"].reindex(columns=tickers)
ratio = raw["splits"].reindex(index=close_sa.index, columns=tickers).fillna(0.0).replace(0.0, 1.0)
after = ratio.iloc[::-1].cumprod().iloc[::-1].shift(-1).fillna(1.0)
return close_sa * after
def _raw_at(raw: pd.DataFrame, tickers: pd.Series, dates: pd.Series) -> np.ndarray:
"""raw close of each (ticker, date) pair as of that date (last close on or before)."""
out = np.full(len(tickers), np.nan)
ff = raw.ffill()
col = {t: i for i, t in enumerate(ff.columns)}
ci = tickers.map(col)
ri = ff.index.searchsorted(dates.values, side="right") - 1
ok = ci.notna().values & (ri >= 0) & dates.notna().values
out[ok] = ff.values[ri[ok], ci[ok].astype(int)]
return out
def map_tickers(trades: pd.DataFrame, filings: pd.DataFrame, panel_tickers: list[str],
raw: pd.DataFrame | None, close: pd.DataFrame) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Assign each trade to a panel ticker; returns (trades with ticker, ticker map)."""
cmap = cik_map()
pset = set(panel_tickers)
rev: dict[int, list[str]] = {}
for t, c in cmap.items():
if t in pset:
rev.setdefault(c, []).append(t)
span = filings.groupby("cik")["filed"].agg(["min", "max", "count"])
rows = []
for c, ts in rev.items():
if c in span.index:
for t in sorted(ts):
rows.append((t, c, "sec_map", span.at[c, "min"], span.at[c, "max"], span.at[c, "count"]))
# symbol fallback for issuers the current SEC map does not tie to a panel ticker
fb = filings[~filings["cik"].isin(list(rev))]
pairs = (fb.groupby(["cik", "ISSUERTRADINGSYMBOL"])["filed"]
.agg(first="min", last="max", n="count").reset_index())
cand = []
for r in pairs.itertuples(index=False):
for t in norm_symbols(r.ISSUERTRADINGSYMBOL):
if t in pset:
cand.append((t, int(r.cik), r.first, r.last, r.n))
cand = (pd.DataFrame(cand, columns=["ticker", "cik", "first", "last", "n"])
.groupby(["ticker", "cik"]).agg(first=("first", "min"), last=("last", "max"), n=("n", "sum"))
.reset_index())
px_first = close.apply(lambda s: s.first_valid_index())
px_last = close.apply(lambda s: s.last_valid_index())
for r in cand.sort_values("n", ascending=False).itertuples(index=False):
first, last = r.first, r.last
owner = cmap.get(r.ticker)
if owner is not None and owner in span.index: # a predecessor only, never alongside the owner
last = min(last, span.at[owner, "min"] - pd.Timedelta(days=1))
if r.ticker in px_first.index and pd.notna(px_first[r.ticker]):
first, last = max(first, px_first[r.ticker]), min(last, px_last[r.ticker])
taken = [(a, b) for (t, _, _, a, b, _) in rows if t == r.ticker]
if last <= first or any(a <= last and first <= b for a, b in taken):
continue
rows.append((r.ticker, r.cik, "symbol", first, last, r.n))
tm = pd.DataFrame(rows, columns=["ticker", "cik", "source", "first", "last", "n_filings"])
# assign trades: SEC-map issuers to the filing's own share class when it is
# one of theirs, fallback issuers only inside their window
prim = {c: ts[0] for c, ts in ((c, sorted(ts)) for c, ts in rev.items())}
t_sec = trades["cik"].map(prim)
sym0 = trades["symbol"].map(lambda s: (norm_symbols(s) or [None])[0])
own_class = [s if (isinstance(s, str) and c in rev and s in rev[c]) else None
for s, c in zip(sym0.values, trades["cik"].values)]
trades = trades.assign(ticker=pd.Series(own_class, index=trades.index).fillna(t_sec))
for r in tm[tm["source"] == "symbol"].itertuples(index=False):
m = (trades["cik"] == r.cik) & trades["filed"].between(r.first, r.last) & trades["ticker"].isna()
trades.loc[m, "ticker"] = r.ticker
trades = trades.dropna(subset=["ticker"]).copy()
# price check: trade prices vs the ticker's unadjusted close on the trade date
trades["raw_close"] = _raw_at(raw, trades["ticker"], trades["trans_date"]) if raw is not None else np.nan
with np.errstate(divide="ignore", invalid="ignore"):
err = np.abs(np.log(trades["price"] / trades["raw_close"]))
trades["price_err"] = err.where(trades["price"] > 0)
chk = trades.groupby(["ticker", "cik"])["price_err"].median().rename("price_err")
tm = tm.merge(chk, left_on=["ticker", "cik"], right_index=True, how="left")
bad = tm[(tm["source"] == "symbol") & (tm["price_err"] > np.log(1.25))]
if len(bad):
print(" symbol matches rejected on price:", bad[["ticker", "cik", "price_err"]].to_dict("records"))
tm = tm.drop(bad.index)
keep = pd.MultiIndex.from_frame(trades[["ticker", "cik"]]).isin(
pd.MultiIndex.from_frame(bad[["ticker", "cik"]]))
trades = trades[~keep]
return trades, tm.reset_index(drop=True)
def main() -> None:
for k in ("http_proxy", "https_proxy", "HTTP_PROXY", "HTTPS_PROXY", "all_proxy", "ALL_PROXY"):
os.environ.pop(k, None) # direct connection for cik_map as well
sess = _session()
paths = download(sess)
parts, fil, t0 = [], [], time.time()
for path in paths:
tr, f = parse_quarter(path)
parts.append(tr)
fil.append(f)
print(f" {os.path.basename(path)}: {len(tr):,} P/S lines by officers/directors "
f"[{time.time() - t0:.0f}s]", flush=True)
trades = pd.concat(parts, ignore_index=True)
filings = pd.concat(fil, ignore_index=True).drop_duplicates("ACCESSION_NUMBER")
trades = trades.drop_duplicates(["accession", "trans_date", "code", "shares", "price", "ownership"])
trades["cik"] = trades["cik"].astype("int64")
filings["cik"] = filings["cik"].astype("int64")
print(f"{len(trades):,} kept lines, {len(filings):,} Form 4 filings", flush=True)
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
close = pickle.load(fh)["close"]
tickers = list(close.columns)
raw = raw_closes(tickers)
trades, tm = map_tickers(trades, filings, tickers, raw, close)
# value at the reported price unless it is > 2x off the issuer's usual
# price / close ratio (~1 normally, ~1500 for BRK-A trades mapped to BRK-B):
# such prices are unit errors (total proceeds typed as the price) and are
# replaced by the ticker's unadjusted close
with np.errstate(divide="ignore", invalid="ignore"):
lr = pd.Series(np.log(trades["price"] / trades["raw_close"]), index=trades.index)
lr = lr.where(trades["price"] > 0)
usual = lr.groupby([trades["ticker"], trades["cik"]]).transform("median")
trades["price_ok"] = (lr - usual).abs() < np.log(2)
px = np.where(trades["price_ok"], trades["price"],
np.where(trades["raw_close"] > 0, trades["raw_close"], trades["price"]))
trades["value"] = trades["shares"] * np.where(px > 0, px, np.nan)
trades["insider_cik"] = trades["insider_cik"].astype("int64")
cols = ["ticker", "cik", "insider_cik", "role", "title", "ten_pct", "filed", "trans_date", "code",
"shares", "price", "value", "price_ok", "plan_10b51", "plan_fn", "ownership", "accession"]
trades = trades[cols].sort_values(["filed", "ticker"]).reset_index(drop=True)
unmapped = sorted(set(tickers) - set(tm["ticker"]))
print(f"{len(trades):,} trades in {trades['ticker'].nunique()} panel tickers "
f"({(trades['code'] == 'P').sum():,} P / {(trades['code'] == 'S').sum():,} S); "
f"map: {(tm['source'] == 'sec_map').sum()} by CIK, {(tm['source'] == 'symbol').sum()} by symbol; "
f"unmapped {unmapped}", flush=True)
with open(OUT, "wb") as fh:
pickle.dump({"trades": trades, "tickers": tm, "unmapped": unmapped}, fh, protocol=4)
print(f"Saved {OUT}")
if __name__ == "__main__":
main()