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

140 lines
5.5 KiB
Python

"""Activist event sleeve: hold fresh SC 13D targets, lab-engine evaluation.
Event study first (research/lab/activists.py): +1.2% CAR1 excess in S&P
400/600 (t 4.2), +2.2% CAR21 in the S&P 500 (t 2.2); low-SVR events beat
high-SVR events (+3.6% vs +0.8% CAR21).
Strategy: on each session, equal-weight all panel members whose SC 13D was
filed in the last H sessions (most recent K at most; daily rebalance to the
target basket, 20bp costs are built into the mid/small panels). PIT: the
weights row for day D includes filings with `filed <= D` and is executed at
the next open by the engine, i.e. after the filing is public.
PYTHONPATH=. uv run python -m research.lab.activist_strategy [mid|small|midsmall|us]
"""
from __future__ import annotations
import json
import os
import pickle
import sys
import numpy as np
import pandas as pd
from research.lab.activists import cik_ticker_map
from research.lab.engine import simulate
from research.lab.factor_scan import stats
TARGETS = "data/research/edgar_13d_targets.csv"
SVR_PATH = "data/research/short_volume.pkl"
def load_events() -> pd.DataFrame:
tg = pd.read_csv(TARGETS)
tg = tg[tg["target_cik"].fillna(0) > 0].copy()
cik2t = cik_ticker_map()
tg["ticker"] = tg["target_cik"].map(lambda c: cik2t.get(int(c)))
tg = tg[tg["ticker"].notna()]
# fallback by exact name
if os.path.exists("data/research/company_tickers.json"):
raw = json.load(open("data/research/company_tickers.json"))
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)
tg = tg.sort_values("filed").drop_duplicates(subset=["ticker", "filed"], keep="first")
return tg[["ticker", "filed"]].dropna()
def svr21() -> pd.DataFrame | None:
if not os.path.exists(SVR_PATH):
return None
with open(SVR_PATH, "rb") as fh:
d = pickle.load(fh)
return (d["short"] / d["total"].replace(0.0, np.nan)).rolling(21, min_periods=10).mean()
def build_weights(p, events: pd.DataFrame, hold: int, k_max: int,
svr_filter: str | None = None, svr_lag: int = 2) -> pd.DataFrame:
dates = p.dates
cols = list(p.close.columns)
pos = {c: i for i, c in enumerate(cols)}
W = np.zeros((len(dates), len(cols)))
svr = svr21()
if svr is not None:
svr = svr.reindex(columns=cols).shift(svr_lag)
med = svr.where(p.member).median(axis=1)
acts: list[tuple[int, int]] = [] # (start_idx, end_idx, col_idx)
for _, ev in events.iterrows():
i = pos.get(ev["ticker"])
if i is None:
continue
t0 = dates.searchsorted(pd.Timestamp(ev["filed"]), side="left")
if t0 >= len(dates) - 2:
continue
start, end = t0 + 1, min(t0 + 1 + hold, len(dates) - 1)
if svr_filter and svr is not None and dates[t0] in svr.index:
v = svr.at[dates[t0], ev["ticker"]] if ev["ticker"] in svr.columns else np.nan
m = med.get(dates[t0], np.nan)
if np.isfinite(v) and np.isfinite(m):
if svr_filter == "low" and v >= m:
continue
if svr_filter == "high" and v < m:
continue
acts.append((start, end, i))
if not acts:
return pd.DataFrame(0.0, index=dates, columns=cols)
starts = np.array([a[0] for a in acts])
ends = np.array([a[1] for a in acts])
idx = np.array([a[2] for a in acts])
for d in range(len(dates)):
active = np.where((starts <= d) & (d <= ends))[0]
if len(active) == 0:
continue
if k_max and len(active) > k_max:
active = active[np.argsort(-starts[active])][:k_max] # most recent filings
W[d, idx[active]] = 1.0 / len(active)
return pd.DataFrame(W, index=dates, columns=cols)
def run(mkt: str, events: pd.DataFrame, hold: int, k_max: int,
svr_filter: str | None = None) -> dict:
if mkt == "us":
with open("data/research/us.panel.pkl", "rb") as fh:
p = pickle.load(fh)
else:
from research.lab.panel_mid import load_us_mid
p = load_us_mid(mkt)
W = build_weights(p, events, hold, k_max, svr_filter)
r = simulate(p, target_weights=W, exec_at="open", min_trade=0.002)
blocks = {}
for a, b in (("2016-09-27", "2021-09-26"), ("2021-09-27", "2026-09-24")):
st = stats(r.equity.loc[a:b], r.turnover.loc[a:b])
blocks[f"{a[:4]}-{b[:4]}"] = st
return {"mkt": mkt, "hold": hold, "k": k_max, "svr": svr_filter or "none",
"blocks": blocks}
def main() -> None:
mkts = sys.argv[1:] or ["midsmall"]
events = load_events()
print(f"events loaded: {len(events)}")
results = []
for mkt in mkts:
for hold, k_max, svr_filter in ((21, 20, None), (21, 20, "low"), (63, 20, None),
(63, 20, "low"), (21, 10, None), (21, 10, "low")):
res = run(mkt, events, hold, k_max, svr_filter)
results.append(res)
line = f"{mkt:8s} H{hold:<3d} K{k_max:<3d} svr={res['svr']:4s}"
for blk, st in res["blocks"].items():
line += f" | {blk} cagr {st['cagr']*100:5.1f}% sh {st['sharpe']:5.2f} mdd {st['mdd']*100:5.1f}%"
print(line)
json.dump(results, open("data/research/results/activist_sleeve.json", "w"), indent=1)
print("saved data/research/results/activist_sleeve.json")
if __name__ == "__main__":
main()