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.

237 lines
8.6 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 Short Sale Volume endpoints
"""
from datetime import date, datetime, timedelta, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import text
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.get(
"/pit-panel",
summary="PIT 횡단면 패널 — 특정 날짜 전체 심볼 (생존편향-0)",
description=(
"특정 날짜(또는 날짜 범위)에 실제 거래되던 모든 종목의 공매도량 + 종가를 반환.\n\n"
"`pit_universe_membership` 뷰 ⋈ `alpaca_price_data(interval='1d', adjustment='all')` 조인.\n\n"
"**생존편향-0**: 상폐/합병 종목(SIVB, FRC, TWTR 등)도 그날 거래됐으면 포함됨.\n\n"
"**날짜 범위**: `date_from`/`date_to` 둘 다 지정 시 최대 `limit`일치 반환 (기본 1일).\n\n"
"**주의**: 전체 패널(22k×8yr)은 날짜별 반복 호출로 조합. "
"단일 날짜 응답은 ~11k rows."
),
)
async def get_pit_panel(
date_from: date = Query(..., description="조회 시작일 (YYYY-MM-DD)"),
date_to: Optional[date] = Query(None, description="조회 종료일 — 생략 시 date_from 단일 날짜"),
limit: int = Query(50000, ge=1, le=200000, description="최대 반환 행 수"),
db: AsyncSession = Depends(get_db),
):
if date_to is None:
date_to = date_from
if date_from > date_to:
raise HTTPException(status_code=400, detail="date_from must be <= date_to")
rows = (await db.execute(
text("""
SELECT
p.d AS date,
p.symbol,
p.short_volume,
p.short_exempt_volume,
p.total_volume,
p.short_ratio,
a.open,
a.high,
a.low,
a.close,
a.volume AS price_volume,
a.vwap
FROM pit_universe_membership p
LEFT JOIN alpaca_price_data a
ON a.ticker = p.symbol
AND a.date::date = p.d
AND a.interval = '1d'
WHERE p.d BETWEEN :d_from AND :d_to
ORDER BY p.d, p.symbol
LIMIT :lim
"""),
{"d_from": date_from, "d_to": date_to, "lim": limit},
)).fetchall()
data = [
{
"date": str(r.date),
"symbol": r.symbol,
"short_volume": r.short_volume,
"short_exempt_volume":r.short_exempt_volume,
"total_volume": r.total_volume,
"short_ratio": r.short_ratio,
"open": r.open,
"high": r.high,
"low": r.low,
"close": r.close,
"price_volume": r.price_volume,
"vwap": r.vwap,
}
for r in rows
]
price_matched = sum(1 for d in data if d["close"] is not None)
return {
"date_from": date_from.isoformat(),
"date_to": date_to.isoformat(),
"count": len(data),
"price_matched": price_matched,
"price_coverage_pct": round(100 * price_matched / len(data), 1) if data else 0,
"data": data,
}
@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'.",
)