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>
149 lines
5.5 KiB
Python
149 lines
5.5 KiB
Python
"""Fetch FINRA REG SHO daily short-sale volume for the research universes.
|
|
|
|
One public file per trading day covers every NMS symbol (no auth):
|
|
|
|
https://cdn.finra.org/equity/regsho/daily/CNMSshvolYYYYMMDD.txt
|
|
Date|Symbol|ShortVolume|ShortExemptVolume|TotalVolume|Market
|
|
|
|
Writes data/research/short_volume.pkl: {"short": DataFrame, "total": DataFrame}
|
|
(float32, date x symbol), filtered to the symbols of data/us.csv plus the US
|
|
mid/small panels (us_mid / us_midsmall). Resume-safe: re-running fetches only
|
|
days that are missing from the store. Network goes through QUANT_MARKET_PROXY
|
|
when set (same convention as the other fetchers).
|
|
|
|
PYTHONPATH=. uv run python -m research.lab.fetch_shortvolume [--days-limit N]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import pickle
|
|
import sys
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
import pandas as pd
|
|
import requests
|
|
|
|
from research.lab.fetch_us import _proxy_env
|
|
|
|
OUT = os.path.join("data", "research", "short_volume.pkl")
|
|
SYMBOLS_CACHE = os.path.join("data", "research", "shortvolume_symbols.json")
|
|
URL = "https://cdn.finra.org/equity/regsho/daily/CNMSshvol{day}.txt"
|
|
HEADERS = {"User-Agent": "Mozilla/5.0 (research; contact local)"}
|
|
WORKERS = 6
|
|
|
|
|
|
def universe_symbols() -> list[str]:
|
|
if os.path.exists(SYMBOLS_CACHE):
|
|
return json.load(open(SYMBOLS_CACHE))
|
|
syms = set()
|
|
us = pd.read_csv("data/us.csv", index_col=0, nrows=0)
|
|
syms.update(us.columns)
|
|
for path in ("data/research/us_mid.panel.pkl", "data/research/us_midsmall.panel.pkl"):
|
|
if os.path.exists(path):
|
|
with open(path, "rb") as fh:
|
|
panel = pickle.load(fh)
|
|
cols = getattr(panel, "close").columns if hasattr(panel, "close") else None
|
|
if cols is None and isinstance(panel, dict):
|
|
cols = panel["close"].columns
|
|
syms.update(str(c).replace(".", "-") for c in cols)
|
|
syms.discard("")
|
|
out = sorted(syms)
|
|
json.dump(out, open(SYMBOLS_CACHE, "w"))
|
|
return out
|
|
|
|
|
|
def trading_days() -> list[str]:
|
|
close = pd.read_csv("data/us.csv", index_col=0, usecols=[0], parse_dates=True)
|
|
days = close.index
|
|
if os.path.exists("data/etfs.csv"):
|
|
etfs = pd.read_csv("data/etfs.csv", index_col=0, usecols=[0], parse_dates=True)
|
|
days = days.union(etfs.index)
|
|
return [d.strftime("%Y%m%d") for d in days]
|
|
|
|
|
|
def fetch_day(day: str, symbols: set[str]) -> tuple[str, dict, dict] | None:
|
|
url = URL.format(day=day)
|
|
for attempt in range(4):
|
|
try:
|
|
r = requests.get(url, headers=HEADERS, timeout=40)
|
|
if r.status_code in (403, 404):
|
|
# FINRA's public archive keeps only ~8 years; older days are gone.
|
|
return day, {}, {}
|
|
r.raise_for_status()
|
|
short, total = {}, {}
|
|
for line in r.text.splitlines()[1:]:
|
|
parts = line.split("|")
|
|
if len(parts) < 5:
|
|
continue
|
|
sym = parts[1]
|
|
if sym not in symbols:
|
|
continue
|
|
try:
|
|
s, t = float(parts[2]), float(parts[4])
|
|
except ValueError:
|
|
continue
|
|
short[sym] = short.get(sym, 0.0) + s
|
|
total[sym] = total.get(sym, 0.0) + t
|
|
return day, short, total
|
|
except Exception as exc: # noqa: BLE001
|
|
if attempt == 3:
|
|
print(f" FAILED {day}: {exc}")
|
|
else:
|
|
import time
|
|
time.sleep(1.5 * (attempt + 1))
|
|
return None
|
|
|
|
|
|
def main() -> None:
|
|
_proxy_env()
|
|
limit = None
|
|
if "--days-limit" in sys.argv:
|
|
limit = int(sys.argv[sys.argv.index("--days-limit") + 1])
|
|
symbols = set(universe_symbols())
|
|
print(f"universe symbols: {len(symbols)}")
|
|
days = trading_days()
|
|
|
|
prev_short = prev_total = None
|
|
have: set[str] = set()
|
|
if os.path.exists(OUT):
|
|
with open(OUT, "rb") as fh:
|
|
d = pickle.load(fh)
|
|
prev_short, prev_total = d["short"], d["total"]
|
|
have = {pd.Timestamp(i).strftime("%Y%m%d") for i in prev_short.index}
|
|
todo = [d for d in days if d not in have]
|
|
if limit:
|
|
todo = todo[:limit]
|
|
print(f"days: {len(days)}, already stored: {len(have)}, fetching: {len(todo)}")
|
|
|
|
short_rows: dict[str, dict] = {}
|
|
total_rows: dict[str, dict] = {}
|
|
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
|
|
futs = {ex.submit(fetch_day, d, symbols): d for d in todo}
|
|
done = 0
|
|
for fut in as_completed(futs):
|
|
res = fut.result()
|
|
done += 1
|
|
if res is not None:
|
|
day, short, total = res
|
|
short_rows[day] = short
|
|
total_rows[day] = total
|
|
if done % 200 == 0:
|
|
print(f" {done}/{len(todo)} fetched")
|
|
|
|
new_short = pd.DataFrame.from_dict(short_rows, orient="index")
|
|
new_total = pd.DataFrame.from_dict(total_rows, orient="index")
|
|
for df in (new_short, new_total):
|
|
df.index = pd.to_datetime(df.index, format="%Y%m%d")
|
|
frames_short = [f for f in (prev_short, new_short) if f is not None and len(f)]
|
|
frames_total = [f for f in (prev_total, new_total) if f is not None and len(f)]
|
|
df_short = pd.concat(frames_short).sort_index()
|
|
df_total = pd.concat(frames_total).sort_index()
|
|
with open(OUT, "wb") as fh:
|
|
pickle.dump({"short": df_short.astype("float32"), "total": df_total.astype("float32")}, fh)
|
|
print(f"stored {df_short.shape} short / {df_total.shape} total -> {OUT}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|