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