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
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.
|
|
|