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>
140 lines
5.5 KiB
Python
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()
|