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.
stock-oracle/app/services/finra_short_volume_service.py

293 lines
9.8 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
FINRA RegSHO daily short sale volume data service
"""
import logging
from datetime import date, datetime, timedelta, timezone
from typing import Dict, List, Optional, Tuple
import aiohttp
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from app.models.finra_short_volume import FinraShortVolume
logger = logging.getLogger(__name__)
FINRA_CDN_BASE = "https://cdn.finra.org/equity/regsho/daily"
class FinraShortVolumeService:
"""FINRA RegSHO daily short sale volume data"""
# ------------------------------------------------------------------
# Fetch & Parse
# ------------------------------------------------------------------
async def fetch_daily_file(self, target_date: date) -> Optional[str]:
"""
Download the CNMS short volume file for *target_date*.
Returns the raw text or None if 404 (weekend/holiday).
"""
filename = f"CNMSshvol{target_date:%Y%m%d}.txt"
url = f"{FINRA_CDN_BASE}/{filename}"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=30)) as resp:
if resp.status == 404:
logger.debug(f"FINRA file not found (holiday/weekend): {filename}")
return None
resp.raise_for_status()
text = await resp.text()
logger.info(f"FINRA: downloaded {filename} ({len(text)} bytes)")
return text
except aiohttp.ClientError as e:
logger.error(f"FINRA download error for {filename}: {e}")
return None
def parse_short_volume_file(self, text: str) -> List[Dict]:
"""
Parse pipe-delimited FINRA short volume text.
Expected header:
Date|Symbol|ShortVolume|ShortExemptVolume|TotalVolume|Market
Returns list of dicts ready for DB insertion.
"""
lines = text.strip().splitlines()
if len(lines) < 2:
return []
records: List[Dict] = []
for line in lines[1:]:
parts = line.split("|")
if len(parts) < 6:
continue
raw_date, symbol, short_vol, exempt_vol, total_vol, market = (
parts[0].strip(),
parts[1].strip(),
parts[2].strip(),
parts[3].strip(),
parts[4].strip(),
parts[5].strip(),
)
if not symbol or not total_vol:
continue
try:
dt = datetime.strptime(raw_date, "%Y%m%d").replace(tzinfo=timezone.utc)
sv = float(short_vol)
sev = float(exempt_vol) if exempt_vol else 0.0
tv = float(total_vol)
ratio = sv / tv if tv > 0 else 0.0
except (ValueError, ZeroDivisionError):
continue
records.append(
{
"date": dt,
"symbol": symbol.upper(),
"short_volume": sv,
"short_exempt_volume": sev,
"total_volume": tv,
"market": market if market else None,
"short_ratio": round(ratio, 6),
}
)
return records
# ------------------------------------------------------------------
# Ingest
# ------------------------------------------------------------------
async def ingest_date(
self, db: AsyncSession, target_date: date, force_refresh: bool = False
) -> int:
"""
Download, parse, and bulk-upsert data for a single date.
Returns number of records inserted.
"""
if not force_refresh:
# Check if already ingested
count = await self._count_for_date(db, target_date)
if count > 0:
logger.info(f"FINRA date {target_date} already ingested ({count} rows)")
return 0
text = await self.fetch_daily_file(target_date)
if text is None:
return 0
records = self.parse_short_volume_file(text)
if not records:
return 0
# Bulk insert skip duplicates
inserted = 0
for rec in records:
existing = await db.execute(
select(FinraShortVolume.id).where(
and_(
FinraShortVolume.symbol == rec["symbol"],
FinraShortVolume.date == rec["date"],
FinraShortVolume.market == rec["market"],
)
)
)
if existing.first():
continue
db.add(FinraShortVolume(**rec))
inserted += 1
if inserted:
await db.commit()
logger.info(f"FINRA: ingested {inserted} records for {target_date}")
return inserted
async def ingest_date_range(
self,
db: AsyncSession,
start_date: date,
end_date: date,
force_refresh: bool = False,
) -> int:
"""Ingest short volume data for a date range (weekdays only)."""
total = 0
current = start_date
while current <= end_date:
# Skip weekends
if current.weekday() < 5:
count = await self.ingest_date(db, current, force_refresh=force_refresh)
total += count
current += timedelta(days=1)
logger.info(f"FINRA range ingest: {total} records from {start_date} to {end_date}")
return total
# ------------------------------------------------------------------
# Query
# ------------------------------------------------------------------
async def get_short_volume(
self,
db: AsyncSession,
symbol: str,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: int = 100,
) -> Tuple[List[FinraShortVolume], int]:
"""
Query short volume for a symbol within a date range.
If no data found, attempts auto-ingest for the requested range.
"""
symbol = symbol.upper()
conditions = [FinraShortVolume.symbol == symbol]
if start_date:
conditions.append(
FinraShortVolume.date >= datetime.combine(start_date, datetime.min.time()).replace(tzinfo=timezone.utc)
)
if end_date:
conditions.append(
FinraShortVolume.date <= datetime.combine(end_date, datetime.min.time()).replace(tzinfo=timezone.utc)
)
# Count
count_q = await db.execute(
select(func.count(FinraShortVolume.id)).where(and_(*conditions))
)
total_count = count_q.scalar() or 0
# If no data, try auto-ingest
if total_count == 0 and start_date and end_date:
ingested = await self.ingest_date_range(db, start_date, end_date)
if ingested > 0:
count_q = await db.execute(
select(func.count(FinraShortVolume.id)).where(and_(*conditions))
)
total_count = count_q.scalar() or 0
# Fetch
result = await db.execute(
select(FinraShortVolume)
.where(and_(*conditions))
.order_by(desc(FinraShortVolume.date))
.limit(limit)
)
rows = result.scalars().all()
return rows, total_count
async def get_short_ratio_history(
self,
db: AsyncSession,
symbol: str,
days: int = 30,
) -> List[Dict]:
"""
Return short_ratio history for the last *days* trading days.
Groups by date (aggregates across markets).
"""
symbol = symbol.upper()
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await db.execute(
select(
FinraShortVolume.date,
func.sum(FinraShortVolume.short_volume).label("short_volume"),
func.sum(FinraShortVolume.short_exempt_volume).label("short_exempt_volume"),
func.sum(FinraShortVolume.total_volume).label("total_volume"),
)
.where(
and_(
FinraShortVolume.symbol == symbol,
FinraShortVolume.date >= cutoff,
)
)
.group_by(FinraShortVolume.date)
.order_by(FinraShortVolume.date)
)
history = []
for row in result.fetchall():
dt, sv, sev, tv = row
ratio = sv / tv if tv and tv > 0 else 0.0
history.append(
{
"date": dt.date() if isinstance(dt, datetime) else dt,
"short_volume": sv,
"short_exempt_volume": sev,
"total_volume": tv,
"short_ratio": round(ratio, 6),
}
)
# Auto-ingest if empty
if not history:
start = (datetime.now(timezone.utc) - timedelta(days=days)).date()
end = datetime.now(timezone.utc).date()
ingested = await self.ingest_date_range(db, start, end)
if ingested > 0:
return await self.get_short_ratio_history(db, symbol, days)
return history
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
async def _count_for_date(self, db: AsyncSession, target_date: date) -> int:
dt = datetime.combine(target_date, datetime.min.time()).replace(tzinfo=timezone.utc)
result = await db.execute(
select(func.count(FinraShortVolume.id)).where(FinraShortVolume.date == dt)
)
return result.scalar() or 0