Docs/dolt plan clarifications #1
+83
-22
@@ -21,6 +21,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -44,13 +45,31 @@ _FORMS_10 = frozenset({"10-K", "10-Q", "10-K/A", "10-Q/A"})
|
|||||||
|
|
||||||
|
|
||||||
class SecError(ProviderError):
|
class SecError(ProviderError):
|
||||||
"""SEC request failed."""
|
"""SEC request failed (403, exhausted 429/5xx, timeout, transport, parse)."""
|
||||||
|
|
||||||
|
|
||||||
class SecForbiddenError(SecError):
|
class SecForbiddenError(SecError):
|
||||||
"""SEC returned 403 — User-Agent/pattern rejected. Alert and stop."""
|
"""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:
|
def cik10(cik: int | str) -> str:
|
||||||
"""Zero-pad a CIK to the 10-digit form SEC URLs use (320193 -> 0000320193)."""
|
"""Zero-pad a CIK to the 10-digit form SEC URLs use (320193 -> 0000320193)."""
|
||||||
return str(int(cik)).zfill(10)
|
return str(int(cik)).zfill(10)
|
||||||
@@ -81,7 +100,24 @@ class SecClient:
|
|||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
self._last_request = 0.0
|
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":
|
async def __aenter__(self) -> "SecClient":
|
||||||
|
if self._transport is None:
|
||||||
|
self._validate_fair_access()
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
headers={"User-Agent": self._ua, "Accept-Encoding": "gzip, deflate"},
|
headers={"User-Agent": self._ua, "Accept-Encoding": "gzip, deflate"},
|
||||||
timeout=self._timeout,
|
timeout=self._timeout,
|
||||||
@@ -108,22 +144,34 @@ class SecClient:
|
|||||||
attempt = 0
|
attempt = 0
|
||||||
while True:
|
while True:
|
||||||
await self._throttle()
|
await self._throttle()
|
||||||
resp = await self._client.get(url)
|
try:
|
||||||
if resp.status_code == 403:
|
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(
|
raise SecForbiddenError(
|
||||||
f"SEC 403 for {url} — User-Agent/pattern rejected; set a real "
|
f"SEC 403 for {url} — User-Agent/pattern rejected; set a real "
|
||||||
"sec_user_agent contact email"
|
"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
|
attempt += 1
|
||||||
if attempt > self._max_retries:
|
if attempt > self._max_retries:
|
||||||
raise SecError(f"SEC 429 after {self._max_retries} retries: {url}")
|
raise SecError(f"SEC {code} after {self._max_retries} retries: {url}")
|
||||||
backoff = min(2.0**attempt, 30.0)
|
delay = _retry_after_seconds(resp) or min(2.0**attempt, 30.0)
|
||||||
logger.warning("SEC 429 for %s — backoff %.1fs (attempt %d)", url, backoff, attempt)
|
logger.warning("SEC %d for %s — backoff %.1fs (attempt %d)", code, url, delay, attempt)
|
||||||
await asyncio.sleep(backoff)
|
await asyncio.sleep(delay)
|
||||||
continue
|
continue
|
||||||
if resp.status_code >= 400:
|
if code >= 400:
|
||||||
raise SecError(f"SEC {resp.status_code} for {url}")
|
raise SecError(f"SEC {code} for {url}")
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
async def get_json(self, url: str) -> Any:
|
async def get_json(self, url: str) -> Any:
|
||||||
@@ -144,18 +192,20 @@ class SecClient:
|
|||||||
out[sym] = int(row["cik_str"])
|
out[sym] = int(row["cik_str"])
|
||||||
return out
|
return out
|
||||||
|
|
||||||
async def submissions(self, cik: int | str) -> dict[str, Any]:
|
async def submissions(self, cik: int | str, *, include_history: bool = False) -> dict[str, Any]:
|
||||||
"""Issuer metadata + the FULL merged filing history.
|
"""Issuer metadata + filing list.
|
||||||
|
|
||||||
``filings.recent`` caps at 1000; older accessions live in
|
``filings.recent`` caps at 1000; older accessions live in
|
||||||
``filings.files[]`` shards. This merges them so backfill sees every
|
``filings.files[]`` shards. Only ``include_history=True`` (the one-time
|
||||||
filing's reportDate / acceptanceDateTime / isXBRL.
|
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")
|
base = await self.get_json(f"{_DATA}/submissions/CIK{cik10(cik)}.json")
|
||||||
filings = _rows_from_arrays(base["filings"]["recent"])
|
filings = _rows_from_arrays(base["filings"]["recent"])
|
||||||
for shard in base["filings"].get("files") or []:
|
if include_history:
|
||||||
shard_data = await self.get_json(f"{_DATA}/submissions/{shard['name']}")
|
for shard in base["filings"].get("files") or []:
|
||||||
filings.extend(_rows_from_arrays(shard_data))
|
shard_data = await self.get_json(f"{_DATA}/submissions/{shard['name']}")
|
||||||
|
filings.extend(_rows_from_arrays(shard_data))
|
||||||
return {
|
return {
|
||||||
"cik": int(base["cik"]),
|
"cik": int(base["cik"]),
|
||||||
"name": base.get("name"),
|
"name": base.get("name"),
|
||||||
@@ -178,8 +228,8 @@ class SecClient:
|
|||||||
url = f"{_WWW}/Archives/edgar/daily-index/{year}/QTR{qtr}/index.json"
|
url = f"{_WWW}/Archives/edgar/daily-index/{year}/QTR{qtr}/index.json"
|
||||||
try:
|
try:
|
||||||
idx = await self.get_json(url)
|
idx = await self.get_json(url)
|
||||||
except SecError:
|
except SecNotFoundError:
|
||||||
continue
|
continue # quarter dir absent — only 404 is "missing"
|
||||||
dates = [
|
dates = [
|
||||||
d
|
d
|
||||||
for item in idx.get("directory", {}).get("item", [])
|
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"
|
url = f"{_WWW}/Archives/edgar/daily-index/{day.year}/QTR{qtr}/form.{day:%Y%m%d}.idx"
|
||||||
try:
|
try:
|
||||||
text = await self.get_text(url)
|
text = await self.get_text(url)
|
||||||
except SecError as exc:
|
except SecNotFoundError:
|
||||||
logger.info("no daily index for %s (%s)", day, exc)
|
logger.info("no daily index for %s (404)", day)
|
||||||
return []
|
return [] # weekend/holiday/not-yet-published; other errors propagate
|
||||||
return _parse_form_index(text)
|
return _parse_form_index(text)
|
||||||
|
|
||||||
|
|
||||||
@@ -252,6 +302,17 @@ def _parse_form_index(text: str) -> list[dict[str, Any]]:
|
|||||||
return rows
|
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:
|
def _index_file_date(name: str) -> date | None:
|
||||||
if name.startswith("form.") and name.endswith(".idx"):
|
if name.startswith("form.") and name.endswith(".idx"):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,19 +1,26 @@
|
|||||||
"""Tracked-universe CIK/SIC resolution and the SEC importer's composite revision.
|
"""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
|
Resolves the app's tracked tickers to SEC issuers (CIK) and prepares
|
||||||
``tickers.cik/sic/sic_description``. Also builds the **universe fingerprint** that
|
``tickers.cik/sic/sic_description`` back-fills. Also builds the **universe
|
||||||
goes into the importer's composite revision, so that adding a ticker changes the
|
fingerprint** in the importer's composite revision, so adding a ticker changes
|
||||||
revision and forces a run instead of being ``no_op``'d away or starved waiting for
|
the revision and forces a run instead of being ``no_op``'d away or starved
|
||||||
its issuer to file (A3 design, Decision 1 review fix).
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, update
|
||||||
|
|
||||||
from app.models.ticker import Ticker
|
from app.models.ticker import Ticker
|
||||||
from app.services.earnings_alignment import normalise_symbol
|
from app.services.earnings_alignment import normalise_symbol
|
||||||
@@ -22,47 +29,72 @@ from app.services.sec_client import SecClient
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def resolve_ciks(db, client: SecClient) -> dict[str, int]:
|
@dataclass
|
||||||
"""Resolve tracked tickers to CIKs via company_tickers.json and persist
|
class ResolvedUniverse:
|
||||||
``tickers.cik`` where it changed. Returns {normalised symbol: cik} for the
|
"""Read-only result of CIK resolution. `cik_updates` are proposed writes
|
||||||
tracked tickers that resolved (multi-class tickers share a CIK)."""
|
(ticker_id → new cik string) applied later in promote."""
|
||||||
ticker_to_cik = await client.company_tickers()
|
|
||||||
rows = (await db.execute(select(Ticker))).scalars().all()
|
|
||||||
|
|
||||||
resolved: dict[str, int] = {}
|
symbol_to_cik: dict[str, int] = field(default_factory=dict)
|
||||||
changed = 0
|
cik_to_ticker_ids: dict[int, list[int]] = field(default_factory=dict)
|
||||||
for t in rows:
|
cik_updates: list[tuple[int, str]] = field(default_factory=list)
|
||||||
if not t.symbol:
|
|
||||||
|
|
||||||
|
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
|
continue
|
||||||
sym = normalise_symbol(t.symbol)
|
sym = normalise_symbol(symbol)
|
||||||
cik = ticker_to_cik.get(sym)
|
cik = ticker_to_cik.get(sym)
|
||||||
if cik is None:
|
if cik is None:
|
||||||
continue # e.g. ADRs / non-SEC issuers — snapshots simply absent
|
continue # ADRs / non-SEC issuers — snapshots simply absent
|
||||||
resolved[sym] = cik
|
result.symbol_to_cik[sym] = cik
|
||||||
cik_str = f"{cik:010d}"
|
result.cik_to_ticker_ids.setdefault(cik, []).append(tid)
|
||||||
if t.cik != cik_str:
|
if current_cik != f"{cik:010d}":
|
||||||
t.cik = cik_str
|
result.cik_updates.append((tid, f"{cik:010d}"))
|
||||||
changed += 1
|
logger.info(
|
||||||
logger.info("resolve_ciks: %d tracked resolved, %d cik updates", len(resolved), changed)
|
"resolve_ciks: %d resolved, %d proposed cik updates",
|
||||||
return resolved
|
len(result.symbol_to_cik),
|
||||||
|
len(result.cik_updates),
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def refresh_sic(db, client: SecClient, ciks: Iterable[int]) -> int:
|
async def fetch_sic_updates(
|
||||||
"""Fetch submissions for the given CIKs and set sic/sic_description on every
|
client: SecClient, cik_to_ticker_ids: dict[int, Iterable[int]]
|
||||||
tracked ticker sharing each CIK. Returns the number of CIKs refreshed. Callers
|
) -> list[tuple[int, str | None, str | None]]:
|
||||||
pass only the CIKs that need it (e.g. those still missing a SIC) to stay light."""
|
"""Fetch SIC for each CIK (recent-only submissions, no history shards) and
|
||||||
refreshed = 0
|
return proposed `(ticker_id, sic, sic_description)` writes. **Read-only** — no
|
||||||
for cik in sorted(set(int(c) for c in ciks)):
|
DB mutation. Callers pass only the CIKs that need it (e.g. missing a SIC)."""
|
||||||
sub = await client.submissions(cik)
|
updates: list[tuple[int, str | None, str | None]] = []
|
||||||
cik_str = f"{cik:010d}"
|
for cik, ticker_ids in cik_to_ticker_ids.items():
|
||||||
rows = (
|
sub = await client.submissions(cik, include_history=False)
|
||||||
await db.execute(select(Ticker).where(Ticker.cik == cik_str))
|
sic = str(sub["sic"]) if sub.get("sic") else None
|
||||||
).scalars().all()
|
desc = sub.get("sic_description")
|
||||||
for t in rows:
|
for tid in ticker_ids:
|
||||||
t.sic = str(sub["sic"]) if sub.get("sic") else None
|
updates.append((tid, sic, desc))
|
||||||
t.sic_description = sub.get("sic_description")
|
return updates
|
||||||
refreshed += 1
|
|
||||||
return refreshed
|
|
||||||
|
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:
|
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()
|
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:
|
def index_content_hash(index_rows: Iterable[dict]) -> str:
|
||||||
"""Order-independent hash of the tracked index accessions consumed this run."""
|
"""Order-independent hash of the tracked index accessions consumed this run."""
|
||||||
keys = sorted(f"{r['cik']}/{r['accession']}" for r in index_rows)
|
keys = sorted(f"{r['cik']}/{r['accession']}" for r in index_rows)
|
||||||
return hashlib.blake2b("|".join(keys).encode("utf-8"), digest_size=12).hexdigest()
|
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)}"
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ async def test_company_tickers_normalised_and_multiclass():
|
|||||||
assert m["BRK-B"] == 1067983 # dash form
|
assert m["BRK-B"] == 1067983 # dash form
|
||||||
|
|
||||||
|
|
||||||
async def test_submissions_merges_shards_and_filters_forms():
|
async def test_submissions_history_merges_shards_and_filters_forms():
|
||||||
async with _client() as c:
|
async with _client() as c:
|
||||||
sub = await c.submissions(320193)
|
sub = await c.submissions(320193, include_history=True)
|
||||||
assert sub["sic"] == "3571" and sub["fiscal_year_end"] == "0926"
|
assert sub["sic"] == "3571" and sub["fiscal_year_end"] == "0926"
|
||||||
accns = {f["accession"] for f in sub["filings"]}
|
accns = {f["accession"] for f in sub["filings"]}
|
||||||
# 10-Q + 10-K from recent, 10-Q from the shard; the 8-K is filtered out
|
# 10-Q + 10-K from recent, 10-Q from the shard; the 8-K is filtered out
|
||||||
@@ -99,6 +99,20 @@ async def test_submissions_merges_shards_and_filters_forms():
|
|||||||
assert older["report_date"] == "1993-12-31" and older["is_xbrl"] is False
|
assert older["report_date"] == "1993-12-31" and older["is_xbrl"] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_submissions_recent_only_skips_shard_requests():
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
seen.append(str(request.url))
|
||||||
|
return _handler(request)
|
||||||
|
|
||||||
|
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0) as c:
|
||||||
|
sub = await c.submissions(320193) # include_history defaults False
|
||||||
|
assert not any("submissions-001" in u for u in seen) # no shard fetch
|
||||||
|
accns = {f["accession"] for f in sub["filings"]}
|
||||||
|
assert accns == {"0000320193-26-000013", "0000320193-26-000006"} # recent only
|
||||||
|
|
||||||
|
|
||||||
async def test_daily_index_parses_10kq_rows():
|
async def test_daily_index_parses_10kq_rows():
|
||||||
async with _client() as c:
|
async with _client() as c:
|
||||||
rows = await c.daily_index(date(2026, 7, 21))
|
rows = await c.daily_index(date(2026, 7, 21))
|
||||||
@@ -157,3 +171,53 @@ async def test_429_gives_up_after_max_retries(monkeypatch):
|
|||||||
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=2) as c:
|
async with SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=2) as c:
|
||||||
with pytest.raises(SecError):
|
with pytest.raises(SecError):
|
||||||
await c.get_json("https://data.sec.gov/z")
|
await c.get_json("https://data.sec.gov/z")
|
||||||
|
|
||||||
|
|
||||||
|
def _status_client(status):
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(status)
|
||||||
|
# max_retries=0 so 5xx/429 raise immediately (no retry sleeps)
|
||||||
|
return SecClient(transport=httpx.MockTransport(handler), spacing_seconds=0, max_retries=0)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_index_methods_propagate_403():
|
||||||
|
# A 403 must NOT be mistaken for "no index".
|
||||||
|
async with _status_client(403) as c:
|
||||||
|
with pytest.raises(sc.SecForbiddenError):
|
||||||
|
await c.daily_index(date(2026, 7, 21))
|
||||||
|
async with _status_client(403) as c:
|
||||||
|
with pytest.raises(sc.SecForbiddenError):
|
||||||
|
await c.latest_index_date(today=date(2026, 7, 22))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_index_methods_propagate_500():
|
||||||
|
async with _status_client(500) as c:
|
||||||
|
with pytest.raises(SecError):
|
||||||
|
await c.daily_index(date(2026, 7, 21))
|
||||||
|
async with _status_client(500) as c:
|
||||||
|
with pytest.raises(SecError):
|
||||||
|
await c.latest_index_date(today=date(2026, 7, 22))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_only_404_is_treated_as_missing():
|
||||||
|
async with _status_client(404) as c:
|
||||||
|
assert await c.daily_index(date(2026, 7, 21)) == []
|
||||||
|
assert await c.latest_index_date(today=date(2026, 7, 22)) is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fair_access_validation_on_real_client():
|
||||||
|
# Placeholder email rejected.
|
||||||
|
with pytest.raises(SecError):
|
||||||
|
async with SecClient(user_agent="signal-platform (contact: you@example.com)"):
|
||||||
|
pass
|
||||||
|
# Non-email UA rejected.
|
||||||
|
with pytest.raises(SecError):
|
||||||
|
async with SecClient(user_agent="signal-platform"):
|
||||||
|
pass
|
||||||
|
# Valid UA but unsafe production spacing rejected.
|
||||||
|
with pytest.raises(SecError):
|
||||||
|
async with SecClient(user_agent="signal-platform real@person.io", spacing_seconds=0.0):
|
||||||
|
pass
|
||||||
|
# Valid UA + safe spacing opens fine.
|
||||||
|
async with SecClient(user_agent="signal-platform real@person.io", spacing_seconds=0.2):
|
||||||
|
pass
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Tests for CIK/SIC resolution and the composite-revision fingerprint."""
|
"""Tests for CIK/SIC resolution (read-only), apply-in-promote, and the
|
||||||
|
composite-revision fingerprint."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -40,11 +41,11 @@ class FakeSecClient:
|
|||||||
async def company_tickers(self):
|
async def company_tickers(self):
|
||||||
return dict(self._tickers)
|
return dict(self._tickers)
|
||||||
|
|
||||||
async def submissions(self, cik):
|
async def submissions(self, cik, *, include_history=False):
|
||||||
return self._submissions[int(cik)]
|
return self._submissions[int(cik)]
|
||||||
|
|
||||||
|
|
||||||
async def test_resolve_ciks_sets_cik_and_returns_mapping(factory):
|
async def test_resolve_ciks_is_read_only_and_proposes_updates(factory):
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
for sym in ["AAPL", "GOOGL", "GOOG", "ZZZZ"]: # ZZZZ not in SEC
|
for sym in ["AAPL", "GOOGL", "GOOG", "ZZZZ"]: # ZZZZ not in SEC
|
||||||
s.add(Ticker(symbol=sym))
|
s.add(Ticker(symbol=sym))
|
||||||
@@ -52,35 +53,47 @@ async def test_resolve_ciks_sets_cik_and_returns_mapping(factory):
|
|||||||
|
|
||||||
client = FakeSecClient({"AAPL": 320193, "GOOGL": 1652044, "GOOG": 1652044})
|
client = FakeSecClient({"AAPL": 320193, "GOOGL": 1652044, "GOOG": 1652044})
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
mapping = await su.resolve_ciks(s, client)
|
resolved = await su.resolve_ciks(s, client)
|
||||||
await s.commit()
|
assert not s.dirty and not s.new # NOTHING mutated during resolution
|
||||||
|
await s.rollback()
|
||||||
|
|
||||||
assert mapping == {"AAPL": 320193, "GOOGL": 1652044, "GOOG": 1652044}
|
assert resolved.symbol_to_cik == {"AAPL": 320193, "GOOGL": 1652044, "GOOG": 1652044}
|
||||||
|
assert len(resolved.cik_updates) == 3 # AAPL, GOOGL, GOOG (ZZZZ unresolved)
|
||||||
|
assert set(resolved.cik_to_ticker_ids) == {320193, 1652044}
|
||||||
|
|
||||||
|
# Read-only really means the DB is untouched until apply.
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
ciks = {t.symbol: t.cik for t in (await s.execute(select(Ticker))).scalars()}
|
ciks = {t.symbol: t.cik for t in (await s.execute(select(Ticker))).scalars()}
|
||||||
assert ciks["AAPL"] == "0000320193"
|
assert all(v is None for v in ciks.values())
|
||||||
assert ciks["GOOGL"] == ciks["GOOG"] == "0001652044" # multi-class share CIK
|
|
||||||
assert ciks["ZZZZ"] is None # unresolved stays null
|
|
||||||
|
|
||||||
|
|
||||||
async def test_refresh_sic_updates_all_tickers_of_a_cik(factory):
|
async def test_apply_ticker_updates_writes_cik_and_sic(factory):
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
for sym in ["GOOGL", "GOOG"]:
|
for sym in ["GOOGL", "GOOG"]:
|
||||||
s.add(Ticker(symbol=sym, cik="0001652044"))
|
s.add(Ticker(symbol=sym))
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
client = FakeSecClient(
|
client = FakeSecClient(
|
||||||
{}, submissions={1652044: {"sic": "7370", "sic_description": "Services-Computer"}}
|
{"GOOGL": 1652044, "GOOG": 1652044},
|
||||||
|
submissions={1652044: {"sic": "7370", "sic_description": "Services-Computer"}},
|
||||||
)
|
)
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
n = await su.refresh_sic(s, client, [1652044])
|
resolved = await su.resolve_ciks(s, client)
|
||||||
|
sic_updates = await su.fetch_sic_updates(client, resolved.cik_to_ticker_ids)
|
||||||
|
counts = await su.apply_ticker_updates(s, resolved, sic_updates)
|
||||||
await s.commit()
|
await s.commit()
|
||||||
|
|
||||||
assert n == 1
|
assert counts == {"cik_updates": 2, "sic_updates": 2}
|
||||||
async with factory() as s:
|
async with factory() as s:
|
||||||
rows = {t.symbol: (t.sic, t.sic_description) for t in (await s.execute(select(Ticker))).scalars()}
|
rows = {t.symbol: (t.cik, t.sic, t.sic_description) for t in (await s.execute(select(Ticker))).scalars()}
|
||||||
assert rows["GOOGL"] == ("7370", "Services-Computer")
|
assert rows["GOOGL"] == ("0001652044", "7370", "Services-Computer")
|
||||||
assert rows["GOOG"] == ("7370", "Services-Computer")
|
assert rows["GOOG"] == ("0001652044", "7370", "Services-Computer")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_sic_updates_is_read_only(factory):
|
||||||
|
client = FakeSecClient({}, submissions={1: {"sic": "1", "sic_description": "x"}})
|
||||||
|
updates = await su.fetch_sic_updates(client, {1: [10, 11]})
|
||||||
|
assert updates == [(10, "1", "x"), (11, "1", "x")] # proposals only, no DB touched
|
||||||
|
|
||||||
|
|
||||||
def test_universe_fingerprint_changes_on_membership():
|
def test_universe_fingerprint_changes_on_membership():
|
||||||
@@ -93,11 +106,13 @@ def test_universe_fingerprint_changes_on_membership():
|
|||||||
assert a != remapped # changed CIK mapping forces a new revision
|
assert a != remapped # changed CIK mapping forces a new revision
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_revision_rejects_missing_index_date():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
su.compose_revision(None, "abc", {"AAPL": 320193})
|
||||||
|
|
||||||
|
|
||||||
def test_compose_revision_and_index_hash():
|
def test_compose_revision_and_index_hash():
|
||||||
rows = [
|
rows = [{"cik": 320193, "accession": "a-1"}, {"cik": 66740, "accession": "b-2"}]
|
||||||
{"cik": 320193, "accession": "a-1"},
|
|
||||||
{"cik": 66740, "accession": "b-2"},
|
|
||||||
]
|
|
||||||
h1 = su.index_content_hash(rows)
|
h1 = su.index_content_hash(rows)
|
||||||
h2 = su.index_content_hash(list(reversed(rows)))
|
h2 = su.index_content_hash(list(reversed(rows)))
|
||||||
assert h1 == h2 # order-independent
|
assert h1 == h2 # order-independent
|
||||||
|
|||||||
Reference in New Issue
Block a user