""" ETF endpoints (clean and correctly indented) """ from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, HTTPException, Query, Depends, Response import asyncio import logging from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import get_db from app.utils.cache import with_cache from app.services.etf_loader_service import etf_loader_service from app.services.etf_holdings_fetcher import etf_holdings_fetcher from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap router = APIRouter() logger = logging.getLogger("app.api.v1.etf") class ETFHoldingsOut(BaseModel): success: bool ticker: Optional[str] = None as_of_date: Optional[str] = None cik: Optional[str] = None holdings_count: Optional[int] = None holdings: Optional[list] = None availability: Optional[dict] = None error: Optional[str] = None @router.get("/holdings/{ticker}", response_model=ETFHoldingsOut) @with_cache(namespace="etf:holdings", ttl=3600, key_params=["ticker", "as_of_date", "top_n", "top_percentage"]) async def get_etf_holdings( ticker: str, response: Response, as_of_date: Optional[str] = Query(None, description="YYYY-MM-DD"), top_n: Optional[int] = Query(None, description="Return top N holdings by weight/value (mutually exclusive with top_percentage)"), top_percentage: Optional[float] = Query(None, description="Return minimal set covering X percent (e.g., 0.5 or 50 for 50%). Mutually exclusive with top_n"), force_refresh: bool = Query(False, description="Bypass cache"), db: AsyncSession = Depends(get_db), ): target_dt: Optional[datetime] = None if as_of_date: try: target_dt = datetime.strptime(as_of_date, "%Y-%m-%d").replace(tzinfo=timezone.utc) except ValueError: raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD") logger.info( f"get_etf_holdings start ticker={ticker} as_of_date={as_of_date} top_n={top_n} top_percentage={top_percentage}" ) try: if top_n is not None and (top_n <= 0): raise HTTPException(status_code=400, detail="top_n must be > 0") if top_percentage is not None and (top_percentage <= 0): raise HTTPException(status_code=400, detail="top_percentage must be > 0") result = await asyncio.wait_for( etf_holdings_fetcher.get_holdings( db, ticker, target_dt, top_n=top_n, top_percentage=top_percentage, ), timeout=55.0, ) logger.info( f"get_etf_holdings done ticker={ticker} count={result.get('holdings_count')} " f"success={result.get('success')}" ) except asyncio.TimeoutError: logger.warning(f"get_etf_holdings timeout ticker={ticker}") raise HTTPException(status_code=504, detail="ETF holdings request timed out. Please retry.") if not result.get("success"): availability = result.get("availability") if availability is not None: return ETFHoldingsOut(**result) raise HTTPException(status_code=404, detail=result.get("error", "ETF holdings not found")) return ETFHoldingsOut(**result) class RefreshMapsOut(BaseModel): cusip_rows: int = Field(...) etf_rows: int = Field(...) @router.post("/admin/refresh-maps", response_model=RefreshMapsOut) async def refresh_etf_maps(db: AsyncSession = Depends(get_db)): refreshed = await etf_loader_service.refresh_all(db) return RefreshMapsOut(**refreshed) # All other admin endpoints (manual upserts/deletes) have been removed per request.