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.
141 lines
4.7 KiB
Python
141 lines
4.7 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, with_cache
|
|
|
|
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.",
|
|
)
|
|
@with_cache(namespace="finra:short-volume", ttl=None, key_params=["symbol", "days", "limit"])
|
|
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),
|
|
):
|
|
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(),
|
|
},
|
|
)
|
|
|
|
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.",
|
|
)
|
|
@with_cache(namespace="finra:short-ratio", ttl=None, key_params=["symbol", "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),
|
|
):
|
|
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)},
|
|
)
|
|
|
|
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'.",
|
|
)
|