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
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 보유**: 2026-02-10 ~ 현재 (약 28거래일). "
|
|
"더 긴 과거 데이터가 필요하면 `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=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.\n\n"
|
|
"**현재 DB 보유**: 2026-02-10 ~ 현재. z-score 신호 품질 향상을 위해 1년치 이상 백필 권장.\n\n"
|
|
"백필: `POST /finra/admin/ingest?start_date=2025-01-02&end_date=2025-12-31`"
|
|
),
|
|
)
|
|
@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.\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'.",
|
|
)
|