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

433 lines
20 KiB
Python

"""
Point-in-time insider-trading factors for the US panel, from officers' and
directors' open-market Form 4 purchases (P) and sales (S)
(research/lab/fetch_insider.py, SEC Insider Transactions Data Sets, 2006 →).
A trade is usable from the first trading day strictly after its filing date,
so a factor on date t only counts filings dated before t (the engine then
trades at the open of t+1). A window of W calendar days counts a filing on t
when t - W <= filed < t.
Factors (dates x panel tickers). NaN where no issuer CIK covers the ticker on
that date (``covered``), 0 where it is covered but nothing was filed:
insider_buyers_90d distinct insiders with a purchase filed in the last
90 days minus distinct insiders with a sale
insider_net_value_180d (purchase $ - sale $) filed in the last 180 days /
market cap; NaN where fundamentals' mcap is missing
(all names before 2009, ~10% after)
insider_opportunistic opportunistic buyers minus opportunistic sellers,
last 90 days (Cohen, Malloy & Pomorski 2012); NaN
before 2009-04-01 (needs three prior years of trades)
insider_cluster_buy 1 if >= 2 distinct officers/directors bought in the
last 30 days, else 0
Routine vs opportunistic (CMP 2012, "Decoding Inside Information", p. 11-12):
an insider who traded in each of the three preceding calendar years is
"routine" for year Y if some calendar month m had a trade in each of Y-1, Y-2
and Y-3, else "opportunistic"; all her trades in Y get that label. Insiders
without a trade in each of the three years are unclassified. The paper does
not condition on direction; ``same_direction=True`` is the variant that
counts only same-direction trades for the routine test. Classification is per
(insider, issuer) and, stricter than the paper, only uses history filed
before the classified trade's filing date.
from research.lab import insider
I = insider.load(p) # cached
PYTHONPATH=. uv run python -m research.lab.insider # evaluation
"""
from __future__ import annotations
import json
import os
import pickle
import time
import warnings
import numpy as np
import pandas as pd
from research.lab import fundamentals as fu
from research.lab.factor_scan import RESULTS
from research.lab.factor_scan import load as load_panel
from research.lab.report import stats
warnings.filterwarnings("ignore")
TRADES = os.path.join("data", "research", "insider_trades.pkl")
CACHE = os.path.join("data", "research", "insider_factors.pkl")
OPP_START = "2009-04-01" # first 90-day window made only of classifiable (2009+) trades
COVER_SLACK = 120 # days after an issuer's last Form 4 that still count as covered
NAMES = ["insider_buyers_90d", "insider_net_value_180d", "insider_opportunistic", "insider_cluster_buy"]
def _raw() -> tuple[pd.DataFrame, pd.DataFrame]:
with open(TRADES, "rb") as fh:
raw = pickle.load(fh)
return raw["trades"], raw["tickers"]
# ---------------------------------------------------------------------------
# trades → events
# ---------------------------------------------------------------------------
def classify_cmp(tr: pd.DataFrame, same_direction: bool = False) -> pd.Series:
"""'routine' / 'opportunistic' / None for every trade (index of ``tr``)."""
d = tr[["insider_cik", "cik", "code", "trans_date", "filed"]].copy()
d["y"] = d["trans_date"].dt.year
d["m"] = d["trans_date"].dt.month
ok = d["y"].notna()
d = d[ok].astype({"y": int, "m": int})
d["fd"] = (d["filed"] - pd.Timestamp("1970-01-01")).dt.days.astype(float) # NaN compares False
who = ["insider_cik", "cik"]
mkey = who + (["code"] if same_direction else [])
# first filing day of any trade per year, and of a trade per (year, month)
any_y = d.groupby(who + ["y"])["fd"].min()
mon = d.groupby(mkey + ["y", "m"])["fd"].min().unstack("m").reindex(columns=range(1, 13))
f = d["fd"].values
hist = np.ones(len(d), bool)
routine = np.ones((len(d), 12), bool)
for k in (1, 2, 3):
a = any_y.reindex(pd.MultiIndex.from_arrays([d[c] for c in who] + [d["y"] - k])).values
hist &= a < f
M = mon.reindex(pd.MultiIndex.from_arrays([d[c] for c in mkey] + [d["y"] - k])).values
routine &= M < f[:, None]
lab = np.where(hist & routine.any(axis=1), "routine", np.where(hist, "opportunistic", None))
return pd.Series(lab, index=d.index).reindex(tr.index)
def _explode(tr: pd.DataFrame, tm: pd.DataFrame) -> pd.DataFrame:
"""One row per (trade, panel ticker): every share class of an issuer CIK
(GOOG/GOOGL, FOX/FOXA, ...) gets that issuer's trades."""
alias = tm[tm["source"] == "sec_map"].groupby("cik")["ticker"].apply(sorted)
multi = alias[alias.str.len() > 1]
parts = [tr[~tr["cik"].isin(multi.index)]]
for c, ts in multi.items():
sub = tr[tr["cik"] == c]
parts += [sub.assign(ticker=t) for t in ts]
return pd.concat(parts, ignore_index=True)
def share_class_group(tm: pd.DataFrame) -> dict[str, str]:
alias = tm[tm["source"] == "sec_map"].groupby("cik")["ticker"].apply(sorted)
return {t: ts[0] for ts in alias for t in ts}
def covered(tm: pd.DataFrame, dates: pd.DatetimeIndex, tickers: list[str]) -> pd.DataFrame:
"""True where an issuer CIK mapped to the ticker was filing Form 4s: from
the session after its first filing in the data to COVER_SLACK days after
its last one."""
col = {t: i for i, t in enumerate(tickers)}
out = np.zeros((len(dates), len(tickers)), bool)
for r in tm.itertuples(index=False):
if r.ticker in col:
a = dates.searchsorted(r.first, side="right")
b = dates.searchsorted(r.last + pd.Timedelta(days=COVER_SLACK), side="right")
out[a:b, col[r.ticker]] = True
return pd.DataFrame(out, index=dates, columns=tickers)
def window(ev: pd.DataFrame, dates: pd.DatetimeIndex, n_cols: int, days: int,
weights: np.ndarray | None = None) -> np.ndarray:
"""(dates x columns) count of distinct ``insider_cik`` with a filing in the
last ``days`` calendar days before each date; with ``weights``, the sum of
the weights of those filings instead."""
filed = ev["filed"].values
s = dates.searchsorted(filed, side="right") # first session after filing
e = dates.searchsorted(filed + np.timedelta64(days, "D"), side="right")
D = np.zeros((len(dates) + 1, n_cols))
c = ev["col"].values
if weights is not None:
np.add.at(D, (s, c), weights)
np.add.at(D, (e, c), -weights)
out = np.cumsum(D, axis=0)[:-1]
out[np.abs(out) < 1.0] = 0.0 # float residue of the running sum
return out
# merge each insider's overlapping [s, e) spans so she counts once
df = pd.DataFrame({"c": c, "k": ev["insider_cik"].values, "f": filed, "s": s, "e": e})
df = df.sort_values(["c", "k", "f"])
new = ((df["c"] != df["c"].shift()) | (df["k"] != df["k"].shift())
| (df["s"] > df["e"].shift())).values
run = np.cumsum(new)
g = df.groupby(run).agg(c=("c", "first"), s=("s", "min"), e=("e", "max"))
np.add.at(D, (g["s"].values, g["c"].values), 1.0)
np.add.at(D, (g["e"].values, g["c"].values), -1.0)
return np.cumsum(D, axis=0)[:-1]
# ---------------------------------------------------------------------------
# build
# ---------------------------------------------------------------------------
def build(p) -> dict[str, pd.DataFrame]:
t0 = time.time()
tr, tm = _raw()
dates, tickers = p.dates, list(p.close.columns)
tr = tr.assign(cmp=classify_cmp(tr))
ex = _explode(tr, tm)
col = pd.Series(np.arange(len(tickers)), index=tickers)
ex = ex[ex["ticker"].isin(col.index)].copy()
ex["col"] = col.reindex(ex["ticker"]).values
cov = covered(tm, dates, tickers)
n = len(tickers)
buy, sell = ex[ex["code"] == "P"], ex[ex["code"] == "S"]
print(f" {len(ex):,} trades on {ex['ticker'].nunique()} tickers [{time.time() - t0:.0f}s]", flush=True)
def frame(x):
return pd.DataFrame(x, index=dates, columns=tickers).where(cov).astype("float32")
F = {}
F["insider_buyers_90d"] = frame(window(buy, dates, n, 90) - window(sell, dates, n, 90))
signed = np.where(ex["code"] == "P", 1.0, -1.0) * ex["value"].fillna(0.0).values
net = window(ex, dates, n, 180, weights=signed)
mcap = fu.load(p)["mcap"].reindex(index=dates, columns=tickers)
F["insider_net_value_180d"] = frame(net / mcap.values)
ob = buy[buy["cmp"] == "opportunistic"]
osl = sell[sell["cmp"] == "opportunistic"]
opp = window(ob, dates, n, 90) - window(osl, dates, n, 90)
opp[dates < pd.Timestamp(OPP_START)] = np.nan
F["insider_opportunistic"] = frame(opp)
F["insider_cluster_buy"] = frame((window(buy, dates, n, 30) >= 2).astype(float))
print(f" factors built [{time.time() - t0:.0f}s]", flush=True)
return F
def load(p, rebuild: bool = False) -> dict[str, pd.DataFrame]:
if os.path.exists(CACHE) and not rebuild:
with open(CACHE, "rb") as fh:
return pickle.load(fh)
F = build(p)
with open(CACHE, "wb") as fh:
pickle.dump(F, fh, protocol=4)
return F
# ---------------------------------------------------------------------------
# evaluation
# ---------------------------------------------------------------------------
END = "2026-09-24"
# 2010-2016 = the first period without the 2008-09 crash and rebound
PERIODS = {"2006-2016": ("2006-01-01", "2016-12-31"), "2010-2016": ("2010-01-01", "2016-12-31"),
"2017-2026": ("2017-01-01", END), "all": ("2006-01-01", END)}
HORIZONS = (5, 21, 63)
# first date each portfolio is evaluated: full 180-day window / classifiable
# history / market caps for >= ~80% of members
START = {"insider_buyers_90d": "2006-07-03", "insider_cluster_buy": "2006-07-03",
"insider_opportunistic": OPP_START, "insider_net_value_180d": "2010-07-01"}
LONG_START = "2006-07-03"
def _avail_rows(dates: pd.DatetimeIndex, filed: pd.Series) -> np.ndarray:
return dates.searchsorted(filed.values, side="right")
def trade_events(p, ex: pd.DataFrame, group: dict[str, str]) -> pd.DataFrame:
"""Ticker-days on which a filing became usable, for PIT members only;
share classes of one issuer count once."""
i = _avail_rows(p.dates, ex["filed"])
e = ex.assign(row=i)[i < len(p.dates)]
e = e[p.member.values[e["row"].values, e["col"].values]]
e = e.assign(grp=e["ticker"].map(lambda t: group.get(t, t)))
return e.drop_duplicates(["grp", "row"])[["row", "col"]]
def onset_events(p, flag: pd.DataFrame, group: dict[str, str]) -> pd.DataFrame:
"""Days a 0/1 panel switches from 0 to 1, for PIT members."""
v = flag.values
prev = np.vstack([np.full((1, v.shape[1]), np.nan), v[:-1]])
on = (v == 1) & (prev == 0) & p.member.values
r, c = np.nonzero(on)
e = pd.DataFrame({"row": r, "col": c})
e["grp"] = [group.get(t, t) for t in flag.columns[c]]
return e.drop_duplicates(["grp", "row"])[["row", "col"]]
def excess_fwd(p, h: int) -> np.ndarray:
"""Open(t) → open(t+h) return minus the equal-weight PIT-member average."""
o = p.open.where(p.open > 0).ffill()
f = o.shift(-h) / o - 1
ew = f.where(p.member).mean(axis=1)
return f.sub(ew, axis=0).values
def clustered_t(x: np.ndarray, month: np.ndarray) -> tuple[float, float, int]:
ok = ~np.isnan(x)
x, month = x[ok], month[ok]
n = len(x)
if n < 10:
return float("nan"), float("nan"), n
m = x.mean()
g = pd.Series(x - m).groupby(month).sum()
G = len(g)
se = np.sqrt((g ** 2).sum() * G / max(G - 1, 1)) / n
return float(m), float(m / se), n
def event_study(p, events: dict[str, pd.DataFrame]) -> dict:
X = {h: excess_fwd(p, h) for h in HORIZONS}
ym = np.asarray(p.dates.year * 100 + p.dates.month)
out = {}
for kind, e in events.items():
out[kind] = {}
for per, (a, b) in PERIODS.items():
ra, rb = p.dates.searchsorted(pd.Timestamp(a)), p.dates.searchsorted(pd.Timestamp(b), side="right")
ee = e[(e["row"] >= ra) & (e["row"] < rb)]
out[kind][per] = {}
for h in HORIZONS:
x = X[h][ee["row"].values, ee["col"].values]
m, t, n = clustered_t(x, ym[ee["row"].values])
out[kind][per][f"+{h}"] = {"mean": m, "t": t, "n": n}
return out
def coverage(p, I: dict, ex: pd.DataFrame) -> pd.DataFrame:
yr = p.dates.year
mem = p.member
cov = I["insider_buyers_90d"].notna() & mem
i = _avail_rows(p.dates, ex["filed"])
e = ex.assign(row=i)[i < len(p.dates)]
e = e[mem.values[e["row"].values, e["col"].values]]
e = e.assign(year=p.dates[e["row"].values].year).drop_duplicates(["accession", "code"])
return pd.DataFrame({
"members": mem.sum(axis=1).groupby(yr).mean().round(0),
"mapped": cov.sum(axis=1).groupby(yr).mean().round(0),
"buy_filings": e[e["code"] == "P"].groupby("year").size(),
"sell_filings": e[e["code"] == "S"].groupby("year").size(),
"opp_buy_filings": e[(e["code"] == "P") & (e["cmp"] == "opportunistic")].groupby("year").size(),
"names_bought": e[e["code"] == "P"].groupby("year")["ticker"].nunique(),
}).loc[2006:].fillna(0).astype(int)
def _active(r: pd.Series, bench: pd.Series) -> tuple[float, float]:
"""Annualized mean daily return over ``bench`` and its t-stat."""
act = (r - bench.reindex(r.index)).dropna()
ir = act.mean() / act.std() * np.sqrt(252)
return act.mean() * 252, ir * np.sqrt(len(act) / 252)
def _period_stats(r: pd.Series, ew: pd.Series, tie: pd.Series, a: str, b: str) -> dict:
r = r.loc[a:b].dropna()
s = stats((1 + r).cumprod())
act, t = _active(r, ew)
act_tie, t_tie = _active(r, tie)
return {"sharpe": s["sharpe"], "cagr": s["cagr"], "mdd": s["mdd"],
"active_ann": act, "active_t": t, "vs_tiebreak_ann": act_tie, "vs_tiebreak_t": t_tie}
def main() -> None:
from research.lab.rotation import factor_ranks
from research.lab.us_sweep import _blend, run
p, _ = load_panel("us")
I = load(p)
tr, tm = _raw()
tr = tr.assign(cmp=classify_cmp(tr), cmp_dir=classify_cmp(tr, same_direction=True))
ex = _explode(tr, tm)
col = pd.Series(np.arange(len(p.close.columns)), index=p.close.columns)
ex = ex[ex["ticker"].isin(col.index)].copy()
ex["col"] = col.reindex(ex["ticker"]).values
group = share_class_group(tm)
out: dict = {}
# ---- mapping and coverage
src = tm.groupby("source")["ticker"].nunique().to_dict()
unmapped = sorted(set(p.close.columns) - set(tm["ticker"]))
unm_mem = [t for t in unmapped if p.member[t].any()]
print(f"ticker map: {src} ; unmapped ever-members: {unm_mem}")
print("by symbol:", sorted(tm.loc[tm["source"] == "symbol", "ticker"]))
cv = coverage(p, I, ex)
print("\n=== coverage among PIT S&P 500 members (per year; filings = Form 4s with a P or S line)")
print(cv.to_string())
out["mapping"] = {"by_source": src, "unmapped_ever_members": unm_mem,
"by_symbol": sorted(tm.loc[tm["source"] == "symbol", "ticker"])}
out["coverage"] = {int(k): {c: int(v) for c, v in r.items()} for k, r in cv.iterrows()}
# ---- event study
B, S = ex["code"] == "P", ex["code"] == "S"
ev = {
"all purchases": trade_events(p, ex[B], group),
"opportunistic purchases": trade_events(p, ex[B & (ex["cmp"] == "opportunistic")], group),
"cluster buys (onset)": onset_events(p, I["insider_cluster_buy"], group),
"opportunistic sales": trade_events(p, ex[S & (ex["cmp"] == "opportunistic")], group),
# references
"routine purchases": trade_events(p, ex[B & (ex["cmp"] == "routine")], group),
"all sales": trade_events(p, ex[S], group),
"opp. purchases, same-direction rule": trade_events(p, ex[B & (ex["cmp_dir"] == "opportunistic")], group),
"opp. sales, same-direction rule": trade_events(p, ex[S & (ex["cmp_dir"] == "opportunistic")], group),
}
es = event_study(p, ev)
out["event_study"] = es
print("\n=== event study: excess return vs EW members from the next open after filing, % "
"(t clustered by month) [n]")
print(f"{'':38s}" + "".join(f"{per:>27s}" for per in PERIODS))
for kind, d in es.items():
for h in HORIZONS:
cells = "".join(f" {d[per][f'+{h}']['mean']*100:+6.2f} ({d[per][f'+{h}']['t']:+5.1f}) "
f"[{d[per][f'+{h}']['n']:5d}]" for per in PERIODS)
print(f"{(kind if h == 5 else ''):34s} +{h:<3d}{cells}")
# ---- portfolios: top 20 among members, factor first, 12-1 momentum breaks ties
pr = factor_ranks(p, "us", ["mom_12_1", "overnight_mom", "rec_126"])
tie = pr["mom_12_1"].fillna(0.0)
def lexi(f):
r = f.where(p.member).rank(axis=1, method="dense")
return (r + 0.5 * tie).where(r.notna())
kw = dict(top_n=20, buffer=2.0, every=21)
spans = lambda a: [(a, "2016-12-30"), ("2017-01-03", END)] # noqa: E731
R, runs = {}, {}
ew_sc = p.close.notna().astype(float).where(p.member)
runs["ew_universe"] = run(p, ew_sc, spans(LONG_START), top_n=10000, every=21)
runs["mom_12_1 alone (tie-breaker)"] = run(p, pr["mom_12_1"].where(p.member), spans(LONG_START), **kw)
mom = _blend(pr["overnight_mom"], pr["rec_126"])
runs["momentum sleeve"] = run(p, mom.where(p.member), spans(LONG_START), top_n=10, buffer=2.0, every=10)
for name in NAMES:
runs[name] = run(p, lexi(I[name]), spans(START[name]), **kw)
print(f" ran {name}", flush=True)
for k, v in runs.items():
R[k] = v["_rets"]
ew, ms, tb = R["ew_universe"], R["momentum sleeve"], R["mom_12_1 alone (tie-breaker)"]
print("\n=== long-only top 20 (every 21 days, buffer 2, next-open fills, 10 bp); "
"periods: start-2016 | 2017-2026 | whole")
print(" sharpe / cagr / mdd per period; active = vs EW universe, vs-tie = vs the 12-1 momentum "
"tie-breaker alone (ann. %, t); phase-avg block Sharpe; corr with momentum sleeve (raw, active vs EW)")
port = {}
for k, v in runs.items():
start = START.get(k, LONG_START)
pers = {"p1": (start, "2016-12-30"), "p2": ("2017-01-03", END), "all": (start, END)}
st = {pk: _period_stats(R[k], ew, tb, a, b) for pk, (a, b) in pers.items()}
r = R[k].loc[start:END]
c_raw = r.corr(ms.loc[start:END])
c_act = (r - ew.loc[start:END]).corr(ms.loc[start:END] - ew.loc[start:END])
n_sig = None
if k in I:
f = I[k].where(p.member).loc[start:END]
n_sig = float((f > 0).sum(axis=1).mean())
port[k] = {"start": start, **st, "blocks": v["blocks"], "phase_sharpe": v["sharpe"],
"phase_cagr": v["cagr"], "phase_mdd": v["mdd"], "turn": v["turn"],
"corr_mom": c_raw, "corr_mom_active": c_act, "avg_signal_names": n_sig}
cells = " | ".join(f"{st[pk]['sharpe']:.2f} {st[pk]['cagr']*100:5.1f}% {st[pk]['mdd']*100:5.1f}%"
for pk in ("p1", "p2", "all"))
act = " | ".join(f"{st[pk]['active_ann']*100:+5.1f}% ({st[pk]['active_t']:+4.1f})" for pk in ("p1", "p2", "all"))
vst = " | ".join(f"{st[pk]['vs_tiebreak_ann']*100:+5.1f}% ({st[pk]['vs_tiebreak_t']:+4.1f})"
for pk in ("p1", "p2", "all"))
sig = f" sig-names {n_sig:5.1f}" if n_sig is not None else ""
print(f"{k:30s} from {start} {cells}\n{'':30s} active {act} blocks "
f"[{' '.join(f'{b:.2f}' for b in v['blocks'])}] corr {c_raw:.2f}/{c_act:+.2f} "
f"turn {v['turn']:.1f}x{sig}")
if k in I:
print(f"{'':30s} vs-tie {vst}")
out["portfolio"] = port
os.makedirs(RESULTS, exist_ok=True)
path = os.path.join(RESULTS, "insider_eval.json")
with open(path, "w") as fh:
json.dump(out, fh, indent=1, default=float)
print(f"\nSaved {path}")
if __name__ == "__main__":
main()