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.

340 lines
12 KiB
Python

"""
SEC Filings endpoints: search, document listing, and exhibit extraction.
"""
import asyncio
import logging
from datetime import datetime, timezone
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db, AsyncSessionLocal
from app.schemas.filing import (
BulkExhibitItem,
BulkExhibitRequest,
BulkExhibitResponse,
BulkFilingSearchItem,
BulkFilingSearchRequest,
BulkFilingSearchResponse,
ExhibitContentResponse,
FilingDocumentInfo,
FilingDocumentListResponse,
FilingSearchResponse,
FilingSummary,
)
from app.services.sec_filings_service import sec_filings_service
from app.utils.cache import with_cache
router = APIRouter()
logger = logging.getLogger("app.api.v1.filings")
@router.get("/search/{ticker}", response_model=FilingSearchResponse)
@with_cache(namespace="filings:search", ttl=3600, key_params=["ticker", "form_type", "start_date", "end_date", "limit", "offset"])
async def search_filings(
ticker: str,
response: Response,
form_type: Optional[str] = Query(
None,
description="Comma-separated form types (e.g. '8-K,6-K'). Default: all supported.",
),
start_date: Optional[str] = Query(None, description="Start date YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="End date YYYY-MM-DD"),
limit: int = Query(20, ge=1, le=100),
offset: int = Query(0, ge=0),
force_refresh: bool = Query(False, description="Force re-indexing from SEC"),
db: AsyncSession = Depends(get_db),
):
"""Search SEC filings for a ticker. Auto-indexes from SEC if not in DB."""
form_types_set = None
if form_type:
form_types_set = {ft.strip().upper() for ft in form_type.split(",") if ft.strip()}
unsupported = form_types_set - sec_filings_service.SUPPORTED_FORM_TYPES
if unsupported:
raise HTTPException(
status_code=400,
detail=f"Unsupported form types: {unsupported}. Supported: {sec_filings_service.SUPPORTED_FORM_TYPES}",
)
start_dt = None
end_dt = None
try:
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
if force_refresh:
try:
await sec_filings_service.index_filings(
db, ticker, form_types=form_types_set, force_refresh=True
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Force refresh indexing failed for {ticker}: {e}")
raise HTTPException(status_code=502, detail=f"SEC indexing failed: {e}")
try:
filings, total_count = await asyncio.wait_for(
sec_filings_service.search_filings(
db,
ticker,
form_types=form_types_set,
start_date=start_dt,
end_date=end_dt,
limit=limit,
offset=offset,
),
timeout=120,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Filing search timed out after 120s.")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Filing search failed for {ticker}: {e}")
raise HTTPException(status_code=502, detail=f"Filing search failed: {e}")
summaries = []
for f in filings:
docs_count = len(f.documents_json) if f.documents_json else None
summaries.append(
FilingSummary(
accession_number=f.accession_number,
form_type=f.form_type,
filing_date=f.filing_date.strftime("%Y-%m-%d"),
accepted_at=f.accepted_at.isoformat() if f.accepted_at else None,
primary_document=f.primary_document,
filing_description=f.filing_description,
documents_count=docs_count,
)
)
return FilingSearchResponse(
ticker=ticker.upper(),
filings=summaries,
total_count=total_count,
metadata={
"limit": limit,
"offset": offset,
"form_types": list(form_types_set) if form_types_set else None,
},
)
@router.get("/documents/{accession_number}", response_model=FilingDocumentListResponse)
@with_cache(namespace="filings:documents", ttl=86400, key_params=["accession_number"])
async def get_filing_documents(
accession_number: str,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""List all documents within a SEC filing."""
try:
documents = await asyncio.wait_for(
sec_filings_service.get_filing_documents(db, accession_number),
timeout=30,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Document listing timed out after 30s.")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Document listing failed for {accession_number}: {e}")
raise HTTPException(status_code=502, detail=f"Document listing failed: {e}")
doc_infos = [FilingDocumentInfo(**d) for d in documents]
return FilingDocumentListResponse(
accession_number=accession_number,
documents=doc_infos,
metadata={"total_documents": len(doc_infos)},
)
@router.get("/exhibit/{accession_number}", response_model=ExhibitContentResponse)
@with_cache(namespace="filings:exhibit", ttl=86400, key_params=["accession_number", "exhibit_type"])
async def get_exhibit_content(
accession_number: str,
response: Response,
exhibit_type: str = Query("EX-99.1", description="Exhibit type (e.g. EX-99.1)"),
db: AsyncSession = Depends(get_db),
):
"""Extract exhibit content (e.g., press release EX-99.1) from a filing."""
try:
result = await asyncio.wait_for(
sec_filings_service.get_exhibit_content(db, accession_number, exhibit_type),
timeout=30,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Exhibit extraction timed out after 30s.")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
logger.error(f"Exhibit extraction failed for {accession_number}/{exhibit_type}: {e}")
raise HTTPException(status_code=502, detail=f"Exhibit extraction failed: {e}")
return ExhibitContentResponse(
accession_number=accession_number,
exhibit_type=exhibit_type,
content=result["content"],
content_type=result.get("content_type"),
filename=result.get("filename"),
url=result.get("url"),
)
@router.post("/search/bulk", response_model=BulkFilingSearchResponse)
@with_cache(namespace="filings:search:bulk", ttl=3600, key_params=["request"])
async def search_filings_bulk(
request: BulkFilingSearchRequest,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""Bulk search SEC filings for multiple tickers. Auto-indexes from SEC if not in DB."""
form_types_set = None
if request.form_type:
form_types_set = {ft.strip().upper() for ft in request.form_type.split(",") if ft.strip()}
unsupported = form_types_set - sec_filings_service.SUPPORTED_FORM_TYPES
if unsupported:
raise HTTPException(
status_code=400,
detail=f"Unsupported form types: {unsupported}.",
)
start_dt = None
end_dt = None
try:
if request.start_date:
start_dt = datetime.strptime(request.start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
if request.end_date:
end_dt = datetime.strptime(request.end_date, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
try:
raw_results, elapsed = await asyncio.wait_for(
sec_filings_service.search_filings_bulk(
db,
request.tickers,
form_types=form_types_set,
start_date=start_dt,
end_date=end_dt,
limit_per_ticker=request.limit_per_ticker,
),
timeout=600,
)
except asyncio.TimeoutError:
raise HTTPException(status_code=504, detail="Bulk filing search timed out after 600s.")
except Exception as e:
logger.error(f"Bulk filing search failed: {e}")
raise HTTPException(status_code=502, detail=f"Bulk filing search failed: {e}")
items = []
successful = 0
failed = 0
for r in raw_results:
summaries = []
for f in r["filings"]:
docs_count = len(f.documents_json) if f.documents_json else None
summaries.append(
FilingSummary(
accession_number=f.accession_number,
form_type=f.form_type,
filing_date=f.filing_date.strftime("%Y-%m-%d"),
accepted_at=f.accepted_at.isoformat() if f.accepted_at else None,
primary_document=f.primary_document,
filing_description=f.filing_description,
documents_count=docs_count,
)
)
items.append(BulkFilingSearchItem(
ticker=r["ticker"],
success=r["success"],
filings=summaries,
total_count=r["total_count"],
error=r["error"],
))
if r["success"]:
successful += 1
else:
failed += 1
return BulkFilingSearchResponse(
results=items,
total_tickers=len(items),
successful_count=successful,
failed_count=failed,
query_time_seconds=round(elapsed, 3),
metadata={"form_types": list(form_types_set) if form_types_set else None},
)
@router.post("/exhibit/bulk", response_model=BulkExhibitResponse)
async def get_exhibit_bulk(
request: BulkExhibitRequest,
db: AsyncSession = Depends(get_db),
):
"""Bulk fetch exhibit content for multiple accession numbers."""
import time as _time
t0 = _time.monotonic()
async def _fetch_one(item: dict) -> BulkExhibitItem:
accession_number = item.get("accession_number", "")
exhibit_type = item.get("exhibit_type", "EX-99.1")
# Each concurrent call gets its own DB session to avoid session contention
async with AsyncSessionLocal() as session:
try:
result = await asyncio.wait_for(
sec_filings_service.get_exhibit_content(session, accession_number, exhibit_type),
timeout=30,
)
return BulkExhibitItem(
accession_number=accession_number,
exhibit_type=exhibit_type,
success=True,
content=result["content"],
content_type=result.get("content_type"),
filename=result.get("filename"),
url=result.get("url"),
)
except Exception as e:
return BulkExhibitItem(
accession_number=accession_number,
exhibit_type=exhibit_type,
success=False,
error=str(e),
)
raw = await asyncio.gather(*[_fetch_one(item) for item in request.items], return_exceptions=True)
results: List[BulkExhibitItem] = []
for r in raw:
if isinstance(r, Exception):
results.append(BulkExhibitItem(
accession_number="unknown",
exhibit_type="unknown",
success=False,
error=str(r),
))
else:
results.append(r)
successful = sum(1 for r in results if r.success)
elapsed = _time.monotonic() - t0
return BulkExhibitResponse(
results=results,
total_items=len(results),
successful_count=successful,
failed_count=len(results) - successful,
query_time_seconds=round(elapsed, 3),
)