From 843a188b76fe5e61856f127013b4df78e97ea164 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 30 May 2026 18:39:04 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20GET=20/finra/pit-panel=20=E2=80=94=20PI?= =?UTF-8?q?T=20=ED=9A=A1=EB=8B=A8=EB=A9=B4=20=ED=8C=A8=EB=84=90=20?= =?UTF-8?q?=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 날짜별 전체 심볼의 공매도량 + 조정종가 조인 결과 반환. pit_universe_membership ⋈ alpaca_price_data(interval='1d') 조인. 생존편향-0: 상폐 종목도 그날 거래됐으면 포함. Co-Authored-By: Claude Opus 4.8 --- app/api/v1/endpoints/finra.py | 82 ++++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) 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,