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:
2026-10-09 22:20:43 -07:00
parent 36cb125719
commit 04e2c5c3be
21 changed files with 2543 additions and 45 deletions

View File

@@ -1,9 +1,12 @@
"""
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.
directors of every requested panel universe's ever-members. The zips are
parsed once; the issuer->ticker mapping and price checks run per universe
(us = S&P 500 research panel, mid = S&P 400, small = S&P 600).
uv run python -m research.lab.fetch_insider
uv run python -m research.lab.fetch_insider # us (insider_trades.pkl)
PYTHONPATH=. uv run python -m research.lab.fetch_insider --universe mid small
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).
@@ -30,7 +33,7 @@ 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:
Writes data/research/insider_trades{,_{mid,small}}.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,
@@ -52,6 +55,7 @@ Rule 10b5-1 (available in every year).
from __future__ import annotations
import argparse
import csv
import os
import pickle
@@ -219,13 +223,14 @@ def norm_symbols(sym) -> list[str]:
return [re.sub(r"\s+", "", x).replace(".", "-") for x in parts if x.strip()]
def raw_closes(tickers: list[str]) -> pd.DataFrame | None:
def raw_closes(tickers: list[str], fund: str = FUND_RAW) -> 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):
fundamentals fetch of that universe; 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):
return None
with open(FUND_RAW, "rb") as fh:
with open(fund, "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)
@@ -317,29 +322,28 @@ def map_tickers(trades: pd.DataFrame, filings: pd.DataFrame, panel_tickers: list
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)
UNIVERSE_OUT = {"us": OUT,
"mid": os.path.join("data", "research", "insider_trades_mid.pkl"),
"small": os.path.join("data", "research", "insider_trades_small.pkl")}
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
close = pickle.load(fh)["close"]
def universe_inputs(u: str) -> tuple[pd.DataFrame, pd.DataFrame | None]:
"""(panel closes, unadjusted closes) of one universe's research panel."""
if u == "us":
with open(os.path.join("data", "research", "us_ohlcv.pkl"), "rb") as fh:
close = pickle.load(fh)["close"]
fund = FUND_RAW
else:
from research.lab.panel_mid import load_us_mid
close = load_us_mid(u).close
fund = os.path.join("data", "research", f"us_{u}_fund_raw.pkl")
return close, raw_closes(list(close.columns), fund)
def finish(trades: pd.DataFrame, filings: pd.DataFrame, close: pd.DataFrame,
raw: pd.DataFrame | None, out: str, label: str) -> None:
"""Map the parsed trades to one panel's tickers, value them, and save."""
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
@@ -359,13 +363,40 @@ def main() -> None:
"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 "
print(f"[{label}] {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:
with open(out, "wb") as fh:
pickle.dump({"trades": trades, "tickers": tm, "unmapped": unmapped}, fh, protocol=4)
print(f"Saved {OUT}")
print(f"Saved {out}", flush=True)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--universe", nargs="+", default=["us"], choices=["us", "mid", "small"])
args = ap.parse_args()
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)
for u in args.universe:
close, raw = universe_inputs(u)
finish(trades.copy(), filings, close, raw, UNIVERSE_OUT[u], u)
if __name__ == "__main__":