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.

157 lines
5.6 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.\n\n"
"**DB 보유**: 2021년 ~ 현재 (5년치 백필 완료). "
"추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`\n\n"
"**데이터 소스**: FINRA RegSHO CDN (공개, API 키 불필요). 주말/공휴일 데이터 없음."
),
)
@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=3650, description="Number of days to look back (max ~10 years)"),
limit: int = Query(100, ge=1, le=10000, 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.\n\n"
"**DB 보유**: 2021년 ~ 현재 (5년치). days 최대 3650 (10년).\n\n"
"추가 백필: `POST /finra/admin/ingest?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD`"
),
)
@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=3650, description="Number of days (max ~10 years)"),
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.\n\n"
"**백필 예시**:\n"
"- 단일 날짜: `?date=2025-01-15`\n"
"- 날짜 범위: `?start_date=2025-01-01&end_date=2025-12-31`\n"
"- 이미 있는 데이터 재인제스트: `?start_date=...&end_date=...&force=true`\n\n"
"주말/공휴일은 자동으로 건너뜀. 1년치 기준 약 20-40분 소요."
),
)
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'.",
)