Files
quant-trading/research/lab/activists.py
Gahow Wang 04e2c5c3be 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>
2026-10-09 22:20:43 -07:00

176 lines
7.4 KiB
Python

"""SC 13D activist-event study on the PIT US panels.
Event: a new SC 13D filing (originals only; /A amendments excluded; 13G
passive stakes tracked separately) for a company whose CIK maps to a ticker
in the panel universe. EDGAR disseminates same-day, so the conservative
trade date is the next session; CARs run from close(t0) where t0 = first
session at/after the filing date.
Reports market-adjusted forward returns (event minus same-panel member mean,
same window) over 1/5/21/63 sessions, split 13D vs 13G, with yearly means,
plus conditioning on 63d momentum quintile and SVR (short-volume ratio)
tercile to test the squeeze reading.
PYTHONPATH=. uv run python -m research.lab.activists [us|midsmall|all]
"""
from __future__ import annotations
import json
import os
import pickle
import sys
import numpy as np
import pandas as pd
import requests
from research.lab.fetch_us import _proxy_env
EVENTS_CSV = "data/research/edgar_13d.csv"
TICKERS_CACHE = "data/research/company_tickers.json"
UA = {"User-Agent": "quant-research/0.1 gahow@example.com", "Accept-Encoding": "gzip"}
HORIZONS = (1, 5, 21, 63)
def cik_ticker_map() -> dict[int, str]:
if not os.path.exists(TICKERS_CACHE):
r = requests.get("https://www.sec.gov/files/company_tickers.json", headers=UA, timeout=60)
r.raise_for_status()
json.dump(r.json(), open(TICKERS_CACHE, "w"))
raw = json.load(open(TICKERS_CACHE))
out = {}
for v in raw.values():
t = str(v["ticker"]).upper().replace(".", "-")
out.setdefault(int(v["cik_str"]), t)
return out
def load_svr() -> pd.DataFrame | None:
path = "data/research/short_volume.pkl"
if not os.path.exists(path):
return None
with open(path, "rb") as fh:
d = pickle.load(fh)
return d["short"] / d["total"].replace(0.0, np.nan)
def run(name: str, close: pd.DataFrame, member: pd.DataFrame,
events: pd.DataFrame, svr: pd.DataFrame | None) -> dict:
close = close.ffill()
member = member.reindex_like(close).fillna(False)
idx = close.index
rets = close.pct_change(fill_method=None)
# cross-sectional member mean daily return (market proxy)
mkt = rets.where(member).mean(axis=1)
cum = (1 + rets.fillna(0.0)).cumprod()
cum_mkt = (1 + mkt.fillna(0.0)).cumprod()
mom = close.pct_change(63)
svr21 = svr.rolling(21, min_periods=10).mean() if svr is not None else None
rows = []
for _, ev in events.iterrows():
t = ev["ticker"]
if t not in close.columns:
continue
pos = idx.searchsorted(pd.Timestamp(ev["filed"]), side="left")
if pos >= len(idx) - max(HORIZONS) - 1:
continue
t0 = idx[pos]
if not bool(member.at[t0, t]):
continue
svr_val = np.nan
if svr21 is not None and t in svr21.columns and t0 in svr21.index:
v = svr21.at[t0, t]
if np.isfinite(v):
svr_val = float(v)
mom_val = mom.at[t0, t] if t in mom.columns else np.nan
entry = {"form": ev["form"], "ticker": t, "date": t0, "cik": ev["cik"],
"mom": float(mom_val) if np.isfinite(mom_val) else np.nan,
"svr": svr_val}
for h in HORIZONS:
r = float(cum.iloc[pos + h][t] / cum.iloc[pos][t] - 1.0)
rm = float(cum_mkt.iloc[pos + h] / cum_mkt.iloc[pos] - 1.0)
entry[f"car{h}"] = r - rm
rows.append(entry)
df = pd.DataFrame(rows)
print(f"\n=== {name}: {len(df)} events mapped to panel "
f"({df['date'].min().date() if len(df) else '-'}..{df['date'].max().date() if len(df) else '-'}) ===")
out = {"universe": name, "n_events": len(df)}
if len(df) == 0:
return out
for form in ("SC 13D", "SC 13G"):
sub = df[df["form"] == form]
if len(sub) < 30:
continue
line = [f"{form:7s} n={len(sub):5d}"]
for h in HORIZONS:
x = sub[f"car{h}"].dropna()
t = x.mean() / x.std() * np.sqrt(len(x)) if len(x) > 1 else np.nan
line.append(f"CAR{h}: {x.mean()*100:5.2f}% (t {t:4.1f}, hit {np.mean(x>0)*100:4.0f}%)")
print(" " + " ".join(line))
out[form] = {f"car{h}": {"mean": float(sub[f'car{h}'].mean()),
"t": float(sub[f'car{h}'].mean() / sub[f'car{h}'].std() * np.sqrt(len(sub))),
"hit": float((sub[f'car{h}'] > 0).mean())} for h in HORIZONS}
yr = sub.groupby(sub["date"].dt.year)["car21"].mean()
print(" yearly CAR21: " + " ".join(f"{y}:{v*100:+.0f}%" for y, v in yr.items()))
# conditioning on momentum / svr (13D only)
d13 = df[df["form"] == "SC 13D"].copy()
if len(d13) >= 100:
d13["mom_q"] = d13["mom"].rank(pct=True)
hi, lo = d13[d13["mom_q"] > 0.66], d13[d13["mom_q"] < 0.34]
if len(hi) > 20 and len(lo) > 20:
print(f" 13D x momentum: hi-mom CAR21 {hi['car21'].mean()*100:5.2f}% (n={len(hi)}) "
f"| lo-mom CAR21 {lo['car21'].mean()*100:5.2f}% (n={len(lo)})")
out["13D_mom_hi_car21"] = float(hi["car21"].mean())
out["13D_mom_lo_car21"] = float(lo["car21"].mean())
if d13["svr"].notna().sum() > 100:
d13["svr_t"] = d13["svr"].rank(pct=True)
hi, lo = d13[d13["svr_t"] > 0.66], d13[d13["svr_t"] < 0.34]
if len(hi) > 20 and len(lo) > 20:
print(f" 13D x SVR: hi-fails-short CAR21 {hi['car21'].mean()*100:5.2f}% (n={len(hi)}) "
f"| lo CAR21 {lo['car21'].mean()*100:5.2f}% (n={len(lo)})")
out["13D_svr_hi_car21"] = float(hi["car21"].mean())
out["13D_svr_lo_car21"] = float(lo["car21"].mean())
return out
def main() -> None:
_proxy_env()
which = sys.argv[1] if len(sys.argv) > 1 else "all"
targets_csv = os.environ.get("TR9_TARGETS_CSV", "data/research/edgar_13d_targets.csv")
if not os.path.exists(targets_csv):
raise SystemExit("run research.lab.fetch_13d_targets first")
tg = pd.read_csv(targets_csv)
tg = tg[(tg["target_cik"].fillna(0) > 0)]
cik2t = cik_ticker_map()
tg["ticker"] = tg["target_cik"].map(lambda c: cik2t.get(int(c)))
# fallback: exact name match against the SEC ticker map
if os.path.exists(TICKERS_CACHE):
raw = json.load(open(TICKERS_CACHE))
by_name = {str(v["title"]).strip().upper(): str(v["ticker"]).upper().replace(".", "-")
for v in raw.values()}
miss = tg["ticker"].isna()
tg.loc[miss, "ticker"] = tg.loc[miss, "target_name"].str.strip().str.upper().map(by_name)
ev = tg[tg["ticker"].notna()].copy()
ev["form"] = "SC 13D"
# one event per target per day (multiple filers on the same stake day)
ev = ev.sort_values("filed").drop_duplicates(subset=["ticker", "filed"], keep="first")
print(f"13D originals mapped to tickers: {len(ev)}")
svr = load_svr()
results = []
if which in ("us", "all"):
with open("data/research/us.panel.pkl", "rb") as fh:
p = pickle.load(fh)
results.append(run("S&P 500", p.close, p.member, ev, svr))
if which in ("midsmall", "all"):
with open("data/research/us_midsmall.panel.pkl", "rb") as fh:
p = pickle.load(fh)
results.append(run("S&P 400+600", p.close, p.member, ev, svr))
json.dump(results, open("data/research/results/activists.json", "w"), indent=1, default=str)
print("saved data/research/results/activists.json")
if __name__ == "__main__":
main()