You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fithia2/scripts/backfill_finra_short_volume...

172 lines
5.2 KiB
Python

"""Backfill short_sale_daily from FINRA CDN daily files.
FINRA publishes daily short-volume files at:
https://cdn.finra.org/equity/regsho/daily/CNMSshvol{yyyymmdd}.txt
This script downloads each file for the requested date range and bulk-inserts
into the short_sale_daily table, filtering to the midlarge universe only.
Usage:
.venv/bin/python3 scripts/backfill_finra_short_volume_cdn.py \
--start-date 2025-07-01 --end-date 2026-04-22
Idempotent: uses ON CONFLICT DO NOTHING.
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import os
import sys
import time
from pathlib import Path
import asyncpg
import requests
import yaml
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from libs.common.config import get_settings
from libs.common.time_utils import trading_days_between
FINRA_CDN_URL = "https://cdn.finra.org/equity/regsho/daily/CNMSshvol{yyyymmdd}.txt"
UNIVERSE_FILE = "configs/symbols_midlarge_snapshot_exact.yaml"
BATCH_SIZE = 1000
def load_universe() -> set[str]:
with open(UNIVERSE_FILE) as f:
udata = yaml.safe_load(f)
syms = udata.get("symbols", udata) if isinstance(udata, dict) else udata
return {s.upper() for s in syms}
def fetch_day(date: dt.date, universe: set[str]) -> list[dict]:
url = FINRA_CDN_URL.format(yyyymmdd=date.strftime("%Y%m%d"))
try:
resp = requests.get(url, timeout=15)
except requests.RequestException as e:
print(f" {date}: request error: {e}")
return []
if resp.status_code != 200:
return []
lines = [l.strip() for l in resp.text.splitlines() if l.strip()]
if not lines:
return []
header = [p.strip() for p in lines[0].split("|")]
try:
sym_idx = header.index("Symbol")
short_idx = header.index("ShortVolume")
total_idx = header.index("TotalVolume")
except ValueError:
return []
rows = []
for line in lines[1:]:
parts = [p.strip() for p in line.split("|")]
if len(parts) <= max(sym_idx, short_idx, total_idx):
continue
sym = parts[sym_idx].upper()
if sym not in universe:
continue
try:
short_vol = int(float(parts[short_idx]))
total_vol = int(float(parts[total_idx]))
except (ValueError, IndexError):
continue
if total_vol <= 0:
continue
rows.append({
"ticker_raw": sym,
"trade_date": date,
"short_volume": short_vol,
"short_exempt_volume": None,
"total_volume": total_vol,
})
return rows
async def insert_batch(conn: asyncpg.Connection, rows: list[dict]) -> int:
if not rows:
return 0
now = dt.datetime.now(tz=dt.timezone.utc)
inserted = await conn.executemany(
"""
INSERT INTO short_sale_daily
(ticker_raw, trade_date, short_volume, short_exempt_volume, total_volume, source_name, created_at_utc)
VALUES ($1, $2, $3, $4, $5, 'finra_cdn', $6)
ON CONFLICT ON CONSTRAINT uq_short_sale_ticker_date_source DO NOTHING
""",
[
(r["ticker_raw"], r["trade_date"], r["short_volume"],
r["short_exempt_volume"], r["total_volume"], now)
for r in rows
],
)
return len(rows)
async def main(start_date: dt.date, end_date: dt.date) -> None:
universe = load_universe()
print(f"Universe: {len(universe)} tickers")
# Get trading days in range
trading_days = [
d for d in trading_days_between(start_date, end_date)
if start_date <= d <= end_date
]
print(f"Trading days: {len(trading_days)} ({trading_days[0]}{trading_days[-1]})")
dsn = str(get_settings().postgres_dsn).replace("+asyncpg", "")
conn = await asyncpg.connect(dsn)
total_rows = 0
total_days = 0
try:
pending: list[dict] = []
for i, day in enumerate(trading_days):
rows = fetch_day(day, universe)
if rows:
pending.extend(rows)
total_days += 1
if (i + 1) % 20 == 0:
print(f" [{i+1}/{len(trading_days)}] day={day}, pending={len(pending)}")
else:
if (i + 1) % 20 == 0:
print(f" [{i+1}/{len(trading_days)}] day={day}, skip (no data)")
if len(pending) >= BATCH_SIZE:
n = await insert_batch(conn, pending)
total_rows += n
pending = []
print(f" → inserted batch, total={total_rows}")
time.sleep(0.05) # gentle rate limiting
if pending:
n = await insert_batch(conn, pending)
total_rows += n
finally:
await conn.close()
print(f"\nDone. {total_days} trading days fetched, {total_rows} rows inserted.")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--start-date", default="2025-07-01")
parser.add_argument("--end-date", default=dt.date.today().isoformat())
args = parser.parse_args()
asyncio.run(main(
dt.date.fromisoformat(args.start_date),
dt.date.fromisoformat(args.end_date),
))