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.

175 lines
6.1 KiB
Python

"""
FINRA Short Sale Volume endpoints
"""
from datetime import date, datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.schemas.finra import (
ShortVolumeEntry,
ShortVolumeResponse,
ShortRatioHistoryResponse,
ShortRatioPoint,
IngestResponse,
)
from app.services.finra_short_volume_service import FinraShortVolumeService
from app.utils.cache import build_cache_key, get_cached_response, set_cached_response
router = APIRouter()
@router.get(
"/short-volume/{symbol}",
response_model=ShortVolumeResponse,
summary="Get short volume data for a symbol",
description="Query FINRA RegSHO short sale volume. Auto-ingests if data is missing.",
)
async def get_short_volume(
symbol: str,
response: Response,
days: int = Query(30, ge=1, le=365, description="Number of days to look back"),
limit: int = Query(100, ge=1, le=1000, description="Max entries to return"),
force_refresh: bool = Query(False, description="Bypass cache"),
db: AsyncSession = Depends(get_db),
):
# Check cache
cache_key = build_cache_key("finra:short-volume", symbol.upper(), days, limit)
if not force_refresh:
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "redis-cache"
return cached_body
svc = FinraShortVolumeService()
end = datetime.now(timezone.utc).date()
start = end - timedelta(days=days)
rows, total = await svc.get_short_volume(
db, symbol=symbol, start_date=start, end_date=end, limit=limit
)
entries = [ShortVolumeEntry.from_orm_obj(r) for r in rows]
body = ShortVolumeResponse(
symbol=symbol.upper(),
entries=entries,
total_count=total,
metadata={
"days_requested": days,
"start_date": start.isoformat(),
"end_date": end.isoformat(),
},
)
body_dict = body.model_dump()
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "finra-db"
return body
@router.get(
"/short-ratio/{symbol}",
response_model=ShortRatioHistoryResponse,
summary="Get short ratio history for a symbol",
description="Return daily short_ratio (aggregated across markets) for the last N days.",
)
async def get_short_ratio(
symbol: str,
response: Response,
days: int = Query(60, ge=1, le=365, description="Number of days"),
force_refresh: bool = Query(False, description="Bypass cache"),
db: AsyncSession = Depends(get_db),
):
# Check cache
cache_key = build_cache_key("finra:short-ratio", symbol.upper(), days)
if not force_refresh:
cached = await get_cached_response(cache_key)
if cached:
cached_body, etag = cached
response.headers["X-Cache"] = "HIT"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "redis-cache"
return cached_body
svc = FinraShortVolumeService()
history = await svc.get_short_ratio_history(db, symbol=symbol, days=days)
points = [ShortRatioPoint(**h) for h in history]
avg = (
sum(p.short_ratio for p in points) / len(points) if points else None
)
body = ShortRatioHistoryResponse(
symbol=symbol.upper(),
history=points,
avg_short_ratio=round(avg, 6) if avg is not None else None,
metadata={"days_requested": days, "data_points": len(points)},
)
body_dict = body.model_dump()
etag = await set_cached_response(cache_key, body_dict, ttl_seconds=settings.CACHE_TTL)
response.headers["X-Cache"] = "MISS"
response.headers["Cache-Control"] = f"public, max-age={settings.CACHE_TTL}"
response.headers["ETag"] = etag
response.headers["X-Data-Source"] = "finra-db"
return body
@router.post(
"/admin/ingest",
response_model=IngestResponse,
summary="Manually ingest FINRA short volume data",
description="Download and ingest FINRA short volume file(s) for a specific date or date range.",
)
async def ingest_short_volume(
date_str: Optional[str] = Query(None, alias="date", description="Single date (YYYY-MM-DD)"),
start_date: Optional[date] = Query(None, description="Range start (YYYY-MM-DD)"),
end_date: Optional[date] = Query(None, description="Range end (YYYY-MM-DD)"),
force: bool = Query(False, description="Re-ingest even if data exists"),
db: AsyncSession = Depends(get_db),
):
svc = FinraShortVolumeService()
# Single date
if date_str:
try:
target = datetime.strptime(date_str, "%Y-%m-%d").date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.")
count = await svc.ingest_date(db, target, force_refresh=force)
return IngestResponse(
date=target.isoformat(),
records_ingested=count,
status="completed",
)
# Date range
if start_date and end_date:
if start_date > end_date:
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
count = await svc.ingest_date_range(db, start_date, end_date, force_refresh=force)
return IngestResponse(
date_range={"start": start_date.isoformat(), "end": end_date.isoformat()},
records_ingested=count,
status="completed",
)
raise HTTPException(
status_code=400,
detail="Provide either 'date' (single date) or both 'start_date' and 'end_date'.",
)