Files
signal-platform/app/services/sec_client.py
T
dennisthiessenandClaude Opus 4.8 7413de9301 feat(sec): A3 slice 2a — companyfacts -> snapshot parser
Pure parser (no I/O/DB) turning one issuer's companyfacts + submissions filing
metadata into per-accession snapshot rows for the filing's primary period.

- Period identity from end == reportDate, never fy/fp (fy/fp is the filing's
  context; comparatives repeat it).
- Duration facts stored as cumulative YTD: pick the fact whose span matches the
  fiscal-period-to-date length (Q1~3mo..FY~12mo) within tolerance; no YTD-length
  fact -> null (never a discrete masquerading as YTD).
- Balance-sheet instants at end == reportDate; shares_outstanding is the dei
  cover-page fact whose own end (cover date) is stored in shares_outstanding_date.
- Cash and debt composites are aggregate-first and mutually exclusive (each
  source tag counted at most once).
- Carries filing_date through submissions rows (snapshot.filed_date).

Verified on REAL Apple companyfacts: 44 snapshots, 0 skipped, YTD revenue
124.3B->219.7B->313.7B->416.2B across FY2025 (Q4 derives at read time), every
shares_date is the cover date != period_end. Tests: 6 fixture + 1 skip-guarded
live-invariants (monotonic YTD, cover-date shares).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 16:11:22 +02:00

352 lines
13 KiB
Python

"""Async SEC EDGAR client for the fundamentals importer (workstream A).
All access is batch (never at request time). This wraps the three SEC products
the A3 design uses — `company_tickers.json`, `submissions/`, `companyfacts/`, and
the daily filing index — behind one client that honors SEC's fair-access policy:
- an identifying ``User-Agent`` with a contact email on every request (config);
- request spacing well under the 10 req/s limit;
- exponential backoff + retry on 429;
- **403 → alert and stop** (raise ``SecForbiddenError``), never a retry-loop — a
403 means the UA or request pattern is wrong and retrying won't fix it.
Parsing lives here (index fixed-width, submissions pagination); DB writes and the
snapshot mapping live in the importer. No conditional GETs — the companyfacts
endpoint exposes no ETag/Last-Modified (verified), which is why the importer is
daily-index driven rather than polling archives.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
from datetime import date, datetime
from pathlib import Path
from typing import Any
import httpx
from app.config import settings
from app.exceptions import ProviderError
from app.services.earnings_alignment import normalise_symbol
logger = logging.getLogger(__name__)
_WWW = "https://www.sec.gov"
_DATA = "https://data.sec.gov"
# Resolve CA bundle for explicit httpx verify (matches app/providers/fmp.py).
_CA = os.environ.get("SSL_CERT_FILE", "")
_CA_VERIFY: str | bool = _CA if _CA and Path(_CA).exists() else True
_FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
class SecError(ProviderError):
"""SEC request failed (403, exhausted 429/5xx, timeout, transport, parse)."""
class SecForbiddenError(SecError):
"""SEC returned 403 — User-Agent/pattern rejected. Alert and stop."""
class SecNotFoundError(SecError):
"""SEC returned 404 — the resource does not exist (e.g. no index for a day).
The *only* error a caller may treat as 'missing' — every other SecError
(403, exhausted retries, 5xx, timeout) must propagate so a fetch failure is
never mistaken for an empty result."""
def _looks_like_contact_email(ua: str) -> bool:
if "example.com" in ua.lower() or "set-a-real-email" in ua.lower():
return False
return re.search(r"[^@\s]+@[^@\s]+\.[^@\s]+", ua) is not None
# SEC asks callers to stay well under 10 req/s; enforce a floor on real clients.
_MIN_PROD_SPACING = 0.11
def cik10(cik: int | str) -> str:
"""Zero-pad a CIK to the 10-digit form SEC URLs use (320193 -> 0000320193)."""
return str(int(cik)).zfill(10)
class SecClient:
"""Fair-access SEC HTTP client. Use as ``async with SecClient() as c:``."""
def __init__(
self,
*,
user_agent: str | None = None,
spacing_seconds: float | None = None,
max_retries: int | None = None,
timeout: float | None = None,
transport: httpx.AsyncBaseTransport | None = None,
) -> None:
self._ua = user_agent or settings.sec_user_agent
self._spacing = (
spacing_seconds if spacing_seconds is not None else settings.sec_request_spacing_seconds
)
self._max_retries = (
max_retries if max_retries is not None else settings.sec_max_retries
)
self._timeout = timeout if timeout is not None else settings.sec_request_timeout_seconds
self._transport = transport # injectable for tests
self._client: httpx.AsyncClient | None = None
self._lock = asyncio.Lock()
self._last_request = 0.0
def _validate_fair_access(self) -> None:
"""On a real (non-mocked) client, enforce SEC fair-access preconditions
so we can't accidentally hammer SEC or get 403'd: a genuine contact-email
User-Agent and a spacing floor. Mock transports skip this (tests use 0)."""
if not _looks_like_contact_email(self._ua):
raise SecError(
"sec_user_agent must contain a real contact email (got "
f"{self._ua!r}) — SEC fair-access requires it"
)
if self._spacing < _MIN_PROD_SPACING:
raise SecError(
f"sec_request_spacing_seconds {self._spacing} is below the "
f"{_MIN_PROD_SPACING}s fair-access floor"
)
async def __aenter__(self) -> "SecClient":
if self._transport is None:
self._validate_fair_access()
self._client = httpx.AsyncClient(
headers={"User-Agent": self._ua, "Accept-Encoding": "gzip, deflate"},
timeout=self._timeout,
verify=_CA_VERIFY,
transport=self._transport,
)
return self
async def __aexit__(self, *exc) -> None:
if self._client is not None:
await self._client.aclose()
self._client = None
async def _throttle(self) -> None:
async with self._lock:
now = asyncio.get_event_loop().time()
wait = self._spacing - (now - self._last_request)
if wait > 0:
await asyncio.sleep(wait)
self._last_request = asyncio.get_event_loop().time()
async def _get(self, url: str) -> httpx.Response:
assert self._client is not None, "use `async with SecClient()`"
attempt = 0
while True:
await self._throttle()
try:
resp = await self._client.get(url)
except (httpx.TimeoutException, httpx.TransportError) as exc:
attempt += 1
if attempt > self._max_retries:
raise SecError(f"SEC network error for {url}: {exc}") from exc
await asyncio.sleep(min(2.0**attempt, 30.0))
continue
code = resp.status_code
if code == 403:
raise SecForbiddenError(
f"SEC 403 for {url} — User-Agent/pattern rejected; set a real "
"sec_user_agent contact email"
)
if code == 404:
raise SecNotFoundError(f"SEC 404 for {url}")
# 429 and 5xx are transient — retry with backoff, honoring Retry-After.
if code == 429 or 500 <= code < 600:
attempt += 1
if attempt > self._max_retries:
raise SecError(f"SEC {code} after {self._max_retries} retries: {url}")
delay = _retry_after_seconds(resp) or min(2.0**attempt, 30.0)
logger.warning("SEC %d for %s — backoff %.1fs (attempt %d)", code, url, delay, attempt)
await asyncio.sleep(delay)
continue
if code >= 400:
raise SecError(f"SEC {code} for {url}")
return resp
async def get_json(self, url: str) -> Any:
return (await self._get(url)).json()
async def get_text(self, url: str) -> str:
return (await self._get(url)).text
# -- domain fetchers ---------------------------------------------------
async def company_tickers(self) -> dict[str, int]:
"""Map normalised ticker -> CIK (int). Multi-class tickers share a CIK."""
data = await self.get_json(f"{_WWW}/files/company_tickers.json")
out: dict[str, int] = {}
for row in data.values():
sym = normalise_symbol(row.get("ticker"))
if sym:
out[sym] = int(row["cik_str"])
return out
async def submissions(self, cik: int | str, *, include_history: bool = False) -> dict[str, Any]:
"""Issuer metadata + filing list.
``filings.recent`` caps at 1000; older accessions live in
``filings.files[]`` shards. Only ``include_history=True`` (the one-time
full backfill) fetches those shards — SIC refresh and incremental runs
use the recent list alone and make no extra requests.
"""
base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json")
filings = _rows_from_arrays(base["filings"]["recent"])
if include_history:
for shard in base["filings"].get("files") or []:
shard_data = await self.get_json(f"{_DATA}/submissions/{shard['name']}")
filings.extend(_rows_from_arrays(shard_data))
return {
"cik": int(base["cik"]),
"name": base.get("name"),
"sic": base.get("sic"),
"sic_description": base.get("sicDescription"),
"fiscal_year_end": base.get("fiscalYearEnd"),
"tickers": base.get("tickers") or [],
"filings": filings,
}
async def companyfacts(self, cik: int | str) -> dict[str, Any]:
"""Raw companyfacts JSON ({cik, entityName, facts})."""
return await self.get_json(f"{_DATA}/api/xbrl/companyfacts/CIK{cik10(cik)}.json")
async def latest_index_date(self, today: date | None = None) -> date | None:
"""The most recent published daily-index date (drives the revision). Checks
the current quarter, falling back to the previous one at a quarter boundary."""
today = today or date.today()
for year, qtr in _quarters_back(today, 2):
url = f"{_WWW}/Archives/edgar/daily-index/{year}/QTR{qtr}/index.json"
try:
idx = await self.get_json(url)
except SecNotFoundError:
continue # quarter dir absent — only 404 is "missing"
dates = [
d
for item in idx.get("directory", {}).get("item", [])
if (d := _index_file_date(item.get("name", ""))) is not None
and d <= today
]
if dates:
return max(dates)
return None
async def daily_index(self, day: date) -> list[dict[str, Any]]:
"""Parse the daily form index into 10-K/10-Q(/A) rows for all issuers.
Returns [{form, cik, accession, company}]. The caller filters to the
tracked universe. A missing index (weekend/holiday/not-yet-published)
returns [] rather than raising.
"""
qtr = (day.month - 1) // 3 + 1
url = f"{_WWW}/Archives/edgar/daily-index/{day.year}/QTR{qtr}/form.{day:%Y%m%d}.idx"
try:
text = await self.get_text(url)
except SecNotFoundError:
logger.info("no daily index for %s (404)", day)
return [] # weekend/holiday/not-yet-published; other errors propagate
return _parse_form_index(text)
def _rows_from_arrays(arrays: dict[str, list]) -> list[dict[str, Any]]:
"""Turn SEC's parallel-array filing block into row dicts (keeping only 10-K/10-Q
family filings — the ones that carry XBRL fundamentals)."""
forms = arrays.get("form", [])
out: list[dict[str, Any]] = []
for i, form in enumerate(forms):
if form not in _FORMS_10:
continue
out.append(
{
"accession": arrays["accessionNumber"][i],
"form": form,
"report_date": arrays["reportDate"][i] or None,
"filing_date": arrays["filingDate"][i] or None,
"acceptance_datetime": arrays["acceptanceDateTime"][i] or None,
"is_xbrl": bool(arrays.get("isXBRL", [0] * len(forms))[i]),
}
)
return out
def _parse_form_index(text: str) -> list[dict[str, Any]]:
"""Parse a daily ``form.YYYYMMDD.idx`` (fixed columns: Form / Company / CIK /
Date Filed / File Name-with-accession)."""
rows: list[dict[str, Any]] = []
started = False
for line in text.splitlines():
if not started:
if set(line.strip()) == {"-"}: # the dashed separator row
started = True
continue
parts = line.split()
if len(parts) < 5:
continue
form = parts[0]
if form not in _FORMS_10:
continue
path = parts[-1] # edgar/data/<cik>/<accession>.txt
cik = _cik_from_path(path)
accession = _accession_from_path(path)
if cik is None or accession is None:
continue
rows.append({"form": form, "cik": cik, "accession": accession, "path": path})
return rows
def _retry_after_seconds(resp: httpx.Response) -> float | None:
"""Parse a numeric-seconds Retry-After header (SEC uses seconds), capped."""
raw = resp.headers.get("Retry-After")
if not raw:
return None
try:
return min(float(raw), 60.0)
except (TypeError, ValueError):
return None
def _index_file_date(name: str) -> date | None:
if name.startswith("form.") and name.endswith(".idx"):
try:
return datetime.strptime(name[5:13], "%Y%m%d").date()
except ValueError:
return None
return None
def _quarters_back(today: date, n: int) -> list[tuple[int, int]]:
"""(year, quarter) for `today`'s quarter and the previous n-1, newest first."""
q = (today.month - 1) // 3 + 1
out = []
y = today.year
for _ in range(n):
out.append((y, q))
q -= 1
if q == 0:
q = 4
y -= 1
return out
def _cik_from_path(path: str) -> int | None:
segs = path.split("/")
if len(segs) >= 3 and segs[2].isdigit():
return int(segs[2])
return None
def _accession_from_path(path: str) -> str | None:
stem = path.rsplit("/", 1)[-1]
if stem.endswith(".txt"):
stem = stem[:-4]
return stem or None