""" 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, build_cache_key, get_negative_cached, set_negative_cached router = APIRouter() logger = logging.getLogger("app.api.v1.filings") @router.get( "/search/{ticker}", response_model=FilingSearchResponse, summary="Search SEC filings for a ticker", description=( "Search SEC filings for the given ticker. Supported form types: **8-K, 6-K, 20-F, 40-F**.\n\n" "Auto-indexes filings from EDGAR on first request (or when `force_refresh=true`). " "Results are cached for 1 hour.\n\n" "**Example**: `GET /filings/search/AAPL?form_type=8-K&limit=10`" ), ) @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, summary="List documents in a SEC filing", description=( "List all documents attached to a SEC filing by accession number.\n\n" "Returns filename, document type, size, and SEC URL for each document. " "Cached for 24 hours.\n\n" "**Example**: `GET /filings/documents/0001193125-24-123456`" ), ) @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, summary="Extract exhibit content from a filing", description=( "Extract the text content of a specific exhibit (e.g., press release **EX-99.1**) " "from a SEC filing.\n\n" "Returns the full text content along with content type, filename, and SEC URL. " "404 responses are negative-cached for 1 hour. Cached for 24 hours.\n\n" "**Example**: `GET /filings/exhibit/0001193125-24-123456?exhibit_type=EX-99.1`" ), ) @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.""" # Fast path: check negative cache (exhibit known not to exist) neg_key = build_cache_key("filings:exhibit:404", accession_number, exhibit_type) if await get_negative_cached(neg_key): raise HTTPException( status_code=404, detail=f"Exhibit {exhibit_type} not found in filing {accession_number}", ) try: result = await asyncio.wait_for( sec_filings_service.get_exhibit_content(db, accession_number, exhibit_type), timeout=25, ) except asyncio.TimeoutError: raise HTTPException(status_code=504, detail="Exhibit extraction timed out after 25s.") except ValueError as e: # Cache this "not found" result so subsequent requests skip SEC entirely await set_negative_cached(neg_key, ttl=3600) 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, summary="Bulk search SEC filings for multiple tickers", description=( "Search SEC filings for up to many tickers in a single request. " "Auto-indexes from EDGAR for any ticker not yet in the database.\n\n" "**Timeout**: 600 seconds. Each ticker is processed concurrently.\n\n" "**Example body**:\n" "```json\n" '{"tickers": ["AAPL", "MSFT", "NVDA"], "form_type": "8-K", ' '"start_date": "2024-01-01", "limit_per_ticker": 5}\n' "```" ), ) @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, summary="Bulk fetch exhibit content", description=( "Fetch exhibit content for multiple accession numbers in one request. " "Up to 4 concurrent fetches; max 300 second timeout.\n\n" "**Example body**:\n" "```json\n" '{"items": [{"accession_number": "0001193125-24-123456", "exhibit_type": "EX-99.1"}]}\n' "```" ), ) 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") neg_key = build_cache_key("filings:exhibit:404", accession_number, exhibit_type) if await get_negative_cached(neg_key): return BulkExhibitItem( accession_number=accession_number, exhibit_type=exhibit_type, success=False, error=f"Exhibit {exhibit_type} not found in filing {accession_number}", ) # 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=25, ) 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 ValueError as e: await set_negative_cached(neg_key, ttl=3600) return BulkExhibitItem( accession_number=accession_number, exhibit_type=exhibit_type, success=False, error=str(e), ) except Exception as e: return BulkExhibitItem( accession_number=accession_number, exhibit_type=exhibit_type, success=False, error=str(e), ) sem = asyncio.Semaphore(4) async def _fetch_one_limited(item): async with sem: return await _fetch_one(item) raw = await asyncio.wait_for( asyncio.gather(*[_fetch_one_limited(item) for item in request.items], return_exceptions=True), timeout=300, ) 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), )