"""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()