diff --git a/app/api/v1/endpoints/finra.py b/app/api/v1/endpoints/finra.py index 03fbcc8..56ee160 100644 --- a/app/api/v1/endpoints/finra.py +++ b/app/api/v1/endpoints/finra.py @@ -3,10 +3,11 @@ FINRA Short Sale Volume endpoints """ from datetime import date, datetime, timedelta, timezone -from typing import Optional +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 @@ -104,6 +105,85 @@ async def get_short_ratio( 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,