Close V25 FINRA short-volume axis: backfill CDN data + Phase 1 diagnostic
- scripts/backfill_finra_short_volume_cdn.py: bulk backfill FINRA short sale CDN files (400d) into short_sale_daily table (382K rows inserted) - apps/intraday_bt/scripts/diag_orb_short_volume_v46.py: Phase 1 diagnostic on V46 400d trade set (180 trades, 99% coverage) - scripts/audit_short_volume_coverage.py: fix asyncpg date param types Result: all three short-ratio features fail G2 (max +0.168R vs gate 0.30R). Direction is INVERTED from Boehmer (short squeeze dominates over informed-bear signal in gap-up ORB). V25 axis permanently closed. V46 terminal. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
08e41831bc
commit
92840b857a
@ -0,0 +1,171 @@
|
||||
"""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),
|
||||
))
|
||||
Loading…
Reference in New Issue