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>
153 lines
5.6 KiB
Python
153 lines
5.6 KiB
Python
"""Institutional-flow factor scan from the DERA 13F aggregates.
|
|
|
|
Per quarter, total institutional shares per CUSIP are aggregated by
|
|
fetch_13f.py. Here they map to tickers (issuer name -> SEC title map) and
|
|
become the quarterly factor:
|
|
|
|
inst_flow = shares(q) / shares(q-1) - 1 (net institutional accumulation)
|
|
filer_chg = n_filers(q) - n_filers(q-1) (breadth of accumulation)
|
|
|
|
PIT: the 13F deadline is 45 days after quarter end; the factor is applied
|
|
conservatively from quarter_end + 60 calendar days.
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.inst_flow [us|midsmall]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import glob
|
|
import json
|
|
import os
|
|
import pickle
|
|
import re
|
|
import sys
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
DIR = os.path.join("data", "research", "dera13f")
|
|
TICKERS_CACHE = "data/research/company_tickers.json"
|
|
PUBLISH_LAG_DAYS = 60
|
|
|
|
_SUFFIX = re.compile(r"[,\s]*(inc|incorporated|corp|corporation|co|company|ltd|limited|plc|"
|
|
r"holdings?|group|the|nv|sa|ag|llc|lp|trust|reit|new|cl|a|b)[.\s]*$", re.I)
|
|
|
|
|
|
def norm_name(name: str) -> str:
|
|
if not isinstance(name, str):
|
|
return ""
|
|
n = name.upper().strip().rstrip(".")
|
|
n = re.sub(r"[^A-Z0-9 ]+", " ", n)
|
|
n = re.sub(r"\s+", " ", n)
|
|
prev = None
|
|
while prev != n:
|
|
prev = n
|
|
n = _SUFFIX.sub("", n).strip()
|
|
return n
|
|
|
|
|
|
def load_quarter(path: str) -> pd.DataFrame:
|
|
d = pd.read_csv(path, dtype={"CUSIP": str})
|
|
d = d[d["CUSIP"].notna() & (d["CUSIP"] != "000000000")]
|
|
d["issuer"] = d["issuer"].astype(str)
|
|
d["norm"] = d["issuer"].map(norm_name)
|
|
return d
|
|
|
|
|
|
def main() -> None:
|
|
which = sys.argv[1] if len(sys.argv) > 1 else "us"
|
|
files = sorted(glob.glob(os.path.join(DIR, "agg_*.csv")))
|
|
if not files:
|
|
raise SystemExit("no agg files; run fetch_13f first")
|
|
print(f"quarters: {len(files)}")
|
|
raw = json.load(open(TICKERS_CACHE))
|
|
name2t = {}
|
|
for v in raw.values():
|
|
name2t.setdefault(norm_name(str(v["title"])), str(v["ticker"]).upper().replace(".", "-"))
|
|
# combine quarters: cusip -> norm name from latest, and shares series
|
|
shares = {}
|
|
filers = {}
|
|
name_by_cusip = {}
|
|
for path in files:
|
|
q = os.path.basename(path)[4:-4] # YYYYqQ
|
|
d = load_quarter(path)
|
|
d = d[d["shares"].fillna(0) > 0]
|
|
shares[q] = d.set_index("CUSIP")["shares"]
|
|
filers[q] = d.set_index("CUSIP")["n_filers"]
|
|
for c, n in d.set_index("CUSIP")["norm"].items():
|
|
name_by_cusip[c] = n
|
|
qs = [os.path.basename(p)[4:-4] for p in files]
|
|
matched, ticker_by_cusip = 0, {}
|
|
for c, n in name_by_cusip.items():
|
|
t = name2t.get(n)
|
|
if t:
|
|
ticker_by_cusip[c] = t
|
|
matched += 1
|
|
print(f"cusip->ticker matched: {matched}/{len(name_by_cusip)}")
|
|
|
|
# factor frame on quarter end dates (apply from +PUBLISH_LAG_DAYS)
|
|
rows = {}
|
|
prev = None
|
|
for q in qs:
|
|
if prev is None:
|
|
prev = q
|
|
continue
|
|
s1 = shares[prev].reindex(shares[q].index)
|
|
flow = shares[q] / s1.replace(0.0, np.nan) - 1.0
|
|
d_fil = filers[q] - filers[prev].reindex(filers[q].index)
|
|
# quarter-end date
|
|
y, qq = int(q[:4]), int(q[-1])
|
|
qend = pd.Timestamp(y, qq * 3, 1) + pd.offsets.MonthEnd(0)
|
|
avail = qend + pd.Timedelta(days=PUBLISH_LAG_DAYS)
|
|
t = pd.Series({ticker_by_cusip[c]: f for c, f in flow.items() if c in ticker_by_cusip and np.isfinite(f)})
|
|
tc = pd.Series({ticker_by_cusip[c]: f for c, f in d_fil.items() if c in ticker_by_cusip and np.isfinite(f)})
|
|
if len(t):
|
|
rows[avail] = (t, tc)
|
|
prev = q
|
|
print(f"quarters with factors: {len(rows)}")
|
|
|
|
if which == "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(which)
|
|
close, member = p.close, p.member.reindex_like(p.close).fillna(False)
|
|
fwd21 = close.shift(-21) / close - 1.0
|
|
# broadcast each quarterly factor until the next availability date
|
|
fac = pd.DataFrame(np.nan, index=close.index, columns=close.columns)
|
|
facf = fac.copy()
|
|
dates_avail = sorted(rows)
|
|
for i, dt in enumerate(dates_avail):
|
|
t, tc = rows[dt]
|
|
end = dates_avail[i + 1] if i + 1 < len(dates_avail) else close.index[-1]
|
|
sel = (close.index >= dt) & (close.index < end)
|
|
for name, ser in (("flow", t), ("filers", tc)):
|
|
frame = fac if name == "flow" else facf
|
|
frame.loc[sel, frame.columns.isin(ser.index)] = ser.reindex(
|
|
frame.columns[frame.columns.isin(ser.index)]).values
|
|
months = fwd21.index.to_period("M")
|
|
for name, frame in (("inst_flow", fac), ("filer_chg", facf)):
|
|
f = frame.where(member)
|
|
ics = []
|
|
for per in sorted(set(months)):
|
|
idx = months == per
|
|
xdf = f.loc[idx]
|
|
if xdf.notna().sum(axis=1).max() < 50:
|
|
continue
|
|
ydf = fwd21.loc[idx]
|
|
mask = xdf.notna().to_numpy() & np.isfinite(ydf.reindex(columns=xdf.columns).to_numpy())
|
|
if mask.sum() < 100:
|
|
continue
|
|
xr = xdf.rank(axis=1).to_numpy()[mask]
|
|
yr = ydf.reindex(columns=xdf.columns).where(mask).rank(axis=1).to_numpy()[mask]
|
|
xm, ym = xr - xr.mean(), yr - yr.mean()
|
|
denom = np.sqrt((xm ** 2).sum() * (ym ** 2).sum())
|
|
if denom > 0:
|
|
ics.append(float((xm * ym).sum() / denom))
|
|
ic = np.array(ics)
|
|
print(f"{name:10s} IC21 {ic.mean():+.4f} t={ic.mean()/ic.std()*np.sqrt(len(ic)):+.2f} months={len(ic)}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|