fix(sec): A3 slice-1 review — read-only resolution, error propagation, fair-access
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>
This commit is contained in:
+83
-22
@@ -21,6 +21,7 @@ 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
|
||||
@@ -44,13 +45,31 @@ _FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
|
||||
|
||||
|
||||
class SecError(ProviderError):
|
||||
"""SEC request failed."""
|
||||
"""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)
|
||||
@@ -81,7 +100,24 @@ class SecClient:
|
||||
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,
|
||||
@@ -108,22 +144,34 @@ class SecClient:
|
||||
attempt = 0
|
||||
while True:
|
||||
await self._throttle()
|
||||
resp = await self._client.get(url)
|
||||
if resp.status_code == 403:
|
||||
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 resp.status_code == 429:
|
||||
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 429 after {self._max_retries} retries: {url}")
|
||||
backoff = min(2.0**attempt, 30.0)
|
||||
logger.warning("SEC 429 for %s — backoff %.1fs (attempt %d)", url, backoff, attempt)
|
||||
await asyncio.sleep(backoff)
|
||||
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 resp.status_code >= 400:
|
||||
raise SecError(f"SEC {resp.status_code} for {url}")
|
||||
if code >= 400:
|
||||
raise SecError(f"SEC {code} for {url}")
|
||||
return resp
|
||||
|
||||
async def get_json(self, url: str) -> Any:
|
||||
@@ -144,18 +192,20 @@ class SecClient:
|
||||
out[sym] = int(row["cik_str"])
|
||||
return out
|
||||
|
||||
async def submissions(self, cik: int | str) -> dict[str, Any]:
|
||||
"""Issuer metadata + the FULL merged filing history.
|
||||
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. This merges them so backfill sees every
|
||||
filing's reportDate / acceptanceDateTime / isXBRL.
|
||||
``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"])
|
||||
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))
|
||||
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"),
|
||||
@@ -178,8 +228,8 @@ class SecClient:
|
||||
url = f"{_WWW}/Archives/edgar/daily-index/{year}/QTR{qtr}/index.json"
|
||||
try:
|
||||
idx = await self.get_json(url)
|
||||
except SecError:
|
||||
continue
|
||||
except SecNotFoundError:
|
||||
continue # quarter dir absent — only 404 is "missing"
|
||||
dates = [
|
||||
d
|
||||
for item in idx.get("directory", {}).get("item", [])
|
||||
@@ -201,9 +251,9 @@ class SecClient:
|
||||
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 SecError as exc:
|
||||
logger.info("no daily index for %s (%s)", day, exc)
|
||||
return []
|
||||
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)
|
||||
|
||||
|
||||
@@ -252,6 +302,17 @@ def _parse_form_index(text: str) -> list[dict[str, Any]]:
|
||||
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:
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
"""Tracked-universe CIK/SIC resolution and the SEC importer's composite revision.
|
||||
|
||||
Resolves the app's tracked tickers to SEC issuers (CIK) and back-fills
|
||||
``tickers.cik/sic/sic_description``. Also builds the **universe fingerprint** that
|
||||
goes into the importer's composite revision, so that adding a ticker changes the
|
||||
revision and forces a run instead of being ``no_op``'d away or starved waiting for
|
||||
its issuer to file (A3 design, Decision 1 review fix).
|
||||
Resolves the app's tracked tickers to SEC issuers (CIK) and prepares
|
||||
``tickers.cik/sic/sic_description`` back-fills. Also builds the **universe
|
||||
fingerprint** in the importer's composite revision, so adding a ticker changes
|
||||
the revision and forces a run instead of being ``no_op``'d away or starved
|
||||
waiting for its issuer to file (A3 design, Decision 1 review fix).
|
||||
|
||||
**Transaction contract:** resolution is read-only — `resolve_ciks` and
|
||||
`fetch_sic_updates` compute *proposed* updates and mutate nothing. They run in
|
||||
the importer's `stage` (which must not write, or a failed validation would leak
|
||||
changes on the framework's failure commit). The proposals are applied only in
|
||||
`promote`, via `apply_ticker_updates`, atomically with the snapshot inserts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterable
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update
|
||||
|
||||
from app.models.ticker import Ticker
|
||||
from app.services.earnings_alignment import normalise_symbol
|
||||
@@ -22,47 +29,72 @@ from app.services.sec_client import SecClient
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def resolve_ciks(db, client: SecClient) -> dict[str, int]:
|
||||
"""Resolve tracked tickers to CIKs via company_tickers.json and persist
|
||||
``tickers.cik`` where it changed. Returns {normalised symbol: cik} for the
|
||||
tracked tickers that resolved (multi-class tickers share a CIK)."""
|
||||
ticker_to_cik = await client.company_tickers()
|
||||
rows = (await db.execute(select(Ticker))).scalars().all()
|
||||
@dataclass
|
||||
class ResolvedUniverse:
|
||||
"""Read-only result of CIK resolution. `cik_updates` are proposed writes
|
||||
(ticker_id → new cik string) applied later in promote."""
|
||||
|
||||
resolved: dict[str, int] = {}
|
||||
changed = 0
|
||||
for t in rows:
|
||||
if not t.symbol:
|
||||
symbol_to_cik: dict[str, int] = field(default_factory=dict)
|
||||
cik_to_ticker_ids: dict[int, list[int]] = field(default_factory=dict)
|
||||
cik_updates: list[tuple[int, str]] = field(default_factory=list)
|
||||
|
||||
|
||||
async def resolve_ciks(db, client: SecClient) -> ResolvedUniverse:
|
||||
"""Resolve tracked tickers to CIKs via company_tickers.json. **Read-only** —
|
||||
returns the mapping + proposed `tickers.cik` writes; mutates nothing."""
|
||||
ticker_to_cik = await client.company_tickers()
|
||||
rows = (await db.execute(select(Ticker.id, Ticker.symbol, Ticker.cik))).all()
|
||||
|
||||
result = ResolvedUniverse()
|
||||
for tid, symbol, current_cik in rows:
|
||||
if not symbol:
|
||||
continue
|
||||
sym = normalise_symbol(t.symbol)
|
||||
sym = normalise_symbol(symbol)
|
||||
cik = ticker_to_cik.get(sym)
|
||||
if cik is None:
|
||||
continue # e.g. ADRs / non-SEC issuers — snapshots simply absent
|
||||
resolved[sym] = cik
|
||||
cik_str = f"{cik:010d}"
|
||||
if t.cik != cik_str:
|
||||
t.cik = cik_str
|
||||
changed += 1
|
||||
logger.info("resolve_ciks: %d tracked resolved, %d cik updates", len(resolved), changed)
|
||||
return resolved
|
||||
continue # ADRs / non-SEC issuers — snapshots simply absent
|
||||
result.symbol_to_cik[sym] = cik
|
||||
result.cik_to_ticker_ids.setdefault(cik, []).append(tid)
|
||||
if current_cik != f"{cik:010d}":
|
||||
result.cik_updates.append((tid, f"{cik:010d}"))
|
||||
logger.info(
|
||||
"resolve_ciks: %d resolved, %d proposed cik updates",
|
||||
len(result.symbol_to_cik),
|
||||
len(result.cik_updates),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def refresh_sic(db, client: SecClient, ciks: Iterable[int]) -> int:
|
||||
"""Fetch submissions for the given CIKs and set sic/sic_description on every
|
||||
tracked ticker sharing each CIK. Returns the number of CIKs refreshed. Callers
|
||||
pass only the CIKs that need it (e.g. those still missing a SIC) to stay light."""
|
||||
refreshed = 0
|
||||
for cik in sorted(set(int(c) for c in ciks)):
|
||||
sub = await client.submissions(cik)
|
||||
cik_str = f"{cik:010d}"
|
||||
rows = (
|
||||
await db.execute(select(Ticker).where(Ticker.cik == cik_str))
|
||||
).scalars().all()
|
||||
for t in rows:
|
||||
t.sic = str(sub["sic"]) if sub.get("sic") else None
|
||||
t.sic_description = sub.get("sic_description")
|
||||
refreshed += 1
|
||||
return refreshed
|
||||
async def fetch_sic_updates(
|
||||
client: SecClient, cik_to_ticker_ids: dict[int, Iterable[int]]
|
||||
) -> list[tuple[int, str | None, str | None]]:
|
||||
"""Fetch SIC for each CIK (recent-only submissions, no history shards) and
|
||||
return proposed `(ticker_id, sic, sic_description)` writes. **Read-only** — no
|
||||
DB mutation. Callers pass only the CIKs that need it (e.g. missing a SIC)."""
|
||||
updates: list[tuple[int, str | None, str | None]] = []
|
||||
for cik, ticker_ids in cik_to_ticker_ids.items():
|
||||
sub = await client.submissions(cik, include_history=False)
|
||||
sic = str(sub["sic"]) if sub.get("sic") else None
|
||||
desc = sub.get("sic_description")
|
||||
for tid in ticker_ids:
|
||||
updates.append((tid, sic, desc))
|
||||
return updates
|
||||
|
||||
|
||||
async def apply_ticker_updates(
|
||||
db,
|
||||
resolved: ResolvedUniverse,
|
||||
sic_updates: list[tuple[int, str | None, str | None]] | None = None,
|
||||
) -> dict[str, int]:
|
||||
"""Apply the proposed cik / sic writes. **The only writer** — call inside
|
||||
promote so it commits atomically with the snapshot inserts."""
|
||||
for tid, cik in resolved.cik_updates:
|
||||
await db.execute(update(Ticker).where(Ticker.id == tid).values(cik=cik))
|
||||
for tid, sic, desc in sic_updates or []:
|
||||
await db.execute(
|
||||
update(Ticker).where(Ticker.id == tid).values(sic=sic, sic_description=desc)
|
||||
)
|
||||
return {"cik_updates": len(resolved.cik_updates), "sic_updates": len(sic_updates or [])}
|
||||
|
||||
|
||||
def universe_fingerprint(symbol_to_cik: dict[str, int]) -> str:
|
||||
@@ -72,16 +104,16 @@ def universe_fingerprint(symbol_to_cik: dict[str, int]) -> str:
|
||||
return hashlib.blake2b(canonical.encode("utf-8"), digest_size=12).hexdigest()
|
||||
|
||||
|
||||
def compose_revision(
|
||||
index_date, index_content_hash: str, symbol_to_cik: dict[str, int]
|
||||
) -> str:
|
||||
"""The importer's composite revision: latest processed index date + a hash of
|
||||
the index content consumed this run + the universe fingerprint. Equal revision
|
||||
across runs ⇒ nothing new to import ⇒ no_op."""
|
||||
return f"{index_date}:{index_content_hash}:{universe_fingerprint(symbol_to_cik)}"
|
||||
|
||||
|
||||
def index_content_hash(index_rows: Iterable[dict]) -> str:
|
||||
"""Order-independent hash of the tracked index accessions consumed this run."""
|
||||
keys = sorted(f"{r['cik']}/{r['accession']}" for r in index_rows)
|
||||
return hashlib.blake2b("|".join(keys).encode("utf-8"), digest_size=12).hexdigest()
|
||||
|
||||
|
||||
def compose_revision(index_date, content_hash: str, symbol_to_cik: dict[str, int]) -> str:
|
||||
"""Composite revision = processed index date + index-content hash + universe
|
||||
fingerprint. Equal across runs ⇒ nothing new ⇒ no_op. Rejects a missing index
|
||||
date rather than emitting a `None:...` revision that could false-match."""
|
||||
if index_date is None:
|
||||
raise ValueError("compose_revision requires a non-null index date")
|
||||
return f"{index_date}:{content_hash}:{universe_fingerprint(symbol_to_cik)}"
|
||||
|
||||
Reference in New Issue
Block a user