""" Historical Stock Universe endpoints — backtesting universe construction Endpoints: GET /universe/screen — Screen stocks at a historical date by market_cap, sector, etc. GET /universe/registry — Browse the registered ticker universe POST /universe/admin/discover — Discover and register US tickers via yfinance screener POST /universe/admin/build-snapshots — Build monthly market_cap snapshots (long-running) """ import logging from typing import Optional from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query from fastapi.responses import Response from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.schemas.universe import ( RegistryResponse, SnapshotBuildRequest, TickerRegistryItem, UniverseScreenResponse, UniverseSnapshotItem, ) from app.services.universe_service import UniverseService from app.utils.cache import with_cache router = APIRouter() logger = logging.getLogger("app.api.v1.universe") @router.get( "/screen", response_model=UniverseScreenResponse, summary="Screen stocks at a historical date", description=( "Query monthly market_cap snapshots to find stocks matching criteria at a past date.\\n\\n" "**용도**: 백테스팅 전략 유니버스 구성 — 특정 시점 시총/섹터 기준 종목 필터링.\\n\\n" "**데이터 소스**: SEC EDGAR shares_outstanding × yfinance monthly close.\\n" "**제한**: 현재 상장 종목만 포함 (survivorship bias). 상폐 종목 미포함.\\n\\n" "**사전 조건**: `/universe/admin/discover` 후 `/universe/admin/build-snapshots` 실행 필요." ), ) @with_cache(namespace="universe:screen", ttl=3600, key_params=["date", "market_cap_min", "market_cap_max", "sector", "exchange", "page", "page_size", "sort_by", "sort_ascending"]) async def screen_historical( response: Response, date: str = Query(..., description="Historical date YYYY-MM-DD (rounded to month start)"), market_cap_min: Optional[float] = Query(None, description="Min market cap (USD), e.g. 2e9"), market_cap_max: Optional[float] = Query(None, description="Max market cap (USD), e.g. 20e9"), sector: Optional[str] = Query(None, description="Sector filter (e.g. Technology, Healthcare)"), exchange: Optional[str] = Query(None, description="Exchange filter (NYSE, NASDAQ, AMEX)"), page: int = Query(1, ge=1, description="Page number"), page_size: int = Query(100, ge=1, le=500, description="Results per page"), sort_by: str = Query("market_cap", description="Sort field: market_cap or ticker"), sort_ascending: bool = Query(False, description="Sort direction"), db: AsyncSession = Depends(get_db), ): svc = UniverseService() try: items, total, snapshot_date = await svc.screen_historical( db, date_str=date, market_cap_min=market_cap_min, market_cap_max=market_cap_max, sector=sector, exchange=exchange, page=page, page_size=page_size, sort_by=sort_by, sort_ascending=sort_ascending, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"Universe screen error: {e}") raise HTTPException(status_code=502, detail=f"Universe screen failed: {e}") total_pages = max(1, (total + page_size - 1) // page_size) filters: dict = {} if market_cap_min is not None: filters["market_cap_min"] = market_cap_min if market_cap_max is not None: filters["market_cap_max"] = market_cap_max if sector: filters["sector"] = sector if exchange: filters["exchange"] = exchange if total == 0: note = ( "No snapshots found for this date. " "Run POST /universe/admin/build-snapshots to populate data." ) else: note = "Survivorship bias: currently-listed stocks only. Delisted companies excluded." return UniverseScreenResponse( stocks=[UniverseSnapshotItem(**item) for item in items], total_count=total, page=page, page_size=page_size, total_pages=total_pages, snapshot_date=snapshot_date, filters_applied=filters, metadata={ "sort_by": sort_by, "sort_ascending": sort_ascending, "note": note, }, ) @router.get( "/registry", response_model=RegistryResponse, summary="Browse registered ticker universe", description="List tickers registered in the universe (populated via /admin/discover).", ) async def get_registry( sector: Optional[str] = Query(None, description="Filter by sector"), exchange: Optional[str] = Query(None, description="Filter by exchange"), is_active: Optional[bool] = Query(None, description="Filter by active status"), page: int = Query(1, ge=1), page_size: int = Query(100, ge=1, le=1000), db: AsyncSession = Depends(get_db), ): svc = UniverseService() try: rows, total = await svc.get_registry( db, sector=sector, exchange=exchange, is_active=is_active, page=page, page_size=page_size, ) except Exception as e: logger.error(f"Registry fetch error: {e}") raise HTTPException(status_code=502, detail=str(e)) total_pages = max(1, (total + page_size - 1) // page_size) return RegistryResponse( tickers=[TickerRegistryItem.model_validate(r) for r in rows], total_count=total, page=page, page_size=page_size, total_pages=total_pages, ) @router.post( "/admin/discover", summary="Discover and register US tickers", description=( "Scrapes US-listed stocks via yfinance screener and registers them in the universe.\\n\\n" "**소요 시간**: 약 1~5분 (시총 기준에 따라 다름).\\n" "**권장**: `market_cap_min=100000000` ($100M) → ~3000~5000 종목." ), ) async def discover_tickers( market_cap_min: float = Query( 1e8, description="Min market cap for inclusion (USD). Default $100M.", ), db: AsyncSession = Depends(get_db), ): svc = UniverseService() try: result = await svc.discover_tickers(db, market_cap_min=market_cap_min) return { "status": "completed", **result, "note": "Run POST /universe/admin/build-snapshots to compute historical market_cap snapshots.", } except Exception as e: logger.error(f"Universe discover error: {e}") raise HTTPException(status_code=502, detail=f"Discover failed: {e}") async def _run_build_snapshots( tickers, start_date: str, end_date: str, force_rebuild: bool ): """Background task wrapper for build_snapshots (needs its own DB session).""" from app.core.database import AsyncSessionLocal async with AsyncSessionLocal() as db: svc = UniverseService() try: result = await svc.build_snapshots( db, tickers=tickers, start_date=start_date, end_date=end_date, force_rebuild=force_rebuild, ) logger.info(f"Universe background build complete: {result}") except Exception as e: logger.error(f"Universe background build failed: {e}") @router.post( "/admin/build-snapshots", summary="Build monthly market_cap snapshots", description=( "Computes monthly market_cap snapshots for registered tickers and stores them " "in `universe_snapshot`.\\n\\n" "**데이터 소스**: SEC EDGAR companyfacts (shares_outstanding) + yfinance monthly close.\\n\\n" "**소요 시간**: 전체 유니버스(~4000 종목) × 10년 기준 30~60분. " "백그라운드에서 실행되므로 응답은 즉시 반환됩니다.\\n\\n" "**권장 시작점**: `tickers=[AAPL,MSFT,GOOGL]`로 소규모 테스트 후 전체 빌드." ), ) async def build_snapshots( body: SnapshotBuildRequest, background_tasks: BackgroundTasks, db: AsyncSession = Depends(get_db), ): tickers = [t.upper() for t in body.tickers] if body.tickers else None # Validate dates try: from datetime import date date.fromisoformat(body.start_date) date.fromisoformat(body.end_date) except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid date: {e}") # For small ticker lists (≤20), run synchronously for immediate feedback if tickers and len(tickers) <= 20: svc = UniverseService() try: result = await svc.build_snapshots( db, tickers=tickers, start_date=body.start_date, end_date=body.end_date, force_rebuild=body.force_rebuild, ) return {"status": "completed", **result} except Exception as e: logger.error(f"Snapshot build error: {e}") raise HTTPException(status_code=502, detail=str(e)) # Large jobs → background ticker_count = len(tickers) if tickers else "all registry" background_tasks.add_task( _run_build_snapshots, tickers, body.start_date, body.end_date, body.force_rebuild, ) return { "status": "started", "tickers_queued": ticker_count, "date_range": f"{body.start_date} → {body.end_date}", "note": ( "Building in background. " "Query GET /universe/screen after a few minutes to verify data." ), }