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>
176 lines
7.4 KiB
Python
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()
|