Add SEC filings indexing, search, and exhibit extraction (8-K, 6-K, 20-F, 40-F)

Extract shared SECHttpClient from ETF fetcher (retry, backoff, cache, throttle)
and apply it to both ETF and core SEC services, fixing missing rate limiting.
Add SECFiling DB model, Pydantic schemas, SECFilingsService with auto-indexing,
and REST endpoints at /filings/search, /filings/documents, /filings/exhibit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 44f8979805
commit d0f1a9a7d0

@ -3,7 +3,7 @@ API v1 router
""" """
from fastapi import APIRouter from fastapi import APIRouter
from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred, filings
api_router = APIRouter() api_router = APIRouter()
@ -15,6 +15,7 @@ api_router.include_router(stocks.router, prefix="/stocks", tags=["stocks"])
api_router.include_router(fred.router, prefix="/fred", tags=["fred"]) api_router.include_router(fred.router, prefix="/fred", tags=["fred"])
api_router.include_router(news.router, prefix="/news", tags=["news"]) api_router.include_router(news.router, prefix="/news", tags=["news"])
api_router.include_router(etf.router, prefix="/etf", tags=["etf"]) api_router.include_router(etf.router, prefix="/etf", tags=["etf"])
api_router.include_router(filings.router, prefix="/filings", tags=["filings"])
api_router.include_router(catalog.router, prefix="/metadata", tags=["metadata"]) api_router.include_router(catalog.router, prefix="/metadata", tags=["metadata"])
api_router.include_router(migration.router, prefix="/admin", tags=["admin"]) api_router.include_router(migration.router, prefix="/admin", tags=["admin"])
# Removed docs.router - documentation now served at root path # Removed docs.router - documentation now served at root path

@ -0,0 +1,162 @@
"""
SEC Filings endpoints: search, document listing, and exhibit extraction.
"""
import logging
from datetime import datetime, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.schemas.filing import (
ExhibitContentResponse,
FilingDocumentInfo,
FilingDocumentListResponse,
FilingSearchResponse,
FilingSummary,
)
from app.services.sec_filings_service import sec_filings_service
router = APIRouter()
logger = logging.getLogger("app.api.v1.filings")
@router.get("/search/{ticker}", response_model=FilingSearchResponse)
async def search_filings(
ticker: str,
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 sec_filings_service.search_filings(
db,
ticker,
form_types=form_types_set,
start_date=start_dt,
end_date=end_dt,
limit=limit,
offset=offset,
)
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"),
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)
async def get_filing_documents(
accession_number: str,
db: AsyncSession = Depends(get_db),
):
"""List all documents within a SEC filing."""
try:
documents = await sec_filings_service.get_filing_documents(db, accession_number)
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)
async def get_exhibit_content(
accession_number: str,
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
)
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"),
)

@ -12,7 +12,7 @@ from app.core.config import settings
from app.api.v1.api import api_router from app.api.v1.api import api_router
from app.core.database import engine, Base from app.core.database import engine, Base
from app.middleware.error_logger import ErrorLoggingMiddleware from app.middleware.error_logger import ErrorLoggingMiddleware
from app.models import error_log, request_log, fred_data # Import to register models from app.models import error_log, request_log, fred_data, filing # Import to register models
# Create database tables # Create database tables
@asynccontextmanager @asynccontextmanager

@ -1,5 +1,6 @@
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData, DataUpdateLog from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData, DataUpdateLog
from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding from app.models.etf import CusipMap, ETFCIKMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding
from app.models.filing import SECFiling
__all__ = [ __all__ = [
"Company", "Company",
@ -12,4 +13,5 @@ __all__ = [
"ETFSeriesMap", "ETFSeriesMap",
"ETFHoldingsSnapshot", "ETFHoldingsSnapshot",
"ETFHolding", "ETFHolding",
"SECFiling",
] ]

@ -0,0 +1,33 @@
"""
Database model for SEC filings (8-K, 6-K, 20-F, 40-F, 10-K, 10-Q, etc.)
"""
from sqlalchemy import Column, String, JSON, Index
from sqlalchemy.dialects.postgresql import UUID, TIMESTAMP
from datetime import datetime, timezone
import uuid
from app.core.database import Base
class SECFiling(Base):
__tablename__ = "sec_filings"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
ticker = Column(String(10), nullable=False, index=True)
cik = Column(String(20), nullable=False, index=True)
accession_number = Column(String(30), nullable=False, unique=True, index=True)
form_type = Column(String(20), nullable=False, index=True)
filing_date = Column(TIMESTAMP(timezone=True), nullable=False)
primary_document = Column(String(256))
primary_document_url = Column(String(512))
filing_description = Column(String(512))
documents_json = Column(JSON) # Cached document list
indexed_at = Column(TIMESTAMP(timezone=True))
created_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc))
updated_at = Column(TIMESTAMP(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
__table_args__ = (
Index("idx_sec_filings_ticker_form", "ticker", "form_type"),
Index("idx_sec_filings_ticker_date", "ticker", "filing_date"),
Index("idx_sec_filings_cik_form", "cik", "form_type"),
)

@ -0,0 +1,46 @@
"""
Pydantic schemas for SEC filing endpoints.
"""
from datetime import datetime
from typing import List, Optional
from pydantic import BaseModel, Field
class FilingDocumentInfo(BaseModel):
type: Optional[str] = None
description: Optional[str] = None
filename: Optional[str] = None
url: Optional[str] = None
size: Optional[str] = None
class FilingSummary(BaseModel):
accession_number: str
form_type: str
filing_date: str
primary_document: Optional[str] = None
filing_description: Optional[str] = None
documents_count: Optional[int] = None
class FilingSearchResponse(BaseModel):
ticker: str
filings: List[FilingSummary]
total_count: int
metadata: dict = Field(default_factory=dict)
class FilingDocumentListResponse(BaseModel):
accession_number: str
documents: List[FilingDocumentInfo]
metadata: dict = Field(default_factory=dict)
class ExhibitContentResponse(BaseModel):
accession_number: str
exhibit_type: str
content: str
content_type: Optional[str] = None
filename: Optional[str] = None
url: Optional[str] = None

@ -23,6 +23,7 @@ from sqlalchemy import select, and_, delete
from app.models.etf import ETFCIKMap, CusipMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding from app.models.etf import ETFCIKMap, CusipMap, ETFSeriesMap, ETFHoldingsSnapshot, ETFHolding
from app.core.config import settings from app.core.config import settings
from app.services.sec_http_client import SECHttpClient
# Optional external ETF scraper integration (provider websites) # Optional external ETF scraper integration (provider websites)
try: try:
@ -34,23 +35,24 @@ except Exception:
class ETFHoldingsFetcher: class ETFHoldingsFetcher:
def __init__(self): def __init__(self):
self.sec_base_data = "https://data.sec.gov" self._http = SECHttpClient("Stock Oracle ETF Fetcher")
self.sec_base_data = self._http.sec_base_data
self.sec_base_archives = "https://www.sec.gov/Archives/edgar/data" self.sec_base_archives = "https://www.sec.gov/Archives/edgar/data"
# Keep HTTP timeouts conservative to avoid long waits in UI # Keep legacy attributes that point to shared client internals
self.http_timeout = aiohttp.ClientTimeout(total=12) self.http_timeout = self._http.http_timeout
# Throttle and simple in-memory/disk cache to reduce 429s self._req_sem = self._http._req_sem
# Allow small concurrency to speed up candidate evaluation while avoiding 429 self._text_cache = self._http._text_cache
self._req_sem = asyncio.Semaphore(2) self._json_cache = self._http._json_cache
self._text_cache: Dict[str, str] = {} self._cache_dir = self._http._cache_dir
self._json_cache: Dict[str, dict] = {} self._user_agent = self._http._user_agent
self._cache_dir = "/tmp/stock_oracle_sec_cache"
try: @property
os.makedirs(self._cache_dir, exist_ok=True) def _deadline(self) -> Optional[float]:
except Exception: return self._http._deadline
pass
self._user_agent = f"Stock Oracle ETF Fetcher ({settings.SEC_EMAIL})" @_deadline.setter
# Per-request deadline (monotonic seconds). Set in get_holdings and honored by fetchers def _deadline(self, value: Optional[float]) -> None:
self._deadline: Optional[float] = None self._http._deadline = value
def _normalize_snapshot_datetime(self, dt: datetime) -> datetime: def _normalize_snapshot_datetime(self, dt: datetime) -> datetime:
if dt.tzinfo is None: if dt.tzinfo is None:
@ -180,8 +182,7 @@ class ETFHoldingsFetcher:
return 0 return 0
def _cache_path(self, url: str) -> str: def _cache_path(self, url: str) -> str:
h = hashlib.sha256(url.encode("utf-8")).hexdigest() return self._http._cache_path(url)
return os.path.join(self._cache_dir, h)
async def _get_cik_record(self, db: AsyncSession, ticker: str) -> Optional[Dict[str, Optional[str]]]: async def _get_cik_record(self, db: AsyncSession, ticker: str) -> Optional[Dict[str, Optional[str]]]:
result = await db.execute(select(ETFCIKMap.cik, ETFCIKMap.name).where(ETFCIKMap.ticker == ticker.upper())) result = await db.execute(select(ETFCIKMap.cik, ETFCIKMap.name).where(ETFCIKMap.ticker == ticker.upper()))
@ -194,183 +195,12 @@ class ETFHoldingsFetcher:
return {"cik": row[0], "name": row[1], "series_id": (s[0] if s else None), "class_id": (s[1] if s else None)} return {"cik": row[0], "name": row[1], "series_id": (s[0] if s else None), "class_id": (s[1] if s else None)}
async def _fetch_json(self, url: str) -> dict: async def _fetch_json(self, url: str) -> dict:
# Simple retry with backoff on 429/5xx, with disk cache return await self._http.fetch_json(url)
attempts = 6
backoff = 1.0
last_exc = None
# Disk cache check
if url in self._json_cache:
return self._json_cache[url]
try:
cp = self._cache_path(url) + ".json"
if os.path.exists(cp):
# TTL based on SEC_DATA_REFRESH_HOURS
ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600)
if _time.time() - os.path.getmtime(cp) <= ttl_sec:
with open(cp, "r", encoding="utf-8") as f:
data = json.load(f)
self._json_cache[url] = data
return data
except Exception:
pass
for i in range(attempts):
now = _time.monotonic()
if self._deadline is not None and now >= self._deadline:
break
# Compute per-request remaining time and cap request timeout
req_timeout = self.http_timeout
if self._deadline is not None:
remaining = max(0.0, self._deadline - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(total=min(remaining, getattr(self.http_timeout, "total", 12)))
async with self._req_sem:
try:
async with aiohttp.ClientSession(timeout=req_timeout, headers={
"User-Agent": self._user_agent,
"Accept": "application/json",
}) as session:
async with session.get(url, timeout=req_timeout) as resp:
if resp.status == 429:
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after and retry_after.isdigit() else backoff
# Respect deadline
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
if 500 <= resp.status < 600:
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
resp.raise_for_status()
data = await resp.json()
# cache successful
try:
with open(self._cache_path(url) + ".json", "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
self._json_cache[url] = data
return data
except Exception as e:
last_exc = e
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
raise last_exc if last_exc else RuntimeError("Failed to fetch JSON")
async def _fetch_text(self, url: str) -> str: async def _fetch_text(self, url: str) -> str:
def _is_sec_block_page(text: str) -> bool: return await self._http.fetch_text(
if not text: url, accept="application/xml, text/xml;q=0.9, text/html;q=0.8"
return False )
tl = text.lower()
if "your request originates from an undeclared automated tool" in tl:
return True
if "<title>sec.gov | your request originates" in tl:
return True
if "reference id:" in tl and "sec.gov" in tl:
return True
return False
if url in self._text_cache:
return self._text_cache[url]
attempts = 6
backoff = 1.0
last_exc = None
# Disk cache check
try:
cp = self._cache_path(url) + ".txt"
if os.path.exists(cp):
ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600)
if _time.time() - os.path.getmtime(cp) <= ttl_sec:
with open(cp, "r", encoding="utf-8") as f:
text = f.read()
if _is_sec_block_page(text):
try:
os.remove(cp)
except Exception:
pass
else:
self._text_cache[url] = text
return text
except Exception:
pass
for i in range(attempts):
now = _time.monotonic()
if self._deadline is not None and now >= self._deadline:
break
# Compute per-request remaining time and cap request timeout
req_timeout = self.http_timeout
if self._deadline is not None:
remaining = max(0.0, self._deadline - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(total=min(remaining, getattr(self.http_timeout, "total", 12)))
async with self._req_sem:
try:
async with aiohttp.ClientSession(timeout=req_timeout, headers={
"User-Agent": self._user_agent,
"Accept": "application/xml, text/xml;q=0.9, text/html;q=0.8",
}) as session:
async with session.get(url, timeout=req_timeout) as resp:
if resp.status == 429:
retry_after = resp.headers.get("Retry-After")
delay = float(retry_after) if retry_after and retry_after.isdigit() else backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
if 500 <= resp.status < 600:
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
resp.raise_for_status()
text = await resp.text()
# Detect SEC block page and treat as transient error
if _is_sec_block_page(text):
last_exc = RuntimeError("SEC_BLOCKED")
delay = backoff * 2.0
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.5 if delay > 0 else 0.0))
backoff *= 2.0
continue
# cache only successful responses
self._text_cache[url] = text
try:
with open(self._cache_path(url) + ".txt", "w", encoding="utf-8") as f:
f.write(text)
except Exception:
pass
return text
except Exception as e:
last_exc = e
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(max(0.0, delay) + random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0))
backoff *= 1.8
continue
raise last_exc if last_exc else RuntimeError("Failed to fetch text")
async def _find_best_filing_and_xml( async def _find_best_filing_and_xml(
self, self,

@ -3,8 +3,6 @@ SEC EDGAR Direct API Service
Fetches real financial data directly from SEC EDGAR without dependencies Fetches real financial data directly from SEC EDGAR without dependencies
""" """
import aiohttp
import asyncio
import json import json
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
from typing import Dict, List, Optional, Tuple, Any from typing import Dict, List, Optional, Tuple, Any
@ -14,67 +12,31 @@ from sqlalchemy import select, and_
from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData from app.models.financial import Company, FinancialData, CalculatedMetrics, PriceData
from app.schemas.financial import DataSource from app.schemas.financial import DataSource
from app.services.sec_http_client import SECHttpClient
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class SECEdgarService: class SECEdgarService:
"""Direct SEC EDGAR API service for financial data""" """Direct SEC EDGAR API service for financial data"""
def __init__(self): def __init__(self):
self.sec_data_url = "https://data.sec.gov" self._http = SECHttpClient("Stock Oracle SEC Service")
self.sec_www_url = "https://www.sec.gov"
self.headers = {
"User-Agent": "Stock Oracle API stockoracle@example.com",
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate"
}
async def get_company_cik(self, ticker: str) -> Optional[str]: async def get_company_cik(self, ticker: str) -> Optional[str]:
"""Get company CIK from SEC ticker mapping""" """Get company CIK from SEC ticker mapping"""
try: return await self._http.get_company_cik(ticker)
url = f"{self.sec_www_url}/files/company_tickers.json"
async with aiohttp.ClientSession() as session:
async with session.get(url, headers=self.headers) as response:
if response.status == 200:
data = await response.json()
# Search for ticker in the mapping
for key, company_info in data.items():
if company_info.get('ticker', '').upper() == ticker.upper():
cik_str = str(company_info.get('cik_str', '')).zfill(10)
logger.info(f"Found CIK {cik_str} for ticker {ticker}")
return cik_str
logger.warning(f"Ticker {ticker} not found in SEC mapping")
return None
else:
logger.error(f"Failed to fetch company tickers: {response.status}")
return None
except Exception as e:
logger.error(f"Error fetching CIK for {ticker}: {e}")
return None
async def get_company_facts(self, cik: str) -> Optional[Dict]: async def get_company_facts(self, cik: str) -> Optional[Dict]:
"""Get company facts from SEC EDGAR API""" """Get company facts from SEC EDGAR API"""
try: try:
url = f"{self.sec_data_url}/api/xbrl/companyfacts/CIK{cik.zfill(10)}.json" url = f"{self._http.sec_base_data}/api/xbrl/companyfacts/CIK{cik.zfill(10)}.json"
data = await self._http.fetch_json(url)
async with aiohttp.ClientSession() as session: logger.info(f"Successfully fetched SEC facts for CIK {cik}")
async with session.get(url, headers=self.headers) as response: return data
if response.status == 200:
data = await response.json()
logger.info(f"Successfully fetched SEC facts for CIK {cik}")
return data
elif response.status == 404:
logger.warning(f"No SEC data found for CIK {cik}")
return None
else:
logger.error(f"SEC API error for CIK {cik}: {response.status}")
return None
except Exception as e: except Exception as e:
if "404" in str(e):
logger.warning(f"No SEC data found for CIK {cik}")
return None
logger.error(f"Error fetching company facts for CIK {cik}: {e}") logger.error(f"Error fetching company facts for CIK {cik}: {e}")
return None return None

@ -0,0 +1,354 @@
"""
SEC Filings Service: index, search, and retrieve SEC filings (8-K, 6-K, 20-F, 40-F, etc.)
and extract exhibit content (e.g., EX-99.1 press releases).
"""
import logging
from datetime import datetime, timezone
from typing import Dict, List, Optional, Set, Tuple
from bs4 import BeautifulSoup
from sqlalchemy import select, and_, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.filing import SECFiling
from app.services.sec_http_client import SECHttpClient
logger = logging.getLogger(__name__)
# Maximum exhibit content size (1 MB)
MAX_EXHIBIT_SIZE = 1 * 1024 * 1024
class SECFilingsService:
SUPPORTED_FORM_TYPES: Set[str] = {
"8-K", "6-K", "20-F", "40-F", "10-K", "10-Q",
"8-K/A", "6-K/A", "20-F/A", "40-F/A",
}
def __init__(self):
self._http = SECHttpClient("Stock Oracle Filings Service")
# ------------------------------------------------------------------
# index_filings: fetch from SEC submissions and upsert to DB
# ------------------------------------------------------------------
async def index_filings(
self,
db: AsyncSession,
ticker: str,
form_types: Optional[Set[str]] = None,
force_refresh: bool = False,
) -> int:
"""Index filings from SEC submissions endpoint into sec_filings table.
Returns the number of filings indexed (inserted or updated).
"""
ticker = ticker.upper()
cik = await self._http.get_company_cik(ticker)
if not cik:
raise ValueError(f"Could not find CIK for ticker {ticker}")
cik_int = int(cik)
url = f"{self._http.sec_base_data}/submissions/CIK{cik_int:010d}.json"
data = await self._http.fetch_json(url)
# Determine which form types to index
target_forms = form_types or self.SUPPORTED_FORM_TYPES
# Collect filings from recent block
raw_filings: List[Dict] = []
def add_from_block(block: dict) -> None:
forms = block.get("form", [])
dates = block.get("filingDate", [])
accessions = block.get("accessionNumber", [])
primary_docs = block.get("primaryDocument", [])
descriptions = block.get("primaryDocDescription", [])
for i, (form, dt_str, acc) in enumerate(zip(forms, dates, accessions)):
if form not in target_forms:
continue
pri_doc = primary_docs[i] if i < len(primary_docs) else None
desc = descriptions[i] if i < len(descriptions) else None
try:
filing_date = datetime.strptime(dt_str, "%Y-%m-%d").replace(
tzinfo=timezone.utc
)
except Exception:
continue
acc_clean = acc.replace("-", "")
pri_doc_url = None
if pri_doc:
pri_doc_url = (
f"https://www.sec.gov/Archives/edgar/data/{cik_int}/{acc_clean}/{pri_doc}"
)
raw_filings.append({
"ticker": ticker,
"cik": cik,
"accession_number": acc,
"form_type": form,
"filing_date": filing_date,
"primary_document": pri_doc,
"primary_document_url": pri_doc_url,
"filing_description": desc,
})
recent = data.get("filings", {}).get("recent", {})
add_from_block(recent)
# Fetch older yearly submission files
files_meta = data.get("filings", {}).get("files", []) or []
for meta in files_meta[:6]:
name = meta.get("name")
if not name:
continue
older_url = f"{self._http.sec_base_data}/submissions/{name}"
try:
older = await self._http.fetch_json(older_url)
add_from_block(older)
except Exception:
continue
if not raw_filings:
return 0
# Upsert into DB
indexed_count = 0
now = datetime.now(timezone.utc)
for rf in raw_filings:
acc = rf["accession_number"]
result = await db.execute(
select(SECFiling).where(SECFiling.accession_number == acc)
)
existing = result.scalar_one_or_none()
if existing:
if force_refresh:
existing.ticker = rf["ticker"]
existing.cik = rf["cik"]
existing.form_type = rf["form_type"]
existing.filing_date = rf["filing_date"]
existing.primary_document = rf["primary_document"]
existing.primary_document_url = rf["primary_document_url"]
existing.filing_description = rf["filing_description"]
existing.indexed_at = now
existing.updated_at = now
indexed_count += 1
else:
filing = SECFiling(
ticker=rf["ticker"],
cik=rf["cik"],
accession_number=rf["accession_number"],
form_type=rf["form_type"],
filing_date=rf["filing_date"],
primary_document=rf["primary_document"],
primary_document_url=rf["primary_document_url"],
filing_description=rf["filing_description"],
indexed_at=now,
created_at=now,
updated_at=now,
)
db.add(filing)
indexed_count += 1
if indexed_count:
await db.commit()
logger.info(f"Indexed {indexed_count} filings for {ticker}")
return indexed_count
# ------------------------------------------------------------------
# search_filings: query DB with filters
# ------------------------------------------------------------------
async def search_filings(
self,
db: AsyncSession,
ticker: str,
form_types: Optional[Set[str]] = None,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
limit: int = 20,
offset: int = 0,
) -> Tuple[List[SECFiling], int]:
"""Search indexed filings. Auto-indexes if none found."""
ticker = ticker.upper()
conditions = [SECFiling.ticker == ticker]
if form_types:
conditions.append(SECFiling.form_type.in_(form_types))
if start_date:
conditions.append(SECFiling.filing_date >= start_date)
if end_date:
conditions.append(SECFiling.filing_date <= end_date)
where_clause = and_(*conditions)
# Count
count_result = await db.execute(
select(func.count()).select_from(SECFiling).where(where_clause)
)
total_count = count_result.scalar() or 0
# If no results, try indexing first
if total_count == 0:
try:
await self.index_filings(db, ticker, form_types=form_types)
except Exception as e:
logger.warning(f"Auto-indexing failed for {ticker}: {e}")
return [], 0
# Re-count after indexing
count_result = await db.execute(
select(func.count()).select_from(SECFiling).where(where_clause)
)
total_count = count_result.scalar() or 0
if total_count == 0:
return [], 0
# Query with pagination
result = await db.execute(
select(SECFiling)
.where(where_clause)
.order_by(SECFiling.filing_date.desc())
.limit(limit)
.offset(offset)
)
filings = result.scalars().all()
return list(filings), total_count
# ------------------------------------------------------------------
# get_filing_documents: list all documents in a filing
# ------------------------------------------------------------------
async def get_filing_documents(
self, db: AsyncSession, accession_number: str
) -> List[Dict]:
"""Get list of documents for a filing. Caches in documents_json column."""
result = await db.execute(
select(SECFiling).where(SECFiling.accession_number == accession_number)
)
filing = result.scalar_one_or_none()
if not filing:
raise ValueError(f"Filing {accession_number} not found in database")
# Return cached documents if available
if filing.documents_json:
return filing.documents_json
# Fetch and parse index page
cik_int = int("".join(ch for ch in filing.cik if ch.isdigit()))
acc_clean = accession_number.replace("-", "")
index_url = (
f"https://www.sec.gov/Archives/edgar/data/{cik_int}"
f"/{acc_clean}/{accession_number}-index.htm"
)
html = await self._http.fetch_text(index_url)
soup = BeautifulSoup(html, "html.parser")
base_url = index_url.rsplit("/", 1)[0]
def mk_abs(href: str) -> str:
if href.startswith("http"):
return href
if href.startswith("/"):
return f"https://www.sec.gov{href}"
return f"{base_url}/{href}"
documents: List[Dict] = []
for row in soup.find_all("tr"):
cells = row.find_all(["td", "th"])
if len(cells) < 4:
continue
# Typical columns: Seq, Description, Document, Type, Size
desc = cells[1].get_text(strip=True) if len(cells) > 1 else ""
doc_cell = cells[2]
doc_type = cells[3].get_text(strip=True) if len(cells) > 3 else ""
size_text = cells[4].get_text(strip=True) if len(cells) > 4 else ""
a_tag = doc_cell.find("a")
if not a_tag or not a_tag.get("href"):
continue
href = a_tag["href"]
filename = a_tag.get_text(strip=True) or doc_cell.get_text(strip=True)
documents.append({
"type": doc_type,
"description": desc,
"filename": filename,
"url": mk_abs(href),
"size": size_text,
})
# Cache to DB
if documents:
filing.documents_json = documents
filing.updated_at = datetime.now(timezone.utc)
await db.commit()
return documents
# ------------------------------------------------------------------
# get_exhibit_content: extract exhibit text
# ------------------------------------------------------------------
async def get_exhibit_content(
self,
db: AsyncSession,
accession_number: str,
exhibit_type: str = "EX-99.1",
) -> Dict:
"""Download and return the content of a specific exhibit."""
documents = await self.get_filing_documents(db, accession_number)
if not documents:
raise ValueError(f"No documents found for filing {accession_number}")
exhibit_type_upper = exhibit_type.upper()
# Find matching document by type
target = None
for doc in documents:
doc_type = (doc.get("type") or "").upper()
if doc_type == exhibit_type_upper:
target = doc
break
# Fallback: try matching description
if not target:
for doc in documents:
desc = (doc.get("description") or "").upper()
if exhibit_type_upper in desc:
target = doc
break
if not target:
raise ValueError(
f"Exhibit {exhibit_type} not found in filing {accession_number}. "
f"Available types: {[d.get('type') for d in documents]}"
)
url = target["url"]
content = await self._http.fetch_text(url)
if len(content) > MAX_EXHIBIT_SIZE:
raise ValueError(
f"Exhibit content exceeds size limit ({len(content)} bytes > {MAX_EXHIBIT_SIZE} bytes)"
)
# Determine content type
filename = target.get("filename", "")
if filename.lower().endswith(".htm") or filename.lower().endswith(".html"):
content_type = "text/html"
elif filename.lower().endswith(".xml"):
content_type = "application/xml"
else:
content_type = "text/plain"
return {
"content": content,
"content_type": content_type,
"filename": filename,
"url": url,
}
# Module-level singleton
sec_filings_service = SECFilingsService()

@ -0,0 +1,327 @@
"""
Shared SEC EDGAR HTTP client with retry, backoff, throttling, and caching.
Extracted from etf_holdings_fetcher.py to be reused by all SEC-related services.
"""
import time as _time
import asyncio
import aiohttp
import hashlib
import json
import os
import random
import logging
from typing import Dict, Optional
from app.core.config import settings
logger = logging.getLogger(__name__)
def _is_sec_block_page(text: str) -> bool:
if not text:
return False
tl = text.lower()
if "your request originates from an undeclared automated tool" in tl:
return True
if "<title>sec.gov | your request originates" in tl:
return True
if "reference id:" in tl and "sec.gov" in tl:
return True
return False
class SECHttpClient:
"""Shared HTTP client for SEC EDGAR API requests.
Features:
- asyncio.Semaphore(2) concurrent request limit
- 6 retries with 1.8x exponential backoff + random jitter
- 429 Retry-After header respect
- SEC block page detection
- Deadline-aware timeouts
- Disk cache (/tmp/stock_oracle_sec_cache/, SHA-256 keyed)
- In-memory cache (_json_cache, _text_cache)
"""
def __init__(self, user_agent_name: str = "Stock Oracle"):
self.sec_base_data = "https://data.sec.gov"
self.sec_base_www = "https://www.sec.gov"
self.http_timeout = aiohttp.ClientTimeout(total=12)
self._req_sem = asyncio.Semaphore(2)
self._text_cache: Dict[str, str] = {}
self._json_cache: Dict[str, dict] = {}
self._cache_dir = "/tmp/stock_oracle_sec_cache"
try:
os.makedirs(self._cache_dir, exist_ok=True)
except Exception:
pass
self._user_agent = f"{user_agent_name} ({settings.SEC_EMAIL})"
self._deadline: Optional[float] = None
# ------------------------------------------------------------------
# Deadline management
# ------------------------------------------------------------------
def set_deadline(self, seconds_from_now: float) -> None:
self._deadline = _time.monotonic() + seconds_from_now
def clear_deadline(self) -> None:
self._deadline = None
@property
def deadline(self) -> Optional[float]:
return self._deadline
@deadline.setter
def deadline(self, value: Optional[float]) -> None:
self._deadline = value
def remaining_time(self) -> Optional[float]:
if self._deadline is None:
return None
return max(0.0, self._deadline - _time.monotonic())
def is_deadline_exceeded(self) -> bool:
if self._deadline is None:
return False
return _time.monotonic() >= self._deadline
# ------------------------------------------------------------------
# Cache helpers
# ------------------------------------------------------------------
def _cache_path(self, url: str) -> str:
h = hashlib.sha256(url.encode("utf-8")).hexdigest()
return os.path.join(self._cache_dir, h)
# ------------------------------------------------------------------
# CIK lookup
# ------------------------------------------------------------------
async def get_company_cik(self, ticker: str) -> Optional[str]:
"""Look up zero-padded 10-digit CIK for a ticker."""
url = f"{self.sec_base_www}/files/company_tickers.json"
try:
data = await self.fetch_json(url)
for _key, company_info in data.items():
if company_info.get("ticker", "").upper() == ticker.upper():
cik_str = str(company_info.get("cik_str", "")).zfill(10)
logger.info(f"Found CIK {cik_str} for ticker {ticker}")
return cik_str
logger.warning(f"Ticker {ticker} not found in SEC mapping")
return None
except Exception as e:
logger.error(f"Error fetching CIK for {ticker}: {e}")
return None
# ------------------------------------------------------------------
# HTTP fetch methods
# ------------------------------------------------------------------
async def fetch_json(self, url: str) -> dict:
"""Fetch JSON with retry, backoff, and caching."""
# In-memory cache
if url in self._json_cache:
return self._json_cache[url]
# Disk cache
try:
cp = self._cache_path(url) + ".json"
if os.path.exists(cp):
ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600)
if _time.time() - os.path.getmtime(cp) <= ttl_sec:
with open(cp, "r", encoding="utf-8") as f:
data = json.load(f)
self._json_cache[url] = data
return data
except Exception:
pass
attempts = 6
backoff = 1.0
last_exc = None
for _i in range(attempts):
now = _time.monotonic()
if self._deadline is not None and now >= self._deadline:
break
req_timeout = self.http_timeout
if self._deadline is not None:
remaining = max(0.0, self._deadline - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(
total=min(remaining, getattr(self.http_timeout, "total", 12))
)
async with self._req_sem:
try:
async with aiohttp.ClientSession(
timeout=req_timeout,
headers={
"User-Agent": self._user_agent,
"Accept": "application/json",
},
) as session:
async with session.get(url, timeout=req_timeout) as resp:
if resp.status == 429:
retry_after = resp.headers.get("Retry-After")
delay = (
float(retry_after)
if retry_after and retry_after.isdigit()
else backoff
)
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
if 500 <= resp.status < 600:
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
resp.raise_for_status()
data = await resp.json()
# Cache successful response
try:
with open(self._cache_path(url) + ".json", "w", encoding="utf-8") as f:
json.dump(data, f)
except Exception:
pass
self._json_cache[url] = data
return data
except Exception as e:
last_exc = e
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
raise last_exc if last_exc else RuntimeError("Failed to fetch JSON")
async def fetch_text(self, url: str, accept: str = "text/html") -> str:
"""Fetch text content with retry, backoff, block page detection, and caching."""
# In-memory cache
if url in self._text_cache:
return self._text_cache[url]
# Disk cache
try:
cp = self._cache_path(url) + ".txt"
if os.path.exists(cp):
ttl_sec = max(3600, settings.SEC_DATA_REFRESH_HOURS * 3600)
if _time.time() - os.path.getmtime(cp) <= ttl_sec:
with open(cp, "r", encoding="utf-8") as f:
text = f.read()
if _is_sec_block_page(text):
try:
os.remove(cp)
except Exception:
pass
else:
self._text_cache[url] = text
return text
except Exception:
pass
attempts = 6
backoff = 1.0
last_exc = None
for _i in range(attempts):
now = _time.monotonic()
if self._deadline is not None and now >= self._deadline:
break
req_timeout = self.http_timeout
if self._deadline is not None:
remaining = max(0.0, self._deadline - now)
if remaining < 0.25:
break
req_timeout = aiohttp.ClientTimeout(
total=min(remaining, getattr(self.http_timeout, "total", 12))
)
async with self._req_sem:
try:
async with aiohttp.ClientSession(
timeout=req_timeout,
headers={
"User-Agent": self._user_agent,
"Accept": accept,
},
) as session:
async with session.get(url, timeout=req_timeout) as resp:
if resp.status == 429:
retry_after = resp.headers.get("Retry-After")
delay = (
float(retry_after)
if retry_after and retry_after.isdigit()
else backoff
)
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
if 500 <= resp.status < 600:
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
resp.raise_for_status()
text = await resp.text()
if _is_sec_block_page(text):
last_exc = RuntimeError("SEC_BLOCKED")
delay = backoff * 2.0
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.5 if delay > 0 else 0.0)
)
backoff *= 2.0
continue
# Cache successful response
self._text_cache[url] = text
try:
with open(self._cache_path(url) + ".txt", "w", encoding="utf-8") as f:
f.write(text)
except Exception:
pass
return text
except Exception as e:
last_exc = e
delay = backoff
if self._deadline is not None:
remaining = max(0.0, self._deadline - _time.monotonic())
delay = min(delay, max(0.0, remaining - 0.05))
await asyncio.sleep(
max(0.0, delay)
+ random.uniform(0.0, delay * 0.25 if delay > 0 else 0.0)
)
backoff *= 1.8
continue
raise last_exc if last_exc else RuntimeError("Failed to fetch text")
Loading…
Cancel
Save