""" FINRA Short Sale Volume endpoints """ import logging import os from datetime import date, datetime, timedelta, timezone from typing import List, Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi.responses import FileResponse, Response from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import AsyncSessionLocal logger = logging.getLogger(__name__) 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, } _EXPORT_PATH = "/app/data/pit_panel.parquet" async def _run_pit_export(start_str: str, end_str: str) -> None: """Background task: export PIT panel to /app/data/pit_panel.parquet.""" try: import pandas as pd except ImportError: logger.error("pandas not installed"); return try: import pyarrow # noqa: F401 except ImportError: logger.error("pyarrow not installed"); return logger.info(f"PIT export: loading {start_str} → {end_str} ...") from datetime import date as date_type import pyarrow as pa import pyarrow.parquet as pq d_from = date_type.fromisoformat(start_str) d_to = date_type.fromisoformat(end_str) COLS = ["date","symbol","short_volume","short_exempt_volume","total_volume", "short_ratio","open","high","low","close","price_volume","vwap"] SQL = text(""" SELECT p.d::date 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 :s AND :e ORDER BY p.d, p.symbol """) os.makedirs(os.path.dirname(_EXPORT_PATH), exist_ok=True) tmp_path = _EXPORT_PATH + ".tmp" # 연도별 청킹으로 메모리 제한 내 처리 writer = None total_rows = 0 year = d_from.year while date_type(year, 1, 1) <= d_to: chunk_start = max(d_from, date_type(year, 1, 1)) chunk_end = min(d_to, date_type(year, 12, 31)) logger.info(f"PIT export: fetching {chunk_start} → {chunk_end} ...") async with AsyncSessionLocal() as db: rows = (await db.execute(SQL, {"s": chunk_start, "e": chunk_end})).fetchall() if rows: df_chunk = pd.DataFrame(rows, columns=COLS) df_chunk["date"] = pd.to_datetime(df_chunk["date"]) table = pa.Table.from_pandas(df_chunk, preserve_index=False) if writer is None: writer = pq.ParquetWriter(tmp_path, table.schema, compression="snappy") writer.write_table(table) total_rows += len(rows) logger.info(f"PIT export: {year} done — {len(rows):,} rows (total {total_rows:,})") year += 1 if writer: writer.close() os.replace(tmp_path, _EXPORT_PATH) mb = os.path.getsize(_EXPORT_PATH) / 1_048_576 if os.path.exists(_EXPORT_PATH) else 0 logger.info( f"PIT export complete: {_EXPORT_PATH} | {mb:.0f} MB | " f"total_rows={total_rows:,} | cols={COLS}" ) @router.post( "/admin/export-pit-panel", summary="PIT 패널 parquet export → /app/data/pit_panel.parquet", description=( "FINRA × Alpaca(adjustment='all') 조인 패널을 parquet으로 저장.\n\n" "`GET /finra/admin/export-pit-panel/download` 로 다운로드.\n\n" "백그라운드 실행 — 전체 기간(2018-08~현재) 기준 수 분 ~ 10여 분 소요." ), ) async def export_pit_panel( background_tasks: BackgroundTasks, start_date: date = Query(date(2018, 8, 1), description="시작일"), end_date: date = Query(None, description="종료일 (기본: 오늘)"), ): _end = end_date or date.today() background_tasks.add_task(_run_pit_export, start_date.isoformat(), _end.isoformat()) return { "status": "started", "start_date": start_date.isoformat(), "end_date": _end.isoformat(), "output": _EXPORT_PATH, "note": "완료 후 GET /finra/admin/export-pit-panel/download 로 다운로드.", } @router.get( "/admin/export-pit-panel/download", summary="pit_panel.parquet 다운로드", ) async def download_pit_panel(): if not os.path.exists(_EXPORT_PATH): raise HTTPException( status_code=404, detail="parquet 파일 없음. POST /finra/admin/export-pit-panel 먼저 실행.", ) return FileResponse( _EXPORT_PATH, media_type="application/octet-stream", filename="pit_panel.parquet", ) @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'.", )