You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

95 lines
3.4 KiB
Python

"""
ETF endpoints (clean and correctly indented)
"""
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, HTTPException, Query, Depends
import asyncio
import logging
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
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)
async def get_etf_holdings(
ticker: str,
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"),
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.