|
|
|
|
@ -2,15 +2,22 @@
|
|
|
|
|
SEC Filings endpoints: search, document listing, and exhibit extraction.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import logging
|
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
|
from typing import Optional
|
|
|
|
|
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
|
|
|
|
|
from app.schemas.filing import (
|
|
|
|
|
BulkExhibitItem,
|
|
|
|
|
BulkExhibitRequest,
|
|
|
|
|
BulkExhibitResponse,
|
|
|
|
|
BulkFilingSearchItem,
|
|
|
|
|
BulkFilingSearchRequest,
|
|
|
|
|
BulkFilingSearchResponse,
|
|
|
|
|
ExhibitContentResponse,
|
|
|
|
|
FilingDocumentInfo,
|
|
|
|
|
FilingDocumentListResponse,
|
|
|
|
|
@ -75,7 +82,8 @@ async def search_filings(
|
|
|
|
|
raise HTTPException(status_code=502, detail=f"SEC indexing failed: {e}")
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
filings, total_count = await sec_filings_service.search_filings(
|
|
|
|
|
filings, total_count = await asyncio.wait_for(
|
|
|
|
|
sec_filings_service.search_filings(
|
|
|
|
|
db,
|
|
|
|
|
ticker,
|
|
|
|
|
form_types=form_types_set,
|
|
|
|
|
@ -83,7 +91,11 @@ async def search_filings(
|
|
|
|
|
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:
|
|
|
|
|
@ -118,13 +130,20 @@ async def search_filings(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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 sec_filings_service.get_filing_documents(db, accession_number)
|
|
|
|
|
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:
|
|
|
|
|
@ -140,16 +159,21 @@ async def get_filing_documents(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@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 sec_filings_service.get_exhibit_content(
|
|
|
|
|
db, accession_number, exhibit_type
|
|
|
|
|
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:
|
|
|
|
|
@ -164,3 +188,150 @@ async def get_exhibit_content(
|
|
|
|
|
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")
|
|
|
|
|
try:
|
|
|
|
|
result = await asyncio.wait_for(
|
|
|
|
|
sec_filings_service.get_exhibit_content(db, 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),
|
|
|
|
|
)
|
|
|
|
|
|