Addresses the slice-1 review: 1. Resolution is now read-only (A1 transaction contract). resolve_ciks / fetch_sic_updates compute proposals and mutate nothing; a new apply_ticker_updates issues the writes, called only in promote — so a failed validation can't leak ticker changes on the framework's failure commit. 2. Only 404 means "missing". Added SecNotFoundError; daily_index / latest_index_date catch only that. 403, exhausted 429, 5xx, timeouts, and transport/parse errors now propagate instead of looking like "no index". 3. Fair-access enforced when opening a REAL client (transport=None): reject blank/placeholder/non-email User-Agent and sub-0.11s spacing. Mock transports skip it (tests use 0 spacing). 4. submissions(include_history=False) by default — only the one-time full backfill fetches the history shards; SIC/incremental work makes no extra requests. Plus: retry transient 5xx/network errors and honor Retry-After during the 1 GB backfill; compose_revision rejects a missing index date (no "None:..." revision). Re-verified live vs real SEC (fair-access validation passes, shard merge intact). Tests: 18 (added error propagation, read-only resolution, fair-access, recent-only submissions, reject-None revision). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
351 lines
13 KiB
Python
351 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,
|
|
"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
|