|
|
"""
|
|
|
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'.",
|
|
|
)
|